From 49578ce21dce62c68e177e704329ac42d63f4bda Mon Sep 17 00:00:00 2001 From: mvelam850 Date: Wed, 8 Apr 2026 12:11:11 -0500 Subject: [PATCH 01/17] feat(docker): add thread radio support in docker Add an optional otbr-radio Docker container that connects a Silicon Labs BRD2703 xG24 Explorer Kit to the Barton devcontainer via D-Bus, enabling Thread integration testing with real hardware. - Dockerfile.otbr-radio: builds image with cpcd and otbr-agent compiled for CPC transport; uses the URL spinel+cpc://cpcd_0?iid=1&iid-list=0 to handle both unicast and broadcast Spinel frames - compose.otbr-radio.yaml: compose overlay that shares a D-Bus socket volume with the barton service and maps RADIO_DEVICE at the same path on both the host and container sides - otbr-radio-entrypoint.sh: writes a fresh cpcd config on every start (uart_device_file, uart_hardflow: true), waits for "Daemon startup was successful" before launching otbr-agent, and auto-detects BACKBONE_IF from the host default route at runtime - setupDockerEnv.sh: auto-detects the backbone interface via ip route and writes it to docker/.env; Refs: BARTON-359 Signed-off-by: mvelam850 --- docker/Dockerfile.otbr-radio | 188 ++++++++++++++++++ docker/README.md | 2 + docker/compose.otbr-radio.yaml | 114 +++++++++++ docker/otbr-radio-entrypoint.sh | 162 ++++++++++++++++ docker/setupDockerEnv.sh | 26 +++ dockerw | 22 ++- docs/THREAD_BORDER_ROUTER_SUPPORT.md | 273 ++++++++++++++++++++++++++- 7 files changed, 781 insertions(+), 6 deletions(-) create mode 100644 docker/Dockerfile.otbr-radio create mode 100644 docker/compose.otbr-radio.yaml create mode 100755 docker/otbr-radio-entrypoint.sh diff --git a/docker/Dockerfile.otbr-radio b/docker/Dockerfile.otbr-radio new file mode 100644 index 00000000..53868c50 --- /dev/null +++ b/docker/Dockerfile.otbr-radio @@ -0,0 +1,188 @@ +# ------------------------------ tabstop = 4 ---------------------------------- +# +# If not stated otherwise in this file or this component's LICENSE file the +# following copyright and licenses apply: +# +# Copyright 2026 Comcast Cable Communications Management, LLC +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# +# ------------------------------ tabstop = 4 ---------------------------------- + +# +# Dockerfile for the optional Thread Border Router container that supports a +# real Silabs USB radio (e.g., BRD2703 xG24 Explorer Kit). +# +# This container runs two daemons: +# 1. cpcd - Silicon Labs Co-Processor Communication daemon. Manages the +# USB serial link to the physical radio using the CPC protocol. +# 2. otbr-agent - OpenThread Border Router agent. Implements the Thread +# network stack and exposes it over D-Bus so Barton can consume it. +# +# The container is NOT built or started by default. It is activated by: +# - CLI: ./dockerw -T bash +# - Devcontainer: add ../docker/compose.otbr-radio.yaml to dockerComposeFile +# in .devcontainer/devcontainer.json +# +# See docs/THREAD_BORDER_ROUTER_SUPPORT.md for full setup instructions, +# including how to use USB-IP to forward a locally connected radio to a remote +# dev server. +# + +FROM ubuntu:24.04 AS base + +RUN apt-get update && apt-get -y upgrade && DEBIAN_FRONTEND='noninteractive' apt-get install -y \ + build-essential \ + cmake \ + git \ + pkg-config \ + wget \ + unzip \ + ninja-build \ + lsb-release \ + sudo \ + jq \ + meson \ + libssl-dev \ + libmbedtls-dev \ + libdbus-1-dev \ + libavahi-client-dev \ + libavahi-common-dev \ + avahi-daemon \ + dbus \ + libsystemd-dev \ + libcurl4-openssl-dev \ + uuid-dev \ + libxml2-dev \ + libreadline-dev \ + libyaml-cpp-dev \ + checkinstall \ + socat \ + iproute2 \ + iptables \ + ipset \ + udev \ + libglib2.0-bin \ + && rm -rf /var/lib/apt/lists/* + +# Copy over the third party patches (shared with the main Dockerfile) +ADD patches /tmp/patches +ARG APPLY_PATCHES_PATH="/tmp/patches/applyPatches.sh" +RUN chmod +x ${APPLY_PATCHES_PATH} + +############################################################################### +# cpcd — Silicon Labs Co-Processor Communication Daemon +# +# cpcd manages the USB serial link to the physical Silabs radio (e.g., BRD2703) +# using the CPC multiplexing protocol. otbr-agent connects to it via the +# spinel+cpc:// URI scheme instead of directly to the serial port. +# +# Source: https://github.com/SiliconLabs/cpc-daemon +############################################################################### +ARG CPCD_GIT_TAG="v4.5.2" + +RUN cd /tmp && \ + git clone --depth 1 --branch ${CPCD_GIT_TAG} \ + https://github.com/SiliconLabs/cpc-daemon.git && \ + cd cpc-daemon && \ + mkdir build && cd build && \ + cmake .. \ + -DCMAKE_BUILD_TYPE=Release \ + -DCPC_SECURITY_ENABLED=FALSE && \ + make -j$(nproc) && \ + make install && \ + ldconfig && \ + rm -rf /tmp/cpc-daemon + +############################################################################### +# otbr-agent — OpenThread Border Router Agent +# +# Built from upstream ot-br-posix, with Silicon Labs Gecko SDK vendor transport +# files layered in to enable CPC/multi-PAN support for `spinel+cpc://`. +############################################################################### +ARG GSDK_GIT_BRANCH="gsdk_4.4" +ARG GSDK_GIT_REV="59d2160fe448b13730a91c0f019a8b4c53c43eb2" +ARG OTBR_GIT_TAG="731e115257d295a1ec64ea2f60e17a43e43892f6" + +RUN cd /tmp && \ + git clone --depth 1 --branch ${GSDK_GIT_BRANCH} \ + https://github.com/SiliconLabs/gecko_sdk.git gecko_sdk && \ + cd gecko_sdk && \ + git fetch --depth 1 origin ${GSDK_GIT_REV} && \ + git checkout ${GSDK_GIT_REV} && \ + cd /tmp && \ + git clone --depth 1 https://github.com/openthread/ot-br-posix && \ + cd ot-br-posix && \ + git fetch --depth 1 origin ${OTBR_GIT_TAG} && \ + git checkout ${OTBR_GIT_TAG} && \ + ${APPLY_PATCHES_PATH} /tmp/patches/otbr . && \ + cd script && \ + OTBR_MDNS=avahi ./bootstrap && \ + ./cmake-build \ + -DOTBR_DUA_ROUTING=ON \ + -DOTBR_DNSSD_DISCOVERY_PROXY=ON \ + -DOTBR_SRP_ADVERTISING_PROXY=ON \ + -DOTBR_BORDER_ROUTING=ON \ + -DOTBR_TREL=ON \ + -DOTBR_NAT64=ON \ + -DOT_TREL=ON \ + -DOT_DNS_CLIENT=ON \ + -DOT_DNSSD_SERVER=ON \ + -DOT_SRP_CLIENT=ON \ + -DOT_SRP_SERVER=ON \ + -DOT_THREAD_VERSION=1.4 \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CXX_FLAGS=-Wno-error=unused-but-set-variable \ + -DOT_DHCP6_CLIENT=ON \ + -DOT_DHCP6_SERVER=ON \ + -DOTBR_DHCP6_PD=OFF \ + -DOTBR_DNS_UPSTREAM_QUERY=ON \ + -DBUILD_TESTING=OFF \ + -DOTBR_COVERAGE=OFF \ + -DOT_MLR=ON \ + -DOTBR_DBUS=ON \ + -DOT_PLATFORM=posix \ + -DOT_PLATFORM_CONFIG=/tmp/gecko_sdk/protocol/openthread/platform-abstraction/posix/openthread-core-silabs-posix-config.h \ + -DOT_ECDSA=ON \ + -DOT_FIREWALL=OFF \ + -DOT_CHANNEL_MANAGER=OFF \ + -DOT_CHANNEL_MONITOR=OFF \ + -DOTBR_BACKBONE_ROUTER=ON \ + -DOT_BACKBONE_ROUTER_DUA_NDPROXYING=ON \ + -DOT_BACKBONE_ROUTER_MULTICAST_ROUTING=ON \ + -DOTBR_MDNS=avahi \ + -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ + -DOTBR_TELEMETRY_DATA_API=ON \ + -DOT_CLI_VENDOR_EXTENSION=/tmp/gecko_sdk/protocol/openthread/platform-abstraction/posix/posix_vendor_cli.cmake \ + -DOT_MULTIPAN_RCP=ON \ + -DOT_POSIX_CONFIG_RCP_VENDOR_DEPS_PACKAGE=/tmp/gecko_sdk/protocol/openthread/platform-abstraction/posix/posix_vendor_rcp.cmake \ + -DOT_POSIX_CONFIG_RCP_VENDOR_INTERFACE=/tmp/gecko_sdk/protocol/openthread/platform-abstraction/posix/cpc_interface.cpp \ + -DOT_POSIX_RCP_VENDOR_BUS=ON \ + && \ + cd ../build/otbr/ && \ + ninja install && \ + cp -a /tmp/ot-br-posix/third_party/openthread/repo/include/openthread /usr/local/include && \ + rm -rf /tmp/ot-br-posix /tmp/gecko_sdk + +# D-Bus policy allowing otbr-agent to own its well-known bus name +COPY otbr-agent.conf /etc/dbus-1/system.d/ + +# Clean up patches +RUN rm -rf /tmp/patches + +COPY otbr-radio-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/README.md b/docker/README.md index 7a64fc18..33b36827 100644 --- a/docker/README.md +++ b/docker/README.md @@ -16,6 +16,7 @@ service, and optional overlays add or override behavior: | `compose.yaml` | Base service definition (`barton`), network, volumes | | `compose.devcontainer.yaml` | Overrides the `barton` service image/build for VS Code devcontainers (bakes the user into the image at build time) | | `compose.host-network.yaml` | Switches to host networking for direct device access (e.g., Thread border routers) | +| `compose.otbr-radio.yaml` | Optional — adds the `otbr-radio` container (cpcd + otbr-agent) for real USB Thread radio support via BRD2703 | All overlays operate on the same `barton` service from `compose.yaml`. They are combined by listing them in order -- either in the `dockerComposeFile` array in `.devcontainer/devcontainer.json` @@ -40,6 +41,7 @@ It always includes `compose.yaml` as the base, and accepts flags to layer on ove | `-e ` | Pass extra environment variables | | `-d` | Mount development volumes | | `-n` | Disable TTY (non-interactive mode) | +| `-T` | Start the optional `otbr-radio` container (requires BRD2703 USB radio, see `docs/THREAD_BORDER_ROUTER_SUPPORT.md`) | Example: diff --git a/docker/compose.otbr-radio.yaml b/docker/compose.otbr-radio.yaml new file mode 100644 index 00000000..e31aa869 --- /dev/null +++ b/docker/compose.otbr-radio.yaml @@ -0,0 +1,114 @@ +# ------------------------------ tabstop = 4 ---------------------------------- +# +# If not stated otherwise in this file or this component's LICENSE file the +# following copyright and licenses apply: +# +# Copyright 2026 Comcast Cable Communications Management, LLC +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# +# ------------------------------ tabstop = 4 ---------------------------------- + +# +# Optional Docker Compose overlay that adds a real Thread Border Router +# container backed by a Silabs BRD2703 xG24 Explorer Kit USB radio. +# +# The container runs: +# - cpcd : Silicon Labs CPC daemon (USB serial <-> CPC socket) +# - otbr-agent : OpenThread Border Router agent (CPC socket <-> D-Bus API) +# +# A named volume (dbus-socket) shares the D-Bus socket directory between this +# container and the main barton/devcontainer so that Barton code can reach +# otbr-agent over D-Bus exactly as it does with the simulated setup. +# +# Running this overlay is OPTIONAL. Do not include it unless a physical radio +# is available (either locally attached or forwarded via USB-IP). +# +# ─── Usage ─────────────────────────────────────────────────────────────────── +# +# CLI (dockerw) +# ./dockerw -T bash +# +# Devcontainer +# Add this file to the dockerComposeFile array in +# .devcontainer/devcontainer.json: +# +# "dockerComposeFile": [ +# "../docker/compose.devcontainer.yaml", +# "../docker/compose.otbr-radio.yaml" +# ] +# +# ─── Environment variables ──────────────────────────────────────────────────── +# +# RADIO_DEVICE Host path of the USB serial device. Default: /dev/ttyACM0 +# Set in docker/.env (generated by docker/setupDockerEnv.sh) +# or export before running: +# export RADIO_DEVICE=/dev/ttyACM1 +# +# BACKBONE_IF Network interface for the Thread backbone. Default: the +# host default-route interface detected by +# docker/setupDockerEnv.sh. +# +# ─── USB-IP (remote radio forwarding) ──────────────────────────────────────── +# +# If your dev server is remote and the radio is on your local workstation, +# see docs/THREAD_BORDER_ROUTER_SUPPORT.md for USB-IP setup instructions. +# +# ───────────────────────────────────────────────────────────────────────────── + +services: + otbr-radio: + build: + context: . + dockerfile: Dockerfile.otbr-radio + # privileged is required for: + # - creating the wpan0 network interface + # - configuring iptables rules for Thread border routing + # - accessing the USB serial device via /dev/ttyACM* + privileged: true + # Host networking is required so that: + # - the wpan0 Thread interface is visible on the host network stack + # - mDNS/Avahi advertisements reach the LAN + # - NAT64 routing functions correctly + network_mode: host + volumes: + # Share the D-Bus socket directory with the barton/devcontainer + # service so Barton code can talk to otbr-agent over D-Bus. + - dbus-socket:/var/run/dbus + devices: + # Pass through the Silabs USB radio to the container at the same + # path as the host so RADIO_DEVICE matches inside the container. + - "${RADIO_DEVICE:-/dev/ttyACM0}:${RADIO_DEVICE:-/dev/ttyACM0}" + environment: + - RADIO_DEVICE=${RADIO_DEVICE:-/dev/ttyACM0} + - BACKBONE_IF=${BACKBONE_IF:-} + # Restart automatically if cpcd/otbr-agent crash, unless the container + # was explicitly stopped. + restart: unless-stopped + + # Extend the barton service to mount the shared D-Bus socket volume. + # This grants Barton access to the otbr-agent D-Bus API running in the + # otbr-radio container without any code changes. + # In devcontainer mode the devcontainer service extends barton, so it + # inherits this volume automatically. + barton: + volumes: + - dbus-socket:/var/run/dbus + +volumes: + # Named volume that shares /var/run/dbus between the otbr-radio container + # (where otbr-agent writes its D-Bus socket) and the devcontainer/barton + # container (where Barton reads it). + dbus-socket: diff --git a/docker/otbr-radio-entrypoint.sh b/docker/otbr-radio-entrypoint.sh new file mode 100755 index 00000000..77921ae6 --- /dev/null +++ b/docker/otbr-radio-entrypoint.sh @@ -0,0 +1,162 @@ +#!/bin/bash +# ------------------------------ tabstop = 4 ---------------------------------- +# +# If not stated otherwise in this file or this component's LICENSE file the +# following copyright and licenses apply: +# +# Copyright 2026 Comcast Cable Communications Management, LLC +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# +# ------------------------------ tabstop = 4 ---------------------------------- + +# +# Entrypoint for the otbr-radio container. Starts services in order: +# 1. D-Bus system bus +# 2. avahi-daemon (mDNS/DNS-SD — required by otbr-agent built with OTBR_MDNS=avahi) +# 3. cpcd (Silicon Labs CPC daemon — USB serial ↔ CPC socket) +# 4. otbr-agent (Thread Border Router — CPC socket ↔ D-Bus API) +# +# Environment variables (all have defaults): +# RADIO_DEVICE - host path of the USB serial device, e.g. /dev/ttyACM0 +# BACKBONE_IF - network interface to use as the Thread backbone, e.g. eth0 +# CPCD_CONF - path to cpcd config file +# +# See docs/THREAD_BORDER_ROUTER_SUPPORT.md for USB-IP setup and hardware info. +# + +set -e + +RADIO_DEVICE="${RADIO_DEVICE:-/dev/ttyACM0}" +CPCD_CONF="${CPCD_CONF:-/usr/local/etc/cpcd.conf}" + +# Resolve the backbone interface. +# 1. Use BACKBONE_IF from the environment if explicitly set and the interface exists. +# 2. Otherwise auto-detect from the default route. +# Never fall back silently to a wrong interface — fail fast with a clear message. +if [ -n "${BACKBONE_IF}" ] && ip link show "${BACKBONE_IF}" >/dev/null 2>&1; then + echo "[otbr-radio] Using backbone interface from environment: ${BACKBONE_IF}" +else + if [ -n "${BACKBONE_IF}" ]; then + echo "[otbr-radio] WARNING: BACKBONE_IF '${BACKBONE_IF}' not found; auto-detecting..." + fi + BACKBONE_IF=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}') + if [ -z "${BACKBONE_IF}" ]; then + echo "[otbr-radio] ERROR: Could not detect a backbone interface. Set BACKBONE_IF explicitly." >&2 + exit 1 + fi + echo "[otbr-radio] Auto-detected backbone interface: ${BACKBONE_IF}" +fi + +############################################################################### +# 1. Start D-Bus system bus +# +# The /var/run/dbus directory is mounted from the persistent named volume +# 'dbus-socket'. If the container previously crashed or was restarted, the +# pid file from the previous run may still be present, causing dbus-daemon to +# refuse to start. Clean it up if the recorded PID is no longer alive. +############################################################################### +echo "[otbr-radio] Starting D-Bus system bus..." +mkdir -p /var/run/dbus +if [ -f /var/run/dbus/pid ]; then + dbusPid=$(cat /var/run/dbus/pid 2>/dev/null || true) + if [ -n "${dbusPid}" ] && kill -0 "${dbusPid}" 2>/dev/null; then + echo "[otbr-radio] D-Bus already running (PID ${dbusPid}), skipping start." + else + echo "[otbr-radio] Removing stale D-Bus pid/socket files..." + rm -f /var/run/dbus/pid /var/run/dbus/system_bus_socket + dbus-daemon --system --fork + fi +else + dbus-daemon --system --fork +fi +echo "[otbr-radio] D-Bus started." + +############################################################################### +# 2. Validate the USB radio device +############################################################################### +if [ ! -e "${RADIO_DEVICE}" ]; then + echo "[otbr-radio] ERROR: USB radio device '${RADIO_DEVICE}' not found." >&2 + echo "[otbr-radio] Check that the BRD2703 is connected and USB-IP is attached." >&2 + echo "[otbr-radio] See docs/THREAD_BORDER_ROUTER_SUPPORT.md for setup instructions." >&2 + exit 1 +fi +echo "[otbr-radio] Using USB radio device: ${RADIO_DEVICE}" + +############################################################################### +# 3. Start Avahi daemon (required by otbr-agent — built with OTBR_MDNS=avahi) +############################################################################### +echo "[otbr-radio] Starting avahi-daemon..." +mkdir -p /var/run/avahi-daemon +avahi-daemon --daemonize --no-chroot +echo "[otbr-radio] avahi-daemon started." + +############################################################################### +# 4. Configure and start cpcd +# +# Always write a fresh config so we fully control all keys. +# The cpcd installed config uses 'uart_device_file' (not 'uart_device'), so +# patching the existing file would silently fail leaving the wrong device path. +############################################################################### +echo "[otbr-radio] Writing cpcd config to ${CPCD_CONF}..." +mkdir -p "$(dirname "${CPCD_CONF}")" +cat > "${CPCD_CONF}" <&1 | tee "${CPCD_LOG}" & +CPCD_PID=$! + +# Wait until cpcd logs its ready message, meaning it has fully connected to the +# secondary and is accepting client connections. +CPCD_WAIT_MAX=30 +cpcdWaitCount=0 +echo "[otbr-radio] Waiting for cpcd to be ready..." +while ! grep -q "Daemon startup was successful" "${CPCD_LOG}" 2>/dev/null; do + if ! kill -0 ${CPCD_PID} 2>/dev/null; then + echo "[otbr-radio] ERROR: cpcd exited before becoming ready. Check device and firmware." >&2 + cat "${CPCD_LOG}" >&2 + exit 1 + fi + if [ ${cpcdWaitCount} -ge ${CPCD_WAIT_MAX} ]; then + echo "[otbr-radio] ERROR: cpcd did not become ready after ${CPCD_WAIT_MAX}s." >&2 + cat "${CPCD_LOG}" >&2 + exit 1 + fi + sleep 1 + cpcdWaitCount=$((cpcdWaitCount + 1)) +done +echo "[otbr-radio] cpcd ready (PID ${CPCD_PID}, waited ${cpcdWaitCount}s)." + +############################################################################### +# 5. Start otbr-agent +# +# Uses spinel+cpc:// instead of the simulated spinel+hdlc+forkpty:// URI +# that is used by scripts/start-simulated-otbr.sh. +############################################################################### +echo "[otbr-radio] Starting otbr-agent (backbone: ${BACKBONE_IF})..." +exec otbr-agent \ + -I wpan0 \ + -B "${BACKBONE_IF}" \ + -d 7 \ + -v \ + "spinel+cpc://cpcd_0?iid=1&iid-list=0" diff --git a/docker/setupDockerEnv.sh b/docker/setupDockerEnv.sh index fed9d99b..fb35d992 100755 --- a/docker/setupDockerEnv.sh +++ b/docker/setupDockerEnv.sh @@ -182,6 +182,32 @@ echo "BARTON_PYTHONPATH=/usr/local/lib/python3.x/dist-packages:/usr/lib/python3/ echo "LIB_BARTON_SHARED_PATH=/usr/local/lib" >> $OUTFILE ############################################################################## +############################################################################## +# Optional Thread real-radio variables (used by docker/compose.otbr-radio.yaml). +# +# RADIO_DEVICE: host path of the Silabs USB radio serial device. +# - Default /dev/ttyACM0 suits the BRD2703 xG24 Explorer Kit. +# - Override by exporting RADIO_DEVICE before running setupDockerEnv.sh or +# dockerw, e.g.: export RADIO_DEVICE=/dev/ttyACM1 +# +# BACKBONE_IF: network interface used by otbr-agent for Thread backbone routing. +# - Defaults to the host default-route interface when detectable. +# - Left empty if detection fails; the container entrypoint will re-detect at +# runtime and exit with an error if no interface can be found. +# - Override by exporting BACKBONE_IF before running setupDockerEnv.sh or +# dockerw, e.g.: export BACKBONE_IF=enp6s0 +# +# These variables are only consumed when compose.otbr-radio.yaml is included +# in the compose stack (dockerw -T, or devcontainer override). +# Auto-detect the default-route network interface for the Thread backbone. +# The entrypoint will also re-detect at runtime, so this is only used when +# BACKBONE_IF is not already set in the environment. +detectedBackboneIf=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}') + +echo "RADIO_DEVICE=${RADIO_DEVICE:-/dev/ttyACM0}" >> $OUTFILE +echo "BACKBONE_IF=${BACKBONE_IF:-$detectedBackboneIf}" >> $OUTFILE +############################################################################## + # Ensure the container network exists NETWORK_NAME="$USER-$BARTON_WORKSPACE_ID-barton-ip6net" _SUBNET_HASH=$(echo "$USER-$BARTON_WORKSPACE_ID" | sha256sum | cut -c1-8) diff --git a/dockerw b/dockerw index 7efb7f55..be1d833f 100755 --- a/dockerw +++ b/dockerw @@ -38,8 +38,11 @@ COMPOSE_INTERACTIVE_FLAGS="" EXTRA_ENV="-e SSH_AUTH_SOCK" DEV_VOL="" HOST_NETWORK="" +OTBR_RADIO_COMPOSE="" -while getopts ":ne:dH" opt; do +DIR=$(pwd) + +while getopts ":ne:dHT" opt; do case ${opt} in n) # process option n COMPOSE_INTERACTIVE_FLAGS="--no-TTY" @@ -53,12 +56,13 @@ while getopts ":ne:dH" opt; do H) # use host networking (useful for Matter device access) HOST_NETWORK="--network host" ;; + T) # spin up the real Thread radio OTBR container (requires BRD2703 + cpcd) + OTBR_RADIO_COMPOSE="-f ${DIR}/docker/compose.otbr-radio.yaml" + ;; esac done shift "$(($OPTIND - 1))" -DIR=$(pwd) - # generate the `docker/.env` file and ensure the network/image is created $DIR/docker/setupDockerEnv.sh @@ -69,12 +73,22 @@ EXTRA_VOLUMES="${DEV_VOL}" VOLUMES_ARGS="-v $HOME:$HOME $EXTRA_VOLUMES" +# If the -T flag was given, build and start the otbr-radio container in the +# background before opening the barton shell. The container runs cpcd and +# otbr-agent and shares its D-Bus socket with the barton service so that +# Barton code can reach otbr-agent without any extra steps inside the shell. +if [ -n "${OTBR_RADIO_COMPOSE}" ]; then + notice "Starting otbr-radio container (cpcd + otbr-agent)..." + docker compose -f "${COMPOSE_FILE}" ${OTBR_RADIO_COMPOSE} up -d otbr-radio + notice "otbr-radio container is up. cpcd and otbr-agent are running in the background." +fi + # create a unique container name prefixed with the user id, useful when on a shared machine USER_ID=$(id -u -n) RANDOM_NUMBER=$(shuf -i 100000-999999 -n 1) CONTAINER_NAME="${USER_ID}_${RANDOM_NUMBER}" -docker compose -f "${COMPOSE_FILE}" run \ +docker compose -f "${COMPOSE_FILE}" ${OTBR_RADIO_COMPOSE} run \ --rm \ --name ${CONTAINER_NAME} \ ${COMPOSE_INTERACTIVE_FLAGS} \ diff --git a/docs/THREAD_BORDER_ROUTER_SUPPORT.md b/docs/THREAD_BORDER_ROUTER_SUPPORT.md index 1a205501..a1dfa93a 100644 --- a/docs/THREAD_BORDER_ROUTER_SUPPORT.md +++ b/docs/THREAD_BORDER_ROUTER_SUPPORT.md @@ -1,3 +1,272 @@ -# Adding Thread Border Router Support to Barton +# Thread Border Router Support -If Thread support is enabled, Barton will attempt to communicate with the `otbr-agent` over dbus. Be sure to also enable Thread and Border Router support in the Matter SDK if Matter is also enabled. +Barton communicates with `otbr-agent` over D-Bus when Thread support is enabled +(`BCORE_THREAD=ON`). Two modes are supported: + +| Mode | When to use | How to start | +|------|-------------|-------------| +| **Simulated** (default) | Unit/integration tests, no hardware needed | `scripts/start-simulated-otbr.sh` | +| **Real USB radio** | Testing with real Thread devices | See below | + +> **Note**: Be sure to enable Thread and Border Router support in the Matter SDK +> (`-DOT_THREAD_VERSION=1.4 -DOTBR_BORDER_ROUTING=ON`) if Matter is also enabled. + +--- + +## Real USB Radio: BRD2703 xG24 Explorer Kit + +The real-radio mode uses a **Silicon Labs BRD2703 xG24 Explorer Kit** connected over USB. +An optional Docker container (`otbr-radio`) runs two daemons: + +- **`cpcd`** — Silicon Labs CPC daemon. Manages the USB serial link to the radio + using the CPC multiplexing protocol. +- **`otbr-agent`** — OpenThread Border Router agent. Connects to `cpcd` via + `spinel+cpc://cpcd_0` and exposes the Thread network on D-Bus. + +A named Docker volume (`dbus-socket`) shares `/var/run/dbus` between the +`otbr-radio` container and the Barton devcontainer so that Barton can reach +`otbr-agent` over D-Bus with no code changes. + +``` +BRD2703 USB Radio + │ /dev/ttyACM0 + ▼ + cpcd (CPC daemon — hardware abstraction) + │ spinel+cpc://cpcd_0 + ▼ + otbr-agent (Thread network stack) + │ D-Bus io.openthread.BorderRouter.wpan0 + ▼ + Barton (your application code) +``` + +--- + +## Flashing RCP Firmware onto the BRD2703 + +Before use, the BRD2703 must be flashed with a CPC-capable **RCP (Radio Co-Processor)** +firmware image. + +If the board is new (out of the box), its debug adapter firmware must be updated first. +Connect the board to **Simplicity Studio** and accept any firmware upgrade prompts before +proceeding. + +Download the firmware image appropriate for the BRD2703 (EFR32MG24) in either +`.s37` or `.hex` format, then flash it using +[Simplicity Commander](https://www.silabs.com/developers/simplicity-studio). + +After flashing the RCP image, lock the security bits: + +```bash +sudo commander security lock +``` + +--- + +## USB-IP: Forwarding the Radio from Laptop to Remote Dev Server + +If your devcontainer runs on a **remote server** (common with VS Code Remote-SSH) +but the BRD2703 is plugged into your **local laptop**, use +[USB-IP](https://www.kernel.org/doc/html/latest/usb/usbip_protocol.html) to +forward the USB device over the network. + +### On the laptop (USB-IP server — where the radio is plugged in) + +```bash +# Update package lists +sudo apt update + +# Install usbip tools +sudo apt install linux-tools-generic linux-cloud-tools-generic + +# Load the kernel module +sudo modprobe usbip_host + +# Find the bus ID of the BRD2703 (look for "Silicon Labs" or "CP210x") +usbip list -l +# Example output: +# - busid 1-2 (10c4:ea60) +# Silicon Labs : CP210x UART Bridge (10c4:ea60) + +# Bind the device to usbip +sudo usbip bind -b + +# If the bind fails, check the correct bus ID and load the kernel modules, then retry +sudo modprobe usbip-core +sudo modprobe usbip-host +sudo modprobe vhci-hcd + +# Start the USB-IP daemon (listens on TCP port 3240) +sudo usbipd -D + +# Confirm that port 3240 is listening +netstat -an | grep 3240 + +# Due to firewall restrictions on the laptop, the remote server cannot connect +# to port 3240 directly. Use an SSH reverse tunnel to work around this. +# Keep this terminal open while working with the device. +ssh -R 3240:localhost:3240 @ +``` + +### On the remote dev server (USB-IP client — where Docker runs) + +```bash +# Install usbip tools +sudo apt install linux-tools-generic hwdata + +# Load the kernel module +sudo modprobe vhci-hcd + +# List the available devices via the tunnel and confirm the BRD2703 is visible +usbip list -r 127.0.0.1 + +# Attach the remote device (replace the bus ID as appropriate) +sudo usbip attach -r 127.0.0.1 -b + +# Confirm the device appeared +ls -la /dev/ttyACM* +# Expected: /dev/ttyACM0 +``` + +> **Tip**: If the device appears at `/dev/ttyACM1` or another path, set +> `RADIO_DEVICE` before starting: +> ```bash +> export RADIO_DEVICE=/dev/ttyACM1 +> ``` + +--- + +## Starting the Real-Radio Container + +### CLI (`dockerw`) + +Use the `-T` flag. This adds `docker/compose.otbr-radio.yaml` to the compose +stack and starts the `otbr-radio` container alongside the main Barton container: + +```bash +./dockerw -T +``` + +To override the radio device or backbone interface: + +```bash +RADIO_DEVICE=/dev/ttyACM1 BACKBONE_IF=eth1 ./dockerw -T +``` + +This creates two containers: the `barton` container and the `otbr-radio` container. + +To check the logs and confirm all services started correctly: + +```bash +docker compose -f docker/compose.yaml -f docker/compose.otbr-radio.yaml logs -f otbr-radio +``` + +Expected log progression: + +- `Using USB radio device: /dev/ttyACM1` (or your selected device) +- `Connected to Secondary` +- `Secondary CPC v4.4.6` +- `Daemon startup was successful. Waiting for client connections` +- `Starting otbr-agent` + +To enter the `otbr-radio` container, find its container ID and run: + +```bash +docker exec -it /bin/bash +``` + +Verify that `otbr-agent` is running: + +```bash +pgrep -a otbr-agent +``` + +Check the runtime cpcd configuration: + +```bash +cat /usr/local/etc/cpcd.conf +``` + +Check the Thread stack state inside otbr-radio container: + +```bash +ot-ctl state +``` + +If `ot-ctl state` returns `disabled`, the radio and `otbr-agent` are healthy but no thread network has been formed yet. Run the following commands to bring up the interface and form a new network: + +```bash +ot-ctl dataset init new +ot-ctl dataset commit active +ot-ctl ifconfig up +ot-ctl thread start +# After few seconds check the state +ot-ctl state +``` + +Expected result: + +- `leader` — this node formed a new Thread network +- `router` — this node joined an existing Thread network + +The `otbr-radio` container continues running after you exit the shell. To stop and +remove it explicitly: + +```bash +docker compose -f docker/compose.yaml -f docker/compose.otbr-radio.yaml rm -sf otbr-radio +``` + +### Devcontainer (VS Code) + +Add `compose.otbr-radio.yaml` to the `dockerComposeFile` array in +`.devcontainer/devcontainer.json`: + +```jsonc +"dockerComposeFile": [ + "../docker/compose.devcontainer.yaml", + "../docker/compose.otbr-radio.yaml" // add this line +], +``` + +Then rebuild the devcontainer (**Dev Containers: Rebuild Container**). + +To set a non-default radio device, export `RADIO_DEVICE` in your shell before +opening VS Code, or set it in `docker/.env` after running +`docker/setupDockerEnv.sh`. + +--- + +## Verifying the D-Bus Interface + +### From inside the `otbr-radio` container + +`gdbus` is available and can be used to query the OTBR D-Bus service: + +```bash +gdbus introspect --system \ + --dest io.openthread.BorderRouter.wpan0 \ + --object-path /io/openthread/BorderRouter/wpan0 +``` + +To read the current Thread device role: + +```bash +gdbus call --system \ + --dest io.openthread.BorderRouter.wpan0 \ + --object-path /io/openthread/BorderRouter/wpan0 \ + --method org.freedesktop.DBus.Properties.Get \ + io.openthread.BorderRouter DeviceRole +``` + +### From inside the Barton container + +The `dbus-socket` volume shares `/var/run/dbus` between the two containers, so +the Barton container can also reach the OTBR D-Bus service: + +```bash +gdbus introspect --system \ + --dest io.openthread.BorderRouter.wpan0 \ + --object-path /io/openthread/BorderRouter/wpan0 +``` +If Barton is built with `BCORE_THREAD=ON`, it will automatically discover and +communicate with the border router over D-Bus on startup. From 492020db95dc1d7502909c276b325d259da4a4cc Mon Sep 17 00:00:00 2001 From: mvelam850 Date: Mon, 13 Apr 2026 07:18:26 -0500 Subject: [PATCH 02/17] build(docker) : address review findings by copilot --- docker/otbr-radio-entrypoint.sh | 18 ++++++++++++------ docker/setupDockerEnv.sh | 5 ++++- dockerw | 12 ++++++------ 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/docker/otbr-radio-entrypoint.sh b/docker/otbr-radio-entrypoint.sh index 77921ae6..e99e51c9 100755 --- a/docker/otbr-radio-entrypoint.sh +++ b/docker/otbr-radio-entrypoint.sh @@ -70,16 +70,21 @@ fi ############################################################################### echo "[otbr-radio] Starting D-Bus system bus..." mkdir -p /var/run/dbus +dbusRunning=false if [ -f /var/run/dbus/pid ]; then dbusPid=$(cat /var/run/dbus/pid 2>/dev/null || true) if [ -n "${dbusPid}" ] && kill -0 "${dbusPid}" 2>/dev/null; then - echo "[otbr-radio] D-Bus already running (PID ${dbusPid}), skipping start." - else - echo "[otbr-radio] Removing stale D-Bus pid/socket files..." - rm -f /var/run/dbus/pid /var/run/dbus/system_bus_socket - dbus-daemon --system --fork + dbusComm=$(cat "/proc/${dbusPid}/comm" 2>/dev/null || true) + if [ "${dbusComm}" = "dbus-daemon" ] && [ -S /var/run/dbus/system_bus_socket ]; then + dbusRunning=true + fi fi +fi +if [ "${dbusRunning}" = "true" ]; then + echo "[otbr-radio] D-Bus already running (PID ${dbusPid}), skipping start." else + echo "[otbr-radio] Removing stale D-Bus pid/socket files..." + rm -f /var/run/dbus/pid /var/run/dbus/system_bus_socket dbus-daemon --system --fork fi echo "[otbr-radio] D-Bus started." @@ -123,7 +128,8 @@ EOF echo "[otbr-radio] Starting cpcd (instance: cpcd_0, device: ${RADIO_DEVICE})..." CPCD_LOG="/tmp/cpcd.log" -cpcd --conf "${CPCD_CONF}" 2>&1 | tee "${CPCD_LOG}" & +: > "${CPCD_LOG}" +cpcd --conf "${CPCD_CONF}" > >(tee "${CPCD_LOG}") 2>&1 & CPCD_PID=$! # Wait until cpcd logs its ready message, meaning it has fully connected to the diff --git a/docker/setupDockerEnv.sh b/docker/setupDockerEnv.sh index fb35d992..005aa823 100755 --- a/docker/setupDockerEnv.sh +++ b/docker/setupDockerEnv.sh @@ -202,7 +202,10 @@ echo "LIB_BARTON_SHARED_PATH=/usr/local/lib" >> $OUTFILE # Auto-detect the default-route network interface for the Thread backbone. # The entrypoint will also re-detect at runtime, so this is only used when # BACKBONE_IF is not already set in the environment. -detectedBackboneIf=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}') +detectedBackboneIf="" +if command -v ip >/dev/null 2>&1; then + detectedBackboneIf=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}') +fi echo "RADIO_DEVICE=${RADIO_DEVICE:-/dev/ttyACM0}" >> $OUTFILE echo "BACKBONE_IF=${BACKBONE_IF:-$detectedBackboneIf}" >> $OUTFILE diff --git a/dockerw b/dockerw index be1d833f..84634bdd 100755 --- a/dockerw +++ b/dockerw @@ -38,7 +38,7 @@ COMPOSE_INTERACTIVE_FLAGS="" EXTRA_ENV="-e SSH_AUTH_SOCK" DEV_VOL="" HOST_NETWORK="" -OTBR_RADIO_COMPOSE="" +OTBR_RADIO_COMPOSE=() DIR=$(pwd) @@ -57,7 +57,7 @@ while getopts ":ne:dHT" opt; do HOST_NETWORK="--network host" ;; T) # spin up the real Thread radio OTBR container (requires BRD2703 + cpcd) - OTBR_RADIO_COMPOSE="-f ${DIR}/docker/compose.otbr-radio.yaml" + OTBR_RADIO_COMPOSE=(-f "${DIR}/docker/compose.otbr-radio.yaml") ;; esac done @@ -77,9 +77,9 @@ VOLUMES_ARGS="-v $HOME:$HOME # background before opening the barton shell. The container runs cpcd and # otbr-agent and shares its D-Bus socket with the barton service so that # Barton code can reach otbr-agent without any extra steps inside the shell. -if [ -n "${OTBR_RADIO_COMPOSE}" ]; then +if [ ${#OTBR_RADIO_COMPOSE[@]} -gt 0 ]; then notice "Starting otbr-radio container (cpcd + otbr-agent)..." - docker compose -f "${COMPOSE_FILE}" ${OTBR_RADIO_COMPOSE} up -d otbr-radio + docker compose -f "${COMPOSE_FILE}" "${OTBR_RADIO_COMPOSE[@]}" up -d otbr-radio notice "otbr-radio container is up. cpcd and otbr-agent are running in the background." fi @@ -88,7 +88,7 @@ USER_ID=$(id -u -n) RANDOM_NUMBER=$(shuf -i 100000-999999 -n 1) CONTAINER_NAME="${USER_ID}_${RANDOM_NUMBER}" -docker compose -f "${COMPOSE_FILE}" ${OTBR_RADIO_COMPOSE} run \ +docker compose -f "${COMPOSE_FILE}" "${OTBR_RADIO_COMPOSE[@]}" run \ --rm \ --name ${CONTAINER_NAME} \ ${COMPOSE_INTERACTIVE_FLAGS} \ @@ -97,4 +97,4 @@ docker compose -f "${COMPOSE_FILE}" ${OTBR_RADIO_COMPOSE} run \ ${VOLUMES_ARGS} \ ${EXTRA_ENV} \ barton \ - $* + "$@" From 8b8db5daedf39c8d13d3c3a251a8dc26410ff360 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:29:25 +0000 Subject: [PATCH 03/17] build(docker): fix restart policy, DBUS wiring, and docs accuracy Agent-Logs-Url: https://github.com/rdkcentral/BartonCore/sessions/20251167-75e5-411a-b21e-5827b40fcf35 Co-authored-by: Lakshmi97-velampati <190572788+Lakshmi97-velampati@users.noreply.github.com> --- docker/compose.otbr-radio.yaml | 7 ++++--- dockerw | 8 +++++--- docs/THREAD_BORDER_ROUTER_SUPPORT.md | 24 ++++++++++++++++++------ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/docker/compose.otbr-radio.yaml b/docker/compose.otbr-radio.yaml index 72c1ecdc..c41e4bc3 100644 --- a/docker/compose.otbr-radio.yaml +++ b/docker/compose.otbr-radio.yaml @@ -109,9 +109,10 @@ services: - RADIO_DEVICE=${RADIO_DEVICE:-} - BACKBONE_IF=${BACKBONE_IF:-} - DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/otbr-dbus/system_bus_socket - # Restart automatically if cpcd/otbr-agent crash, unless the container - # was explicitly stopped. - restart: unless-stopped + # Restart automatically if cpcd/otbr-agent crash, but limit retries so + # a missing/invalid RADIO_DEVICE does not cause an infinite restart + # loop and log spam when this overlay is included without hardware. + restart: on-failure:5 # Add the shared private D-Bus socket volume to the barton service. # This makes the otbr-radio container's private D-Bus socket available diff --git a/dockerw b/dockerw index c6f3f11e..b2a64cd7 100755 --- a/dockerw +++ b/dockerw @@ -75,12 +75,14 @@ VOLUMES_ARGS="-v $HOME:$HOME # If the -T flag was given, build and start the otbr-radio container in the # background before opening the barton shell. The container runs cpcd and -# otbr-agent and shares its D-Bus socket with the barton service so that -# Barton code can reach otbr-agent without any extra steps inside the shell. +# otbr-agent and shares its D-Bus socket with the barton service. +# Inject DBUS_SYSTEM_BUS_ADDRESS so Barton automatically connects to the +# private bus instead of the host system bus. if [ ${#OTBR_RADIO_COMPOSE[@]} -gt 0 ]; then notice "Starting otbr-radio container (cpcd + otbr-agent)..." docker compose -f "${COMPOSE_FILE}" "${OTBR_RADIO_COMPOSE[@]}" up -d otbr-radio - notice "otbr-radio container is up. cpcd and otbr-agent are running in the background." + notice "otbr-radio container started in the background." + EXTRA_ENV="${EXTRA_ENV} -e DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/otbr-dbus/system_bus_socket" fi # create a unique container name prefixed with the user id, useful when on a shared machine diff --git a/docs/THREAD_BORDER_ROUTER_SUPPORT.md b/docs/THREAD_BORDER_ROUTER_SUPPORT.md index 2fb29f7a..6cacc264 100644 --- a/docs/THREAD_BORDER_ROUTER_SUPPORT.md +++ b/docs/THREAD_BORDER_ROUTER_SUPPORT.md @@ -268,9 +268,20 @@ gdbus call \ ### From inside the Barton container The `dbus-socket` volume shares `/var/run/otbr-dbus` between the two -containers, and the overlay sets `DBUS_SYSTEM_BUS_ADDRESS` so both containers -talk to the private D-Bus daemon started by `otbr-radio` instead of the host's -system bus: +containers. To reach the private D-Bus daemon started by `otbr-radio`, Barton +must be pointed at that socket via `DBUS_SYSTEM_BUS_ADDRESS`. + +- **`dockerw -T`**: `DBUS_SYSTEM_BUS_ADDRESS` is injected automatically, so no + extra steps are required. +- **Devcontainer**: export the variable before rebuilding the container: + + ```bash + export DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/otbr-dbus/system_bus_socket + ``` + + Then rebuild the devcontainer (**Dev Containers: Rebuild Container**). + +Once `DBUS_SYSTEM_BUS_ADDRESS` is set you can verify connectivity: ```bash gdbus introspect \ @@ -278,6 +289,7 @@ gdbus introspect \ --dest io.openthread.BorderRouter.wpan0 \ --object-path /io/openthread/BorderRouter/wpan0 ``` -If Barton is built with `BCORE_THREAD=ON`, it will automatically discover and -communicate with the border router over D-Bus on startup using the private -container D-Bus address from the overlay. + +If Barton is built with `BCORE_THREAD=ON` and `DBUS_SYSTEM_BUS_ADDRESS` points +to the private socket, it will automatically discover and communicate with the +border router over D-Bus on startup. From 2e68240a4a3fdd3df29872f04318b004a44f0d83 Mon Sep 17 00:00:00 2001 From: Thomas Lea Date: Wed, 29 Apr 2026 12:12:34 -0500 Subject: [PATCH 04/17] added simultaneous bluetooth support on same silabs radio --- docker/Dockerfile | 3 +- docker/Dockerfile.otbr-radio | 20 +++++++++- docker/compose.otbr-radio.yaml | 6 +++ docker/otbr-radio-entrypoint.sh | 66 ++++++++++++++++++++++++++++++++- docker/version | 2 +- 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index d66024df..1fb538fe 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -376,7 +376,8 @@ RUN apt-get update && apt-get -y upgrade && DEBIAN_FRONTEND='noninteractive' apt tcpdump \ lcov \ rsync \ - bash-completion + bash-completion \ + bluez RUN pip3 install --upgrade --break-system-packages \ stack-pr diff --git a/docker/Dockerfile.otbr-radio b/docker/Dockerfile.otbr-radio index ed7cfc15..72e98a77 100644 --- a/docker/Dockerfile.otbr-radio +++ b/docker/Dockerfile.otbr-radio @@ -75,6 +75,7 @@ RUN apt-get update && apt-get -y upgrade && DEBIAN_FRONTEND='noninteractive' apt ipset \ udev \ libglib2.0-bin \ + bluez \ && rm -rf /var/lib/apt/lists/* # Copy over the third party patches (shared with the main Dockerfile) @@ -91,7 +92,7 @@ RUN chmod +x ${APPLY_PATCHES_PATH} # # Source: https://github.com/SiliconLabs/cpc-daemon ############################################################################### -ARG CPCD_GIT_TAG="v4.5.2" +ARG CPCD_GIT_TAG="v4.4.6" RUN cd /tmp && \ git clone --depth 1 --branch ${CPCD_GIT_TAG} \ @@ -174,7 +175,22 @@ RUN cd /tmp && \ cd ../build/otbr/ && \ ninja install && \ cp -a /tmp/ot-br-posix/third_party/openthread/repo/include/openthread /usr/local/include && \ - rm -rf /tmp/ot-br-posix /tmp/gecko_sdk + rm -rf /tmp/ot-br-posix + +############################################################################### +# bt_host_cpc_hci_bridge — CPC-to-HCI bridge for Bluetooth +# +# Connects to cpcd via CPC and exposes a virtual HCI serial device (pts) that +# BlueZ can attach to via btattach. This enables bluetoothd / bluetoothctl +# to use the Bluetooth radio on the multiprotocol EFR32 co-processor. +# +# Source: gecko_sdk/app/bluetooth/example_host/bt_host_cpc_hci_bridge +# Docs: https://docs.silabs.com/bluetooth/9.1.1/multiprotocol-solution-linux/running-local-processes-concurrently +############################################################################### +RUN cd /tmp/gecko_sdk/app/bluetooth/example_host/bt_host_cpc_hci_bridge && \ + make -j$(nproc) && \ + cp exe/bt_host_cpc_hci_bridge /usr/local/bin/ && \ + rm -rf /tmp/gecko_sdk # D-Bus policy allowing otbr-agent to own its well-known bus name COPY otbr-agent.conf /etc/dbus-1/system.d/ diff --git a/docker/compose.otbr-radio.yaml b/docker/compose.otbr-radio.yaml index c41e4bc3..16eae623 100644 --- a/docker/compose.otbr-radio.yaml +++ b/docker/compose.otbr-radio.yaml @@ -100,6 +100,12 @@ services: # (common on shared build servers with multiple USB-IP users). # The privileged: true above grants the necessary device access. - /dev:/dev + # Bind-mount the host's network namespace so that btattach and + # bluetoothd can be run via nsenter --net=/run/host-netns. + # AF_BLUETOOTH sockets only work in the initial (host) network + # namespace; this avoids requiring network_mode: host on the + # entire container. + - /proc/1/ns/net:/run/host-netns:ro environment: # RADIO_DEVICE must be set explicitly before starting this overlay. # On a shared build server each user's radio may attach at a diff --git a/docker/otbr-radio-entrypoint.sh b/docker/otbr-radio-entrypoint.sh index d5e9aa7d..d2d5b1ae 100755 --- a/docker/otbr-radio-entrypoint.sh +++ b/docker/otbr-radio-entrypoint.sh @@ -164,7 +164,71 @@ done echo "[otbr-radio] cpcd ready (PID ${CPCD_PID}, waited ${cpcdWaitCount}s)." ############################################################################### -# 5. Start otbr-agent +# 5. Start bt_host_cpc_hci_bridge (CPC → virtual HCI serial device) +# +# Connects to cpcd, opens a CPC Bluetooth endpoint, and creates a numbered +# virtual serial device (e.g. /dev/pts/6) plus a convenience symlink +# 'pts_hci' in the working directory. BlueZ attaches to this device via +# btattach in the next step. +############################################################################### +echo "[otbr-radio] Starting bt_host_cpc_hci_bridge..." +BT_BRIDGE_DIR="/var/run/bt-hci-bridge" +mkdir -p "${BT_BRIDGE_DIR}" +cd "${BT_BRIDGE_DIR}" +bt_host_cpc_hci_bridge & +BT_BRIDGE_PID=$! + +# Wait for the pts_hci symlink that bt_host_cpc_hci_bridge creates once ready. +BT_BRIDGE_WAIT_MAX=15 +btBridgeWaitCount=0 +while [ ! -L "${BT_BRIDGE_DIR}/pts_hci" ]; do + if ! kill -0 ${BT_BRIDGE_PID} 2>/dev/null; then + echo "[otbr-radio] ERROR: bt_host_cpc_hci_bridge exited unexpectedly." >&2 + exit 1 + fi + + if [ ${btBridgeWaitCount} -ge ${BT_BRIDGE_WAIT_MAX} ]; then + echo "[otbr-radio] ERROR: bt_host_cpc_hci_bridge did not create pts_hci after ${BT_BRIDGE_WAIT_MAX}s." >&2 + exit 1 + fi + sleep 1 + btBridgeWaitCount=$((btBridgeWaitCount + 1)) +done +PTS_DEVICE=$(readlink -f "${BT_BRIDGE_DIR}/pts_hci") +echo "[otbr-radio] bt_host_cpc_hci_bridge ready (PID ${BT_BRIDGE_PID}, pts: ${PTS_DEVICE}, waited ${btBridgeWaitCount}s)." + +############################################################################### +# 6. Attach the virtual HCI device and start bluetoothd +# +# AF_BLUETOOTH sockets (needed by btattach and bluetoothd) only work in the +# initial (host) network namespace. We use nsenter --net to enter the host +# network namespace for these two processes while keeping everything else on +# the container's bridge network. +# +# bluetoothd inherits the container's DBUS_SYSTEM_BUS_ADDRESS so it registers +# on our private D-Bus, not the host's. This avoids conflicts with any +# host-side bluetoothd that may be managing hci0. +############################################################################### +HOST_NETNS="/run/host-netns" +if [ ! -e "${HOST_NETNS}" ]; then + echo "[otbr-radio] WARNING: Host network namespace not mounted at ${HOST_NETNS}." >&2 + echo "[otbr-radio] Bluetooth support will not be available." >&2 + echo "[otbr-radio] Add '- /proc/1/ns/net:/run/host-netns:ro' to volumes." >&2 +else + echo "[otbr-radio] Attaching HCI device ${PTS_DEVICE} via btattach (host netns)..." + nsenter --net="${HOST_NETNS}" btattach -B "${PTS_DEVICE}" -S 115200 & + BTATTACH_PID=$! + sleep 2 + echo "[otbr-radio] btattach started (PID ${BTATTACH_PID})." + + echo "[otbr-radio] Starting bluetoothd (host netns, private D-Bus)..." + nsenter --net="${HOST_NETNS}" bluetoothd & + sleep 1 + echo "[otbr-radio] bluetoothd started." +fi + +############################################################################### +# 7. Start otbr-agent # # Uses spinel+cpc:// instead of the simulated spinel+hdlc+forkpty:// URI # that is used by scripts/start-simulated-otbr.sh. diff --git a/docker/version b/docker/version index 6b4950e3..9f55b2cc 100644 --- a/docker/version +++ b/docker/version @@ -1 +1 @@ -2.4 +3.0 From adf2734cea19dd2145a05f603ca64848808870cd Mon Sep 17 00:00:00 2001 From: Thomas Lea Date: Wed, 29 Apr 2026 21:28:44 +0000 Subject: [PATCH 05/17] pick the right ble adapter for matter sdk --- core/src/subsystems/matter/Matter.cpp | 55 ++++++++++++++++++++++++++- core/src/subsystems/matter/Matter.h | 12 ++++++ docker/compose.otbr-radio.yaml | 12 +++--- docker/otbr-radio-entrypoint.sh | 40 ++++++++++++++++--- 4 files changed, 107 insertions(+), 12 deletions(-) diff --git a/core/src/subsystems/matter/Matter.cpp b/core/src/subsystems/matter/Matter.cpp index 3c1578ba..07abcccd 100644 --- a/core/src/subsystems/matter/Matter.cpp +++ b/core/src/subsystems/matter/Matter.cpp @@ -32,6 +32,8 @@ #include "system/SystemClock.h" #include "zap-generated/gen_config.h" #include +#include +#include #include #define LOG_TAG "Matter" #define logFmt(fmt) "(%s): " fmt, __func__ @@ -99,7 +101,9 @@ extern "C" { #define CONNECT_DEVICE_TIMEOUT_SECONDS 15 #define DISCOVER_ON_NETWORK_DEVICE_TIMEOUT_SECS 1 -#define BLE_CONTROLLER_ADAPTER_ID 0 +#define BLE_CONTROLLER_ADAPTER_ID_DEFAULT 0 +#define BLE_CONTROLLER_ADAPTER_ID_ENV "BARTON_BLE_ADAPTER_ID" +#define BLE_CONTROLLER_ADAPTER_ID_FILE "/var/run/otbr-dbus/ble_adapter_id" #define BLE_CONTROLLER_DEVICE_NAME BARTON_CONFIG_MATTER_BLE_CONTROLLER_DEVICE_NAME #define LOCAL_NODE_ID_SYSTEM_PROPERTY_NAME "localMatterNodeId" @@ -278,7 +282,7 @@ bool Matter::Init(uint64_t accountId, std::string &&attestationTrustStorePath) } chip::DeviceLayer::ConnectivityMgr().SetBLEDeviceName(BLE_CONTROLLER_DEVICE_NAME); - chip::DeviceLayer::Internal::BLEMgrImpl().ConfigureBle(BLE_CONTROLLER_ADAPTER_ID, true); + chip::DeviceLayer::Internal::BLEMgrImpl().ConfigureBle(ResolveBleAdapterId(), true); chip::DeviceLayer::ConnectivityMgr().SetBLEAdvertisingEnabled(false); #if CHIP_ENABLE_OPENTHREAD @@ -294,6 +298,53 @@ bool Matter::Init(uint64_t accountId, std::string &&attestationTrustStorePath) return result; } +uint32_t Matter::ResolveBleAdapterId() +{ + uint32_t adapterId = BLE_CONTROLLER_ADAPTER_ID_DEFAULT; + const char *env = getenv(BLE_CONTROLLER_ADAPTER_ID_ENV); + + if (env != nullptr) + { + char *endPtr = nullptr; + unsigned long val = strtoul(env, &endPtr, 10); + + if (endPtr != env && *endPtr == '\0') + { + adapterId = static_cast(val); + icInfo("Using BLE adapter hci%u from %s", adapterId, BLE_CONTROLLER_ADAPTER_ID_ENV); + } + else + { + icWarn("Invalid %s value '%s', using default hci%u", BLE_CONTROLLER_ADAPTER_ID_ENV, env, adapterId); + } + + return adapterId; + } + + FILE *f = fopen(BLE_CONTROLLER_ADAPTER_ID_FILE, "r"); + + if (f != nullptr) + { + char buf[16]; + + if (fgets(buf, sizeof(buf), f) != nullptr) + { + char *endPtr = nullptr; + unsigned long val = strtoul(buf, &endPtr, 10); + + if (endPtr != buf) + { + adapterId = static_cast(val); + icInfo("Using BLE adapter hci%u from %s", adapterId, BLE_CONTROLLER_ADAPTER_ID_FILE); + } + } + + fclose(f); + } + + return adapterId; +} + bool Matter::Start() { icDebug(); diff --git a/core/src/subsystems/matter/Matter.h b/core/src/subsystems/matter/Matter.h index 19781cee..93a78e56 100644 --- a/core/src/subsystems/matter/Matter.h +++ b/core/src/subsystems/matter/Matter.h @@ -281,6 +281,18 @@ namespace barton */ bool SetAccessRestrictionList(); + /** + * Determine which BlueZ HCI adapter index to use for BLE operations. + * + * Selection order: + * 1. BARTON_BLE_ADAPTER_ID environment variable (integer, e.g. "1" for hci1). + * 2. The adapter index file written at /var/run/otbr-dbus/ble_adapter_id. + * 3. Falls back to hci0. + * + * @return the HCI adapter index to pass to BLEMgrImpl::ConfigureBle() + */ + static uint32_t ResolveBleAdapterId(); + bool OpenLocalCommissioningWindow(uint16_t discriminator, uint16_t timeoutSecs, SetupPayload &setupPayload); diff --git a/docker/compose.otbr-radio.yaml b/docker/compose.otbr-radio.yaml index 16eae623..1f252eb7 100644 --- a/docker/compose.otbr-radio.yaml +++ b/docker/compose.otbr-radio.yaml @@ -101,10 +101,9 @@ services: # The privileged: true above grants the necessary device access. - /dev:/dev # Bind-mount the host's network namespace so that btattach and - # bluetoothd can be run via nsenter --net=/run/host-netns. - # AF_BLUETOOTH sockets only work in the initial (host) network - # namespace; this avoids requiring network_mode: host on the - # entire container. + # bluetoothd can run via nsenter --net=/run/host-netns. + # AF_BLUETOOTH sockets are only available in the initial (host) + # network namespace. - /proc/1/ns/net:/run/host-netns:ro environment: # RADIO_DEVICE must be set explicitly before starting this overlay. @@ -132,8 +131,11 @@ services: barton: volumes: - dbus-socket:/var/run/otbr-dbus + # Expose the host network namespace so that tools requiring + # AF_BLUETOOTH (e.g. bluetoothctl) can run via nsenter. + - /proc/1/ns/net:/run/host-netns:ro environment: - - DBUS_SYSTEM_BUS_ADDRESS=${DBUS_SYSTEM_BUS_ADDRESS:-unix:path=/var/run/dbus/system_bus_socket} + - DBUS_SYSTEM_BUS_ADDRESS=${DBUS_SYSTEM_BUS_ADDRESS:-unix:path=/var/run/otbr-dbus/system_bus_socket} networks: # Reuse the same per-user IPv6 bridge network defined in compose.yaml so diff --git a/docker/otbr-radio-entrypoint.sh b/docker/otbr-radio-entrypoint.sh index d2d5b1ae..7490324f 100755 --- a/docker/otbr-radio-entrypoint.sh +++ b/docker/otbr-radio-entrypoint.sh @@ -200,14 +200,17 @@ echo "[otbr-radio] bt_host_cpc_hci_bridge ready (PID ${BT_BRIDGE_PID}, pts: ${PT ############################################################################### # 6. Attach the virtual HCI device and start bluetoothd # -# AF_BLUETOOTH sockets (needed by btattach and bluetoothd) only work in the -# initial (host) network namespace. We use nsenter --net to enter the host -# network namespace for these two processes while keeping everything else on -# the container's bridge network. +# AF_BLUETOOTH sockets are only available in the initial (host) network +# namespace. btattach and bluetoothd must run in the host netns via nsenter. # # bluetoothd inherits the container's DBUS_SYSTEM_BUS_ADDRESS so it registers # on our private D-Bus, not the host's. This avoids conflicts with any -# host-side bluetoothd that may be managing hci0. +# host-side bluetoothd that may be running on the host system bus. +# +# The host machine may have its own built-in Bluetooth adapter (e.g. hci0). +# btattach creates a new HCI device for the radio (e.g. hci1). We detect +# its index and write it to the shared D-Bus volume so Barton can read it +# at runtime to configure the Matter SDK's BLE adapter selection. ############################################################################### HOST_NETNS="/run/host-netns" if [ ! -e "${HOST_NETNS}" ]; then @@ -215,12 +218,39 @@ if [ ! -e "${HOST_NETNS}" ]; then echo "[otbr-radio] Bluetooth support will not be available." >&2 echo "[otbr-radio] Add '- /proc/1/ns/net:/run/host-netns:ro' to volumes." >&2 else + # Snapshot existing HCI devices before btattach creates a new one. + HCI_BEFORE=$(ls /sys/class/bluetooth/ 2>/dev/null | sort) + echo "[otbr-radio] Attaching HCI device ${PTS_DEVICE} via btattach (host netns)..." nsenter --net="${HOST_NETNS}" btattach -B "${PTS_DEVICE}" -S 115200 & BTATTACH_PID=$! sleep 2 echo "[otbr-radio] btattach started (PID ${BTATTACH_PID})." + # Identify the new HCI device created by btattach. + HCI_AFTER=$(ls /sys/class/bluetooth/ 2>/dev/null | sort) + RADIO_HCI="" + + for hci in ${HCI_AFTER}; do + if ! echo "${HCI_BEFORE}" | grep -qw "${hci}"; then + RADIO_HCI="${hci}" + break + fi + done + + if [ -z "${RADIO_HCI}" ]; then + echo "[otbr-radio] WARNING: Could not identify new HCI device; falling back to last in list." >&2 + RADIO_HCI=$(echo "${HCI_AFTER}" | tail -1) + fi + + RADIO_HCI_INDEX=${RADIO_HCI#hci} + echo "[otbr-radio] Radio HCI device: ${RADIO_HCI} (index ${RADIO_HCI_INDEX})" + + # Write the radio's HCI index to the shared volume so Barton can + # discover which adapter to use at runtime. + echo "${RADIO_HCI_INDEX}" > "${DBUS_DIR}/ble_adapter_id" + echo "[otbr-radio] Wrote BLE adapter index ${RADIO_HCI_INDEX} to ${DBUS_DIR}/ble_adapter_id" + echo "[otbr-radio] Starting bluetoothd (host netns, private D-Bus)..." nsenter --net="${HOST_NETNS}" bluetoothd & sleep 1 From 0b3a612d1495f6fc7774e26bf52ef3440922862a Mon Sep 17 00:00:00 2001 From: Thomas Lea Date: Thu, 7 May 2026 13:55:36 -0500 Subject: [PATCH 06/17] helper scripts and doc updates --- docker/Dockerfile.otbr-radio | 2 +- docker/README.md | 2 +- docker/compose.otbr-radio.yaml | 2 +- docker/otbr-radio-entrypoint.sh | 149 ++++++-- docs/REMOTE_RADIO_FOR_DEVELOPMENT.md | 383 ++++++++++++++++++++ docs/THREAD_BORDER_ROUTER_SUPPORT.md | 295 --------------- docs/USING_BARTON_GUIDE.md | 2 +- scripts/remote-radio/usbip-attach-local.sh | 283 +++++++++++++++ scripts/remote-radio/usbip-attach-remote.sh | 312 ++++++++++++++++ scripts/remote-radio/usbip-detach-local.sh | 136 +++++++ scripts/remote-radio/usbip-detach-remote.sh | 124 +++++++ scripts/remote-radio/usbip-validate.sh | 339 +++++++++++++++++ 12 files changed, 1697 insertions(+), 332 deletions(-) create mode 100644 docs/REMOTE_RADIO_FOR_DEVELOPMENT.md delete mode 100644 docs/THREAD_BORDER_ROUTER_SUPPORT.md create mode 100755 scripts/remote-radio/usbip-attach-local.sh create mode 100755 scripts/remote-radio/usbip-attach-remote.sh create mode 100755 scripts/remote-radio/usbip-detach-local.sh create mode 100755 scripts/remote-radio/usbip-detach-remote.sh create mode 100755 scripts/remote-radio/usbip-validate.sh diff --git a/docker/Dockerfile.otbr-radio b/docker/Dockerfile.otbr-radio index 72e98a77..29a5f4ca 100644 --- a/docker/Dockerfile.otbr-radio +++ b/docker/Dockerfile.otbr-radio @@ -36,7 +36,7 @@ # - Devcontainer: This overlay is included by default in the devcontainer # compose stack. # -# See docs/THREAD_BORDER_ROUTER_SUPPORT.md for full setup instructions, +# See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for full setup instructions, # including how to use USB-IP to forward a locally connected radio to a remote # dev server. # diff --git a/docker/README.md b/docker/README.md index 46bb52c5..513b051a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -41,7 +41,7 @@ It always includes `compose.yaml` as the base, and accepts flags to layer on ove | `-e ` | Pass extra environment variables | | `-d` | Mount development volumes | | `-n` | Disable TTY (non-interactive mode) | -| `-T` | Start the optional `otbr-radio` container for real USB Thread radio support (see `docs/THREAD_BORDER_ROUTER_SUPPORT.md`) | +| `-T` | Start the optional `otbr-radio` container for real USB Thread radio support (see `docs/REMOTE_RADIO_FOR_DEVELOPMENT.md`) | Example: diff --git a/docker/compose.otbr-radio.yaml b/docker/compose.otbr-radio.yaml index 1f252eb7..fbf0bfd7 100644 --- a/docker/compose.otbr-radio.yaml +++ b/docker/compose.otbr-radio.yaml @@ -64,7 +64,7 @@ # ─── USB-IP (remote radio forwarding) ──────────────────────────────────────── # # If your dev server is remote and the radio is on your local workstation, -# see docs/THREAD_BORDER_ROUTER_SUPPORT.md for USB-IP setup instructions. +# see docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for USB-IP setup instructions. # # ───────────────────────────────────────────────────────────────────────────── diff --git a/docker/otbr-radio-entrypoint.sh b/docker/otbr-radio-entrypoint.sh index 7490324f..e481ecec 100755 --- a/docker/otbr-radio-entrypoint.sh +++ b/docker/otbr-radio-entrypoint.sh @@ -39,7 +39,7 @@ # DBUS_SOCKET_PATH - socket path for the private system bus # DBUS_SYSTEM_BUS_ADDRESS - D-Bus address used by otbr-agent and clients # -# See docs/THREAD_BORDER_ROUTER_SUPPORT.md for USB-IP setup and hardware info. +# See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for USB-IP setup and hardware info. # set -e @@ -99,13 +99,13 @@ if [ -z "${RADIO_DEVICE}" ]; then echo "[otbr-radio] ERROR: RADIO_DEVICE is not set." >&2 echo "[otbr-radio] Set it in docker/.env or export before starting:" >&2 echo "[otbr-radio] export RADIO_DEVICE=/dev/ttyACM0" >&2 - echo "[otbr-radio] See docs/THREAD_BORDER_ROUTER_SUPPORT.md for setup instructions." >&2 + echo "[otbr-radio] See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for setup instructions." >&2 exit 1 fi if [ ! -e "${RADIO_DEVICE}" ]; then echo "[otbr-radio] ERROR: USB radio device '${RADIO_DEVICE}' not found." >&2 echo "[otbr-radio] Check that the thread radio is connected and USB-IP is attached." >&2 - echo "[otbr-radio] See docs/THREAD_BORDER_ROUTER_SUPPORT.md for setup instructions." >&2 + echo "[otbr-radio] See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for setup instructions." >&2 exit 1 fi echo "[otbr-radio] Using USB radio device: ${RADIO_DEVICE}" @@ -211,50 +211,133 @@ echo "[otbr-radio] bt_host_cpc_hci_bridge ready (PID ${BT_BRIDGE_PID}, pts: ${PT # btattach creates a new HCI device for the radio (e.g. hci1). We detect # its index and write it to the shared D-Bus volume so Barton can read it # at runtime to configure the Matter SDK's BLE adapter selection. +# +# A background monitor watches btattach and restarts the BLE stack if it +# exits (e.g. after a USB-IP disconnect/reconnect cycle). ############################################################################### HOST_NETNS="/run/host-netns" -if [ ! -e "${HOST_NETNS}" ]; then - echo "[otbr-radio] WARNING: Host network namespace not mounted at ${HOST_NETNS}." >&2 - echo "[otbr-radio] Bluetooth support will not be available." >&2 - echo "[otbr-radio] Add '- /proc/1/ns/net:/run/host-netns:ro' to volumes." >&2 -else + +# ble_attach — run btattach in the host network namespace against the +# pts_hci device and identify the new HCI adapter. On success, starts +# bluetoothd and writes ble_adapter_id so Barton picks the right adapter. +# Sets BTATTACH_PID in the caller's scope. +ble_attach() { # Snapshot existing HCI devices before btattach creates a new one. - HCI_BEFORE=$(ls /sys/class/bluetooth/ 2>/dev/null | sort) + local hciBefore + hciBefore=$(ls /sys/class/bluetooth/ 2>/dev/null | sort) - echo "[otbr-radio] Attaching HCI device ${PTS_DEVICE} via btattach (host netns)..." - nsenter --net="${HOST_NETNS}" btattach -B "${PTS_DEVICE}" -S 115200 & + local ptsDevice + ptsDevice=$(readlink -f "${BT_BRIDGE_DIR}/pts_hci") + + echo "[otbr-radio] Attaching HCI device ${ptsDevice} via btattach (host netns)..." + nsenter --net="${HOST_NETNS}" btattach -B "${ptsDevice}" -S 115200 & BTATTACH_PID=$! - sleep 2 - echo "[otbr-radio] btattach started (PID ${BTATTACH_PID})." - # Identify the new HCI device created by btattach. - HCI_AFTER=$(ls /sys/class/bluetooth/ 2>/dev/null | sort) - RADIO_HCI="" + # Wait up to 5 seconds for a new HCI device to appear. + local waited=0 + + while [ ${waited} -lt 5 ]; do + sleep 1 + waited=$((waited + 1)) - for hci in ${HCI_AFTER}; do - if ! echo "${HCI_BEFORE}" | grep -qw "${hci}"; then - RADIO_HCI="${hci}" - break + local hciAfter + hciAfter=$(ls /sys/class/bluetooth/ 2>/dev/null | sort) + local radioHci="" + + for hci in ${hciAfter}; do + + if ! echo "${hciBefore}" | grep -qw "${hci}"; then + radioHci="${hci}" + break + fi + done + + if [ -n "${radioHci}" ]; then + local radioHciIndex="${radioHci#hci}" + echo "[otbr-radio] Radio HCI device: ${radioHci} (index ${radioHciIndex})" + echo "${radioHciIndex}" > "${DBUS_DIR}/ble_adapter_id" + echo "[otbr-radio] Wrote BLE adapter index ${radioHciIndex} to ${DBUS_DIR}/ble_adapter_id" + + # Kill any stale bluetoothd before starting a fresh instance. + pkill -x bluetoothd 2>/dev/null || true + sleep 0.5 + echo "[otbr-radio] Starting bluetoothd (host netns, private D-Bus)..." + nsenter --net="${HOST_NETNS}" bluetoothd & + sleep 1 + echo "[otbr-radio] bluetoothd started." + + return 0 fi done - if [ -z "${RADIO_HCI}" ]; then - echo "[otbr-radio] WARNING: Could not identify new HCI device; falling back to last in list." >&2 - RADIO_HCI=$(echo "${HCI_AFTER}" | tail -1) + echo "[otbr-radio] WARNING: btattach did not create a new HCI device within 5s." >&2 + + if ! kill -0 ${BTATTACH_PID} 2>/dev/null; then + echo "[otbr-radio] WARNING: btattach (PID ${BTATTACH_PID}) already exited." >&2 fi - RADIO_HCI_INDEX=${RADIO_HCI#hci} - echo "[otbr-radio] Radio HCI device: ${RADIO_HCI} (index ${RADIO_HCI_INDEX})" + return 1 +} - # Write the radio's HCI index to the shared volume so Barton can - # discover which adapter to use at runtime. - echo "${RADIO_HCI_INDEX}" > "${DBUS_DIR}/ble_adapter_id" - echo "[otbr-radio] Wrote BLE adapter index ${RADIO_HCI_INDEX} to ${DBUS_DIR}/ble_adapter_id" +# ble_monitor — background loop that restarts btattach (and bluetoothd) +# whenever btattach exits. This handles USB-IP disconnect/reconnect +# cycles where cpcd and bt_host_cpc_hci_bridge survive but btattach +# loses the HCI device and exits. +ble_monitor() { + local backoff=2 - echo "[otbr-radio] Starting bluetoothd (host netns, private D-Bus)..." - nsenter --net="${HOST_NETNS}" bluetoothd & - sleep 1 - echo "[otbr-radio] bluetoothd started." + while true; do + + if ! kill -0 ${BTATTACH_PID} 2>/dev/null; then + echo "[otbr-radio] btattach (PID ${BTATTACH_PID}) exited — restarting BLE stack..." >&2 + + # If bt_host_cpc_hci_bridge also died, wait for it to come back. + if ! kill -0 ${BT_BRIDGE_PID} 2>/dev/null; then + echo "[otbr-radio] bt_host_cpc_hci_bridge also exited; waiting for it to restart..." >&2 + sleep "${backoff}" + continue + fi + + # pts_hci must still be valid. + if [ ! -L "${BT_BRIDGE_DIR}/pts_hci" ]; then + echo "[otbr-radio] pts_hci symlink missing; waiting..." >&2 + sleep "${backoff}" + continue + fi + + if ble_attach; then + echo "[otbr-radio] BLE stack restarted successfully." + backoff=2 + else + echo "[otbr-radio] BLE stack restart failed; retrying in ${backoff}s..." >&2 + + if [ ${backoff} -lt 30 ]; then + backoff=$((backoff * 2)) + fi + fi + fi + + sleep "${backoff}" + done +} + +if [ ! -e "${HOST_NETNS}" ]; then + echo "[otbr-radio] WARNING: Host network namespace not mounted at ${HOST_NETNS}." >&2 + echo "[otbr-radio] Bluetooth support will not be available." >&2 + echo "[otbr-radio] Add '- /proc/1/ns/net:/run/host-netns:ro' to volumes." >&2 +else + BTATTACH_PID=0 + + if ble_attach; then + echo "[otbr-radio] BLE stack initialized." + else + echo "[otbr-radio] WARNING: Initial BLE attach failed; monitor will retry." >&2 + fi + + # Start background monitor to handle USB-IP reconnects. + ble_monitor & + BLE_MONITOR_PID=$! + echo "[otbr-radio] BLE monitor started (PID ${BLE_MONITOR_PID})." fi ############################################################################### diff --git a/docs/REMOTE_RADIO_FOR_DEVELOPMENT.md b/docs/REMOTE_RADIO_FOR_DEVELOPMENT.md new file mode 100644 index 00000000..42124fa9 --- /dev/null +++ b/docs/REMOTE_RADIO_FOR_DEVELOPMENT.md @@ -0,0 +1,383 @@ +# Thread and BLE Support for Remote Development + +This document covers how Barton integrates with a **Thread Border Router** and +**Bluetooth Low Energy (BLE)** for Matter device commissioning. Both capabilities +are provided by the same physical radio — a Silicon Labs BRD2703 xG24 — and +managed by the `otbr-radio` Docker container. + +--- + +## Overview + +The BRD2703 xG24 is a multi-protocol radio that provides two wireless interfaces +through a single USB connection: + +- **Thread 802.15.4** — used by `otbr-agent` for the Thread mesh network +- **Bluetooth LE** — used by the Matter SDK for BLE-based device commissioning + +Both interfaces are multiplexed over the **CPC (Co-Processor Communication)** +protocol. The `cpcd` daemon manages the USB serial link and exposes separate +CPC endpoints for each protocol. + +``` + BRD2703 USB Radio (/dev/ttyACM0) + │ + ▼ + cpcd (CPC daemon — multiplexes radio protocols) + ├── spinel+cpc://cpcd_0 ──▶ otbr-agent (Thread 802.15.4) + │ │ D-Bus: io.openthread.BorderRouter.wpan0 + │ ▼ + │ Barton (Thread network management) + │ + └── BLE CPC endpoint ────▶ bt_host_cpc_hci_bridge + │ creates virtual serial device (pts_hci) + ▼ + btattach (creates HCI device, e.g. hci1) + │ host network namespace + ▼ + bluetoothd (BlueZ daemon, private D-Bus) + │ D-Bus: org.bluez + ▼ + Barton / Matter SDK (BLE scanning & commissioning) +``` + +### Container Architecture + +| Container | Runs | Provides | +|-----------|------|----------| +| `otbr-radio` | cpcd, otbr-agent, bt_host_cpc_hci_bridge, btattach, bluetoothd | Thread + BLE via shared D-Bus and host network namespace | +| `barton` (devcontainer) | Barton application code, Matter SDK | Consumes Thread D-Bus API and BLE via BlueZ | + +A named Docker volume (`dbus-socket`) at `/var/run/otbr-dbus` shares a private +D-Bus bus between the two containers. This is **not** the host's system D-Bus. + +The BLE stack requires the **host network namespace** because Linux +`AF_BLUETOOTH` sockets only work in the initial (host) network namespace. +`btattach` and `bluetoothd` run via `nsenter --net=/run/host-netns` inside the +`otbr-radio` container. + +### How BLE Adapter Selection Works + +The `otbr-radio` entrypoint detects the HCI device index created by `btattach` +(e.g. `hci1`) and writes it to `/var/run/otbr-dbus/ble_adapter_id`. Barton +reads this file at startup to configure the Matter SDK's BLE adapter via +`BLEMgrImpl().ConfigureBle(adapterId, true)`. + +The host machine may have its own built-in Bluetooth adapter (e.g. `hci0`). +The radio's BLE adapter appears as a separate HCI device with a distinct index. +A background monitor in the entrypoint watches `btattach` and automatically +restarts it if the USB connection drops (e.g. USB-IP reconnect), updating the +`ble_adapter_id` file accordingly. + +--- + +## Thread Modes + +| Mode | When to use | How to start | +|------|-------------|-------------| +| **Simulated** (default) | Unit/integration tests, no hardware needed | `scripts/start-simulated-otbr.sh` | +| **Real USB radio** | Testing with real Thread + BLE devices | See below | + +> **Note**: Enable Thread and Border Router support in the Matter SDK +> (`-DOT_THREAD_VERSION=1.4 -DOTBR_BORDER_ROUTING=ON`) if Matter is also enabled. + +--- + +## Hardware: BRD2703 xG24 Explorer Kit + +### Flashing RCP Firmware + +Before use, the BRD2703 must be flashed with a CPC-capable **RCP (Radio +Co-Processor)** firmware image that includes the BLE HCI endpoint. + +If the board is new (out of the box), its debug adapter firmware must be updated +first. Connect the board to **Simplicity Studio** and accept any firmware +upgrade prompts before proceeding. + +Download the firmware image appropriate for the BRD2703 (EFR32MG24) in either +`.s37` or `.hex` format, then flash it using +[Simplicity Commander](https://www.silabs.com/developers/simplicity-studio). + +--- + +## USB-IP: Forwarding the Radio to a Remote Dev Server + +If your devcontainer runs on a **remote server** (common with VS Code +Remote-SSH) but the BRD2703 is plugged into your **local laptop**, use USB-IP +to forward the USB device over the network. + +Automation scripts are provided in `scripts/remote-radio/`. They handle +package installation, kernel module loading, device detection, per-user port +isolation on shared servers, and cleanup. + +### Quick Start (Scripted) + +**Step 1 — On your laptop** (where the radio is plugged in): + +```bash +scripts/remote-radio/usbip-attach-local.sh user@devserver.example.com +``` + +This auto-detects the radio, binds it, starts the USB-IP daemon, and opens an +SSH reverse tunnel. Leave the terminal open. + +**Step 2 — On the remote server** (where Docker runs): + +```bash +scripts/remote-radio/usbip-attach-remote.sh +``` + +This auto-detects the radio from the remote USB listing, attaches it, waits +for `/dev/ttyACM*` to appear, and prints the `RADIO_DEVICE` path to use. + +**Step 3 — Validate** (inside the Barton container, after containers are up): + +```bash +scripts/remote-radio/usbip-validate.sh +``` + +Checks the entire chain: D-Bus socket, container processes (cpcd, otbr-agent, +bt_host_cpc_hci_bridge, btattach, bluetoothd), Thread network state, BLE HCI +device, and `DBUS_SYSTEM_BUS_ADDRESS`. + +**Teardown** — when done: + +```bash +# On the remote server: +scripts/remote-radio/usbip-detach-remote.sh + +# On your laptop: +scripts/remote-radio/usbip-detach-local.sh +``` + +### Port Isolation on Shared Servers + +The scripts use per-user loopback addresses to avoid conflicts when multiple +developers share the same remote server. Each user's SSH tunnel binds to a +unique address in the `127.0.0.0/8` range derived from their UID: + +``` +loopback_addr = 127.0.. +``` + +For example, UID 1000 → `127.0.3.232`. All tunnels use the standard USB-IP +port 3240, so both `usbip list` and `usbip attach` work without any +non-standard flags. + +> **Prerequisite**: The remote server's sshd must have `GatewayPorts clientspecified` +> enabled so that SSH can bind the reverse tunnel to the per-user loopback address +> instead of defaulting to `127.0.0.1`. Add this to `/etc/ssh/sshd_config` and +> restart sshd: +> ``` +> GatewayPorts clientspecified +> ``` + +Each user's attach and detach scripts only touch their own state files and +vhci ports. There is no cross-user interference. + +### Manual USB-IP Setup + +If you prefer to run the commands manually: + +
+Click to expand manual instructions + +#### On the laptop (USB-IP server) + +```bash +sudo apt install linux-tools-generic linux-cloud-tools-generic +sudo modprobe usbip_core usbip_host vhci_hcd + +# Find and bind the radio +# Look for Silicon Labs CP210x (10c4:ea60) or SEGGER J-Link (1366:0105) +usbip list -l +sudo usbip bind -b + +# Start the daemon +sudo usbipd -D + +# Compute your per-user loopback: 127.0.. +# Example for remote UID 1000: 127.0.3.232 +ssh -R 127.0.3.232:3240:localhost:3240 @ +``` + +#### On the remote server (USB-IP client) + +```bash +sudo apt install linux-tools-generic hwdata +sudo modprobe vhci_hcd + +# List and attach using your per-user loopback address +usbip list -r 127.0.3.232 +sudo usbip attach -r 127.0.3.232 -b + +# Confirm +ls -la /dev/ttyACM* +``` + +
+ +> **Tip**: Always set `RADIO_DEVICE` explicitly, even if the device is at +> `/dev/ttyACM0`. On shared servers, multiple radios may be attached at +> different paths. + +--- + +## Starting the Real-Radio Container + +### CLI (`dockerw`) + +Use the `-T` flag to add `docker/compose.otbr-radio.yaml` to the compose stack. +Set `RADIO_DEVICE` explicitly: + +```bash +RADIO_DEVICE=/dev/ttyACM0 ./dockerw -T bash +``` + +To override the backbone interface: + +```bash +RADIO_DEVICE=/dev/ttyACM1 BACKBONE_IF=eth1 ./dockerw -T bash +``` + +This creates two containers: `barton` and `otbr-radio`. + +To check the logs: + +```bash +docker compose -f docker/compose.yaml -f docker/compose.otbr-radio.yaml logs -f otbr-radio +``` + +Expected log progression: + +1. `Using USB radio device: /dev/ttyACM0` +2. `Connected to Secondary` / `Secondary CPC v4.4.6` +3. `Daemon startup was successful` +4. `bt_host_cpc_hci_bridge ready` +5. `Radio HCI device: hci1 (index 1)` +6. `bluetoothd started` +7. `Starting otbr-agent` + +### Devcontainer (VS Code) + +`compose.otbr-radio.yaml` is included in the devcontainer by default — no +manual edit to `.devcontainer/devcontainer.json` is required. + +The `otbr-radio` container starts automatically when the devcontainer launches. +If `RADIO_DEVICE` is not set it exits with a clear error in its own logs, but +**the `barton` devcontainer is unaffected and starts normally**. + +To enable real radio support, set `RADIO_DEVICE` in `docker/.env` after running +`docker/setupDockerEnv.sh`, or export it in your host shell before opening +VS Code: + +```bash +export RADIO_DEVICE=/dev/ttyACM0 +``` + +Then rebuild the devcontainer (**Dev Containers: Rebuild Container**). + +--- + +## Verifying the Full Stack + +The fastest way to verify everything is working is to run the validation script +from inside the Barton container: + +```bash +scripts/remote-radio/usbip-validate.sh +``` + +This checks: + +- D-Bus socket exists and is reachable +- otbr-radio container processes (cpcd, otbr-agent, bt_host_cpc_hci_bridge, + btattach, bluetoothd) +- Thread network state via D-Bus +- BLE adapter ID file and HCI device presence +- `DBUS_SYSTEM_BUS_ADDRESS` is set correctly + +### Manual D-Bus Verification + +From inside either container, query the OTBR D-Bus interface: + +```bash +gdbus introspect \ + --address unix:path=/var/run/otbr-dbus/system_bus_socket \ + --dest io.openthread.BorderRouter.wpan0 \ + --object-path /io/openthread/BorderRouter/wpan0 +``` + +Read the current Thread device role: + +```bash +gdbus call \ + --address unix:path=/var/run/otbr-dbus/system_bus_socket \ + --dest io.openthread.BorderRouter.wpan0 \ + --object-path /io/openthread/BorderRouter/wpan0 \ + --method org.freedesktop.DBus.Properties.Get \ + io.openthread.BorderRouter DeviceRole +``` + +### D-Bus Configuration + +The `dbus-socket` volume shares `/var/run/otbr-dbus` between containers. To +reach the private D-Bus daemon, `DBUS_SYSTEM_BUS_ADDRESS` must point to it: + +- **`dockerw -T`**: Injected automatically. +- **Devcontainer**: Set in `docker/.env` or export before rebuilding: + + ```bash + export DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/otbr-dbus/system_bus_socket + ``` + +### BLE Verification + +Check the BLE adapter from inside the Barton container: + +```bash +# Verify the adapter ID file +cat /var/run/otbr-dbus/ble_adapter_id + +# Check HCI devices in the host network namespace +sudo nsenter --net=/run/host-netns hciconfig + +# List Bluetooth controllers +sudo nsenter --net=/run/host-netns bluetoothctl list +``` + +Expected: two HCI devices — the host's built-in adapter (e.g. `hci0`) and the +radio's BLE adapter (e.g. `hci1`). The `ble_adapter_id` file should contain +the index of the radio's adapter. + +--- + +## Teardown + +### Stopping the otbr-radio container + +```bash +docker compose -f docker/compose.yaml -f docker/compose.otbr-radio.yaml rm -sf otbr-radio +``` + +### Detaching the USB-IP device + +```bash +# On the remote server: +scripts/remote-radio/usbip-detach-remote.sh + +# On your laptop (also close the SSH tunnel terminal): +scripts/remote-radio/usbip-detach-local.sh +``` + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `InitCommissioner` fails with `CHIP_ERROR_INCORRECT_STATE` | Default `CertifierOperationalCredentialsIssuer` requires an auth token | Build with dev platform: `cmake -C config/cmake/platforms/dev/linux.cmake ..` to use `SelfSignedCertifierOperationalCredentialsIssuer` | +| Only `hci0` visible, no `hci1` | `btattach` failed or USB-IP disconnected | Run `scripts/remote-radio/usbip-validate.sh` to diagnose; the entrypoint's BLE monitor should auto-restart `btattach` | +| `ble_adapter_id` contains `0` | Radio BLE bridge didn't create a new HCI device | Check otbr-radio logs for `bt_host_cpc_hci_bridge` errors | +| OpenSSL link errors during build | Stale OpenSSL 1.1.1 in `/usr/local/openssl/` | Remove it: `sudo rm -rf /usr/local/openssl /usr/local/lib/libcurl*` and reconfigure | +| `DBUS_SYSTEM_BUS_ADDRESS` not set | Barton connects to system D-Bus instead of otbr-radio's private bus | Export it or use `dockerw -T` which injects it automatically | diff --git a/docs/THREAD_BORDER_ROUTER_SUPPORT.md b/docs/THREAD_BORDER_ROUTER_SUPPORT.md deleted file mode 100644 index 6cacc264..00000000 --- a/docs/THREAD_BORDER_ROUTER_SUPPORT.md +++ /dev/null @@ -1,295 +0,0 @@ -# Thread Border Router Support - -Barton communicates with `otbr-agent` over D-Bus when Thread support is enabled -(`BCORE_THREAD=ON`). Two modes are supported: - -| Mode | When to use | How to start | -|------|-------------|-------------| -| **Simulated** (default) | Unit/integration tests, no hardware needed | `scripts/start-simulated-otbr.sh` | -| **Real USB radio** | Testing with real Thread devices | See below | - -> **Note**: Be sure to enable Thread and Border Router support in the Matter SDK -> (`-DOT_THREAD_VERSION=1.4 -DOTBR_BORDER_ROUTING=ON`) if Matter is also enabled. - ---- - -## Real USB Radio: BRD2703 xG24 Explorer Kit - -The real-radio mode uses a **Silicon Labs BRD2703 xG24 Explorer Kit** connected over USB. -An optional Docker container (`otbr-radio`) runs two daemons: - -- **`cpcd`** — Silicon Labs CPC daemon. Manages the USB serial link to the radio - using the CPC multiplexing protocol. -- **`otbr-agent`** — OpenThread Border Router agent. Connects to `cpcd` via - `spinel+cpc://cpcd_0` and exposes the Thread network on D-Bus. - -A named Docker volume (`dbus-socket`) shares a private D-Bus socket directory -at `/var/run/otbr-dbus` between the `otbr-radio` container and the Barton -container so that Barton can reach `otbr-agent` over D-Bus with no code -changes. This is a private container-only bus, not the host's system D-Bus. - -``` -BRD2703 USB Radio - │ /dev/ttyACM0 - ▼ - cpcd (CPC daemon — hardware abstraction) - │ spinel+cpc://cpcd_0 - ▼ - otbr-agent (Thread network stack) - │ D-Bus io.openthread.BorderRouter.wpan0 - ▼ - Barton (your application code) -``` - ---- - -## Flashing RCP Firmware onto the BRD2703 - -Before use, the BRD2703 must be flashed with a CPC-capable **RCP (Radio Co-Processor)** -firmware image. - -If the board is new (out of the box), its debug adapter firmware must be updated first. -Connect the board to **Simplicity Studio** and accept any firmware upgrade prompts before -proceeding. - -Download the firmware image appropriate for the BRD2703 (EFR32MG24) in either -`.s37` or `.hex` format, then flash it using -[Simplicity Commander](https://www.silabs.com/developers/simplicity-studio). - -After flashing the RCP image, lock the security bits: - -```bash -sudo commander security lock -``` - ---- - -## USB-IP: Forwarding the Radio from Laptop to Remote Dev Server - -If your devcontainer runs on a **remote server** (common with VS Code Remote-SSH) -but the BRD2703 is plugged into your **local laptop**, use -[USB-IP](https://www.kernel.org/doc/html/latest/usb/usbip_protocol.html) to -forward the USB device over the network. - -### On the laptop (USB-IP server — where the radio is plugged in) - -```bash -# Update package lists -sudo apt update - -# Install usbip tools -sudo apt install linux-tools-generic linux-cloud-tools-generic - -# Load the kernel module -sudo modprobe usbip_host - -# Find the bus ID of the BRD2703 (look for "Silicon Labs" or "CP210x") -usbip list -l -# Example output: -# - busid 1-2 (10c4:ea60) -# Silicon Labs : CP210x UART Bridge (10c4:ea60) - -# Bind the device to usbip -sudo usbip bind -b - -# If the bind fails, check the correct bus ID and load the kernel modules, then retry -sudo modprobe usbip-core -sudo modprobe usbip-host -sudo modprobe vhci-hcd - -# Start the USB-IP daemon (listens on TCP port 3240) -sudo usbipd -D - -# Confirm that port 3240 is listening -netstat -an | grep 3240 - -# Due to firewall restrictions on the laptop, the remote server cannot connect -# to port 3240 directly. Use an SSH reverse tunnel to work around this. -# Keep this terminal open while working with the device. -ssh -R 3240:localhost:3240 @ -``` - -### On the remote dev server (USB-IP client — where Docker runs) - -```bash -# Install usbip tools -sudo apt install linux-tools-generic hwdata - -# Load the kernel module -sudo modprobe vhci-hcd - -# List the available devices via the tunnel and confirm the BRD2703 is visible -usbip list -r 127.0.0.1 - -# Attach the remote device (replace the bus ID as appropriate) -sudo usbip attach -r 127.0.0.1 -b - -# Confirm the device appeared -ls -la /dev/ttyACM* -# Expected: /dev/ttyACM0 -``` - -> **Tip**: Set `RADIO_DEVICE` explicitly before starting, even if the device is -> at the default-looking path `/dev/ttyACM0`. This avoids silently using the -> wrong device on shared servers. For example: -> ```bash -> export RADIO_DEVICE=/dev/ttyACM1 -> ``` - ---- - -## Starting the Real-Radio Container - -### CLI (`dockerw`) - -Use the `-T` flag. This adds `docker/compose.otbr-radio.yaml` to the compose -stack and starts the `otbr-radio` container alongside the main Barton container. -Set `RADIO_DEVICE` explicitly before starting, even if the device is -`/dev/ttyACM0`: - -```bash -RADIO_DEVICE=/dev/ttyACM0 ./dockerw -T -``` - -To override the radio device or backbone interface: - -```bash -RADIO_DEVICE=/dev/ttyACM1 BACKBONE_IF=eth1 ./dockerw -T -``` - -This creates two containers: the `barton` container and the `otbr-radio` container. - -To check the logs and confirm all services started correctly: - -```bash -docker compose -f docker/compose.yaml -f docker/compose.otbr-radio.yaml logs -f otbr-radio -``` - -Expected log progression: - -- `Using USB radio device: /dev/ttyACM1` (or your selected device) -- `Connected to Secondary` -- `Secondary CPC v4.4.6` -- `Daemon startup was successful. Waiting for client connections` -- `Starting otbr-agent` - -To enter the `otbr-radio` container, find its container ID and run: - -```bash -docker exec -it /bin/bash -``` - -Verify that `otbr-agent` is running: - -```bash -pgrep -a otbr-agent -``` - -Check the runtime cpcd configuration: - -```bash -cat /usr/local/etc/cpcd.conf -``` - -Check the Thread stack state inside otbr-radio container: - -```bash -ot-ctl state -``` - -If `ot-ctl state` returns `disabled`, the radio and `otbr-agent` are healthy but no thread network has been formed yet. Run the following commands to bring up the interface and form a new network: - -```bash -ot-ctl dataset init new -ot-ctl dataset commit active -ot-ctl ifconfig up -ot-ctl thread start -# After few seconds check the state -ot-ctl state -``` - -Expected result: - -- `leader` — this node formed a new Thread network -- `router` — this node joined an existing Thread network - -The `otbr-radio` container continues running after you exit the shell. To stop and -remove it explicitly: - -```bash -docker compose -f docker/compose.yaml -f docker/compose.otbr-radio.yaml rm -sf otbr-radio -``` - -### Devcontainer (VS Code) - -`compose.otbr-radio.yaml` is included in the devcontainer by default — no -manual edit to `.devcontainer/devcontainer.json` is required. - -The `otbr-radio` container will start automatically when the devcontainer -launches. If `RADIO_DEVICE` is not set it will exit with a clear error in its -own logs, but **the `barton` devcontainer is unaffected and starts normally**. - -To enable Thread radio support, set `RADIO_DEVICE` in `docker/.env` after -running `docker/setupDockerEnv.sh`, or export it in your host shell before -opening VS Code. Set it explicitly even if the device path is `/dev/ttyACM0`: - -```bash -export RADIO_DEVICE=/dev/ttyACM0 -``` - -Then rebuild the devcontainer (**Dev Containers: Rebuild Container**). - ---- - -## Verifying the D-Bus Interface - -### From inside the `otbr-radio` container - -`gdbus` is available and can be used to query the OTBR D-Bus service: - -```bash -gdbus introspect \ - --address unix:path=/var/run/otbr-dbus/system_bus_socket \ - --dest io.openthread.BorderRouter.wpan0 \ - --object-path /io/openthread/BorderRouter/wpan0 -``` - -To read the current Thread device role: - -```bash -gdbus call \ - --address unix:path=/var/run/otbr-dbus/system_bus_socket \ - --dest io.openthread.BorderRouter.wpan0 \ - --object-path /io/openthread/BorderRouter/wpan0 \ - --method org.freedesktop.DBus.Properties.Get \ - io.openthread.BorderRouter DeviceRole -``` - -### From inside the Barton container - -The `dbus-socket` volume shares `/var/run/otbr-dbus` between the two -containers. To reach the private D-Bus daemon started by `otbr-radio`, Barton -must be pointed at that socket via `DBUS_SYSTEM_BUS_ADDRESS`. - -- **`dockerw -T`**: `DBUS_SYSTEM_BUS_ADDRESS` is injected automatically, so no - extra steps are required. -- **Devcontainer**: export the variable before rebuilding the container: - - ```bash - export DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/otbr-dbus/system_bus_socket - ``` - - Then rebuild the devcontainer (**Dev Containers: Rebuild Container**). - -Once `DBUS_SYSTEM_BUS_ADDRESS` is set you can verify connectivity: - -```bash -gdbus introspect \ - --address unix:path=/var/run/otbr-dbus/system_bus_socket \ - --dest io.openthread.BorderRouter.wpan0 \ - --object-path /io/openthread/BorderRouter/wpan0 -``` - -If Barton is built with `BCORE_THREAD=ON` and `DBUS_SYSTEM_BUS_ADDRESS` points -to the private socket, it will automatically discover and communicate with the -border router over D-Bus on startup. diff --git a/docs/USING_BARTON_GUIDE.md b/docs/USING_BARTON_GUIDE.md index a31265e2..84ff9af7 100644 --- a/docs/USING_BARTON_GUIDE.md +++ b/docs/USING_BARTON_GUIDE.md @@ -14,7 +14,7 @@ Barton's API is based upon GObject (the GLib Object System) in native C as the p 1. Configure Barton as appropriate through cmake options (see `config/cmake/options.cmake`) 2. If using Matter, configure and build your Matter SDK as described in [MATTER_SUPPORT.md](). 3. If using Zigbee, see [ZIGBEE_SUPPORT.md](). -4. If using OpenThread's Border Router, see [THREAD_BORDER_ROUTER_SUPPORT.md](). +4. If using OpenThread's Border Router, see [REMOTE_RADIO_FOR_DEVELOPMENT.md](). ### Interacting with Barton from your Code diff --git a/scripts/remote-radio/usbip-attach-local.sh b/scripts/remote-radio/usbip-attach-local.sh new file mode 100755 index 00000000..51f33248 --- /dev/null +++ b/scripts/remote-radio/usbip-attach-local.sh @@ -0,0 +1,283 @@ +#!/usr/bin/env bash +# ------------------------------ tabstop = 4 ---------------------------------- +# +# If not stated otherwise in this file or this component's LICENSE file the +# following copyright and licenses apply: +# +# Copyright 2026 Comcast Cable Communications Management, LLC +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# +# ------------------------------ tabstop = 4 ---------------------------------- + +############################################################################### +# usbip-attach-local.sh +# +# Run this on the LOCAL WORKSTATION (laptop) where the BRD2703 xG24 radio is +# physically plugged in via USB. It: +# +# 1. Ensures required packages and kernel modules are loaded. +# 2. Auto-detects the Silicon Labs radio by USB vendor/product ID. +# 3. Binds the device to USB-IP. +# 4. Starts the USB-IP daemon if not already running. +# 5. Opens an SSH reverse tunnel to the remote server using a per-user +# loopback address (127.0.. from remote UID) so multiple +# developers can share a server without port conflicts. +# Requires GatewayPorts clientspecified in sshd_config on the server. +# +# Usage: +# scripts/usbip-attach-local.sh user@remote-server +# scripts/usbip-attach-local.sh remote-server # uses current username +# +# Leave the terminal open while you work. Press Ctrl-C to close the tunnel. +# Run scripts/usbip-detach-local.sh to clean up. +# +# See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for full setup instructions. +############################################################################### + +set -euo pipefail + +# ─── Constants ──────────────────────────────────────────────────────────────── +SILABS_VID="10c4" +SILABS_PID="ea60" +SEGGER_VID="1366" +SEGGER_PID="0105" +RADIO_IDS=("${SILABS_VID}:${SILABS_PID}" "${SEGGER_VID}:${SEGGER_PID}") +USBIP_PORT=3240 +STATE_FILE="/tmp/.usbip-radio-local-$(id -u)" + +# ─── Helpers ────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${BOLD}[usbip-local]${NC} $*"; } +ok() { echo -e "${GREEN}[usbip-local] OK:${NC} $*"; } +warn() { echo -e "${YELLOW}[usbip-local] WARNING:${NC} $*" >&2; } +fail() { echo -e "${RED}[usbip-local] ERROR:${NC} $*" >&2; } + +die() { + fail "$@" + echo "" + echo -e "${RED}[usbip-local] FAILED — see errors above.${NC}" + exit 1 +} + +# ─── Parse arguments ───────────────────────────────────────────────────────── +if [[ $# -lt 1 ]]; then + echo "Usage: $0 <[user@]remote-host>" + echo "" + echo " remote-host The SSH host where Docker and the devcontainer run." + echo " Prefix with user@ if your remote username differs." + echo "" + echo "Examples:" + echo " $0 devserver.example.com" + echo " $0 jdoe@devserver.example.com" + exit 1 +fi + +SSH_TARGET="$1" + +# Extract user and host for display. If no user@ prefix, use current user. +if [[ "${SSH_TARGET}" == *@* ]]; then + REMOTE_USER="${SSH_TARGET%%@*}" + REMOTE_HOST="${SSH_TARGET#*@}" +else + REMOTE_USER="$(whoami)" + REMOTE_HOST="${SSH_TARGET}" + SSH_TARGET="${REMOTE_USER}@${REMOTE_HOST}" +fi + +# ─── Pre-flight checks ─────────────────────────────────────────────────────── +info "Checking prerequisites..." + +if [[ "$(uname -s)" != "Linux" ]]; then + die "This script only works on Linux." +fi + +if ! command -v usbip &>/dev/null; then + info "usbip not found — installing..." + sudo apt-get update -qq + sudo apt-get install -y -qq linux-tools-generic linux-cloud-tools-generic +fi + +if ! command -v usbip &>/dev/null; then + die "usbip still not found after install. Check your kernel version and packages." +fi + +ok "usbip is available." + +# ─── Resolve the remote UID for per-user loopback address ───────────────────── +info "Resolving remote UID for ${SSH_TARGET}..." +REMOTE_UID=$(ssh -o ConnectTimeout=10 "${SSH_TARGET}" "id -u" 2>/dev/null) || \ + die "Cannot SSH to ${SSH_TARGET}. Check connectivity and credentials." + +if ! [[ "${REMOTE_UID}" =~ ^[0-9]+$ ]]; then + die "Got invalid UID '${REMOTE_UID}' from remote host." +fi + +# Each user gets a unique loopback address so multiple developers can share +# a server. Both usbip list and usbip attach connect to this address on the +# standard port 3240. Requires 'GatewayPorts clientspecified' in sshd_config. +# UID 1000 → 127.0.3.232, UID 1001 → 127.0.3.233, etc. +USBIP_LOOPBACK="127.0.$(( (REMOTE_UID >> 8) & 255 )).$(( REMOTE_UID & 255 ))" +ok "Remote UID: ${REMOTE_UID}, tunnel: ${USBIP_LOOPBACK}:${USBIP_PORT}" + +# ─── Verify kernel modules ──────────────────────────────────────────────────── +info "Checking required kernel modules..." + +MISSING_MODULES=() + +for mod in usbip_core usbip_host vhci_hcd; do + + if ! grep -qw "${mod}" /proc/modules 2>/dev/null; then + # Try to load it (may fail without sudo privileges) + sudo modprobe "${mod}" 2>/dev/null || true + + if ! grep -qw "${mod}" /proc/modules 2>/dev/null; then + MISSING_MODULES+=("${mod}") + fi + fi +done + +if [[ ${#MISSING_MODULES[@]} -gt 0 ]]; then + fail "The following required kernel modules are not loaded:" + echo "" + + for mod in "${MISSING_MODULES[@]}"; do + echo -e " ${RED}•${NC} ${BOLD}${mod}${NC}" + done + + echo "" + echo -e " Please load them with:" + echo "" + + for mod in "${MISSING_MODULES[@]}"; do + echo -e " ${BOLD}sudo modprobe ${mod}${NC}" + done + + echo "" + die "Required kernel modules are not loaded." +fi + +ok "Kernel modules loaded." + +# ─── Find the BRD2703 radio ─────────────────────────────────────────────────── +info "Searching for BRD2703 radio (${RADIO_IDS[*]})..." + +RADIO_BUSID="" +MATCHED_ID="" + +while IFS= read -r line; do + + for rid in "${RADIO_IDS[@]}"; do + + if echo "${line}" | grep -qi "${rid}"; then + RADIO_BUSID=$(echo "${line}" | grep -oP 'busid\s+\K[0-9.-]+' || echo "${line}" | awk '{print $3}') + MATCHED_ID="${rid}" + break 2 + fi + done +done < <(usbip list -l 2>/dev/null) + +# Fallback: scan sysfs directly +if [[ -z "${RADIO_BUSID}" ]]; then + + for dev in /sys/bus/usb/devices/*/idVendor; do + devDir=$(dirname "${dev}") + vid=$(cat "${devDir}/idVendor" 2>/dev/null || true) + pid=$(cat "${devDir}/idProduct" 2>/dev/null || true) + + for rid in "${RADIO_IDS[@]}"; do + IFS=: read -r rVid rPid <<< "${rid}" + + if [[ "${vid}" == "${rVid}" && "${pid}" == "${rPid}" ]]; then + RADIO_BUSID=$(basename "${devDir}") + MATCHED_ID="${rid}" + break 2 + fi + done + done +fi + +if [[ -z "${RADIO_BUSID}" ]]; then + die "No BRD2703 radio found. Is it plugged in via USB?" \ + "Looked for USB IDs: ${RADIO_IDS[*]}" +fi + +ok "Found radio at bus ID: ${RADIO_BUSID} (${MATCHED_ID})" + +# ─── Bind the device ───────────────────────────────────────────────────────── +info "Binding device ${RADIO_BUSID} to USB-IP..." + +# Unbind first in case it's already bound (idempotent). +sudo usbip unbind -b "${RADIO_BUSID}" 2>/dev/null || true +sudo usbip bind -b "${RADIO_BUSID}" + +if ! usbip list -l 2>/dev/null | grep -q "${RADIO_BUSID}"; then + die "Device ${RADIO_BUSID} does not appear in 'usbip list -l' after binding." +fi + +ok "Device bound." + +# ─── Start the USB-IP daemon ───────────────────────────────────────────────── +if ss -tlnp 2>/dev/null | grep -q ":${USBIP_PORT}\b" || \ + netstat -tlnp 2>/dev/null | grep -q ":${USBIP_PORT}\b"; then + ok "usbipd already listening on port ${USBIP_PORT}." +else + info "Starting usbipd on port ${USBIP_PORT}..." + sudo usbipd -D + sleep 1 + + if ss -tlnp 2>/dev/null | grep -q ":${USBIP_PORT}\b" || \ + netstat -tlnp 2>/dev/null | grep -q ":${USBIP_PORT}\b"; then + ok "usbipd started." + else + die "usbipd failed to start on port ${USBIP_PORT}." + fi +fi + +# ─── Save state for teardown ───────────────────────────────────────────────── +cat > "${STATE_FILE}" <. from UID). +# 3. Verifies the USB-IP tunnel is reachable. +# 4. Lists available devices and auto-detects the BRD2703 radio. +# 5. Detaches any stale attachment belonging to this user only. +# 6. Attaches the remote USB device. +# 7. Waits for the /dev/ttyACM* device to appear. +# 8. Saves state so usbip-detach-remote.sh can clean up safely. +# +# No arguments required. +# +# Requires 'GatewayPorts clientspecified' in sshd_config on this server. +# +# See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for full setup instructions. +############################################################################### + +set -euo pipefail + +# ─── Constants ──────────────────────────────────────────────────────────────── +SILABS_VID="10c4" +SILABS_PID="ea60" +SEGGER_VID="1366" +SEGGER_PID="0105" +RADIO_IDS=("${SILABS_VID}:${SILABS_PID}" "${SEGGER_VID}:${SEGGER_PID}") +USBIP_PORT=3240 +# Each user gets a unique loopback address so multiple developers can share +# a server. Matches the address used by usbip-attach-local.sh. +# UID 1000 → 127.0.3.232, UID 1001 → 127.0.3.233, etc. +USBIP_LOOPBACK="127.0.$(( ($(id -u) >> 8) & 255 )).$(( $(id -u) & 255 ))" +STATE_FILE="/tmp/.usbip-radio-remote-$(id -u)" + +# ─── Helpers ────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${BOLD}[usbip-remote]${NC} $*"; } +ok() { echo -e "${GREEN}[usbip-remote] OK:${NC} $*"; } +warn() { echo -e "${YELLOW}[usbip-remote] WARNING:${NC} $*" >&2; } +fail() { echo -e "${RED}[usbip-remote] ERROR:${NC} $*" >&2; } + +die() { + fail "$@" + echo "" + echo -e "${RED}[usbip-remote] FAILED — see errors above.${NC}" + exit 1 +} + +# ─── Pre-flight checks ─────────────────────────────────────────────────────── +info "Checking prerequisites..." +info "Per-user loopback: ${USBIP_LOOPBACK}:${USBIP_PORT} (UID $(id -u))" + +if [[ "$(uname -s)" != "Linux" ]]; then + die "This script only works on Linux." +fi + +if ! command -v usbip &>/dev/null; then + info "usbip not found — installing..." + sudo apt-get update -qq + sudo apt-get install -y -qq linux-tools-generic hwdata +fi + +if ! command -v usbip &>/dev/null; then + die "usbip still not found after install." +fi + +ok "usbip is available." + +# ─── Verify kernel modules ──────────────────────────────────────────────────── +info "Checking required kernel modules..." + +MISSING_MODULES=() + +for mod in vhci_hcd usbip_core; do + + if ! grep -qw "${mod}" /proc/modules 2>/dev/null; then + MISSING_MODULES+=("${mod}") + fi +done + +if [[ ${#MISSING_MODULES[@]} -gt 0 ]]; then + fail "The following required kernel modules are not loaded:" + echo "" + + for mod in "${MISSING_MODULES[@]}"; do + echo -e " ${RED}•${NC} ${BOLD}${mod}${NC}" + done + + echo "" + echo -e " These modules must be loaded by a system administrator." + echo -e " Please ask an administrator to run:" + echo "" + + for mod in "${MISSING_MODULES[@]}"; do + echo -e " ${BOLD}sudo modprobe ${mod}${NC}" + done + + echo "" + echo -e " To make this persistent across reboots, ask them to add the" + echo -e " module names to ${BOLD}/etc/modules-load.d/usbip.conf${NC}." + die "Required kernel modules are not loaded." +fi + +ok "Kernel modules loaded (vhci_hcd, usbip_core)." + +# ─── Verify tunnel is up ───────────────────────────────────────────────────── +info "Checking USB-IP tunnel on ${USBIP_LOOPBACK}:${USBIP_PORT}..." + +if ! ss -tlnp 2>/dev/null | grep -q "${USBIP_LOOPBACK}:${USBIP_PORT}" && \ + ! netstat -tlnp 2>/dev/null | grep -q "${USBIP_LOOPBACK}:${USBIP_PORT}"; then + die "Nothing is listening on ${USBIP_LOOPBACK}:${USBIP_PORT}." \ + "Make sure usbip-attach-local.sh is running on your workstation" \ + "with an SSH reverse tunnel open." \ + "The remote sshd must have 'GatewayPorts clientspecified' enabled." +fi + +ok "Tunnel is reachable at ${USBIP_LOOPBACK}:${USBIP_PORT}." + +# ─── List available devices ────────────────────────────────────────────────── +info "Listing remote USB devices..." + +DEVICE_LIST="" + +if ! DEVICE_LIST=$(usbip list -r "${USBIP_LOOPBACK}" 2>&1); then + fail "usbip list failed with output:" + echo "${DEVICE_LIST}" | head -20 + die "Failed to list remote devices. Is the tunnel open?" +fi + +echo "${DEVICE_LIST}" | head -20 +echo "" + +# ─── Find the BRD2703 radio ────────────────────────────────────────────────── +info "Looking for BRD2703 radio (${RADIO_IDS[*]})..." + +RADIO_BUSID="" +MATCHED_ID="" + +while IFS= read -r line; do + + for rid in "${RADIO_IDS[@]}"; do + + if echo "${line}" | grep -qi "${rid}"; then + RADIO_BUSID=$(echo "${line}" | grep -oP '^\s*\K[0-9.-]+' || true) + + if [[ -z "${RADIO_BUSID}" ]]; then + RADIO_BUSID=$(echo "${line}" | awk -F: '{print $1}' | tr -d ' ') + fi + + MATCHED_ID="${rid}" + break 2 + fi + done +done <<< "${DEVICE_LIST}" + +if [[ -z "${RADIO_BUSID}" ]]; then + die "No BRD2703 radio found in the remote device list." \ + "Looked for USB IDs: ${RADIO_IDS[*]}" \ + "Check that the radio is plugged in and bound on the local workstation." +fi + +ok "Found radio at bus ID: ${RADIO_BUSID} (${MATCHED_ID})" + +# ─── Detach any stale attachment for THIS user ──────────────────────────────── +# Only touch vhci ports recorded in our own state file. +if [[ -f "${STATE_FILE}" ]]; then + PREV_PORT=$(grep '^VHCI_PORT=' "${STATE_FILE}" 2>/dev/null | cut -d= -f2 || true) + + if [[ -n "${PREV_PORT}" ]]; then + info "Detaching stale USB-IP vhci port ${PREV_PORT} from previous session..." + sudo usbip detach -p "${PREV_PORT}" 2>/dev/null || true + sleep 1 + fi + + rm -f "${STATE_FILE}" +fi + +# ─── Snapshot existing ttyACM devices ───────────────────────────────────────── +TTY_BEFORE=$(ls /dev/ttyACM* 2>/dev/null | sort || true) + +# ─── Snapshot existing vhci ports ───────────────────────────────────────────── +VHCI_BEFORE=$(usbip port 2>/dev/null | grep -oP 'Port\s+\K[0-9]+' | sort || true) + +# ─── Attach the device ─────────────────────────────────────────────────────── +info "Attaching device ${RADIO_BUSID} via ${USBIP_LOOPBACK}..." + +sudo usbip attach -r "${USBIP_LOOPBACK}" -b "${RADIO_BUSID}" + +# ─── Identify the new vhci port (for clean detach later) ───────────────────── +sleep 1 +VHCI_AFTER=$(usbip port 2>/dev/null | grep -oP 'Port\s+\K[0-9]+' | sort || true) +NEW_VHCI_PORT="" + +for port in ${VHCI_AFTER}; do + + if ! echo "${VHCI_BEFORE}" | grep -qw "${port}"; then + NEW_VHCI_PORT="${port}" + break + fi +done + +# ─── Wait for the ttyACM device to appear ──────────────────────────────────── +info "Waiting for /dev/ttyACM* device to appear..." + +RADIO_DEVICE="" +WAIT_MAX=10 +waited=0 + +while [[ ${waited} -lt ${WAIT_MAX} ]]; do + sleep 1 + waited=$((waited + 1)) + + TTY_AFTER=$(ls /dev/ttyACM* 2>/dev/null | sort || true) + + for tty in ${TTY_AFTER}; do + + if ! echo "${TTY_BEFORE}" | grep -qw "${tty}"; then + RADIO_DEVICE="${tty}" + break + fi + done + + if [[ -n "${RADIO_DEVICE}" ]]; then + break + fi +done + +if [[ -z "${RADIO_DEVICE}" ]]; then + # Fall back to the last ttyACM device if we can't diff + RADIO_DEVICE=$(ls /dev/ttyACM* 2>/dev/null | tail -1 || true) +fi + +if [[ -z "${RADIO_DEVICE}" ]]; then + die "No /dev/ttyACM* device appeared after attaching." +fi + +ok "Radio device appeared: ${RADIO_DEVICE}" + +# ─── Verify the device is accessible ───────────────────────────────────────── +if [[ ! -c "${RADIO_DEVICE}" ]]; then + die "${RADIO_DEVICE} exists but is not a character device." +fi + +ok "Device is accessible." + +# ─── Save state for teardown ───────────────────────────────────────────────── +cat > "${STATE_FILE}" <&2; } +fail() { echo -e "${RED}[usbip-local] ERROR:${NC} $*" >&2; } + +# ─── Load state ────────────────────────────────────────────────────────────── +RADIO_BUSID="" + +if [[ -f "${STATE_FILE}" ]]; then + RADIO_BUSID=$(grep '^RADIO_BUSID=' "${STATE_FILE}" 2>/dev/null | cut -d= -f2 || true) + info "Loaded state from ${STATE_FILE}" +else + warn "No state file found at ${STATE_FILE}." + warn "Will attempt auto-detection." +fi + +# ─── Auto-detect if no state ───────────────────────────────────────────────── +SILABS_VID="10c4" +SILABS_PID="ea60" +SEGGER_VID="1366" +SEGGER_PID="0105" +RADIO_IDS=("${SILABS_VID}:${SILABS_PID}" "${SEGGER_VID}:${SEGGER_PID}") + +if [[ -z "${RADIO_BUSID}" ]]; then + info "Scanning for BRD2703 radio (${RADIO_IDS[*]})..." + + for dev in /sys/bus/usb/devices/*/idVendor; do + devDir=$(dirname "${dev}") + vid=$(cat "${devDir}/idVendor" 2>/dev/null || true) + pid=$(cat "${devDir}/idProduct" 2>/dev/null || true) + + for rid in "${RADIO_IDS[@]}"; do + IFS=: read -r rVid rPid <<< "${rid}" + + if [[ "${vid}" == "${rVid}" && "${pid}" == "${rPid}" ]]; then + RADIO_BUSID=$(basename "${devDir}") + break 2 + fi + done + done +fi + +# ─── Unbind the device ─────────────────────────────────────────────────────── +if [[ -n "${RADIO_BUSID}" ]]; then + info "Unbinding device ${RADIO_BUSID} from USB-IP..." + sudo usbip unbind -b "${RADIO_BUSID}" 2>/dev/null || true + ok "Device unbound." +else + warn "No radio bus ID found — skipping unbind." +fi + +# ─── Stop usbipd ───────────────────────────────────────────────────────────── +info "Stopping usbipd..." + +if pgrep -x usbipd &>/dev/null; then + sudo pkill -x usbipd 2>/dev/null || true + sleep 1 + + if pgrep -x usbipd &>/dev/null; then + warn "usbipd is still running." + else + ok "usbipd stopped." + fi +else + ok "usbipd was not running." +fi + +# ─── Clean up state file ───────────────────────────────────────────────────── +if [[ -f "${STATE_FILE}" ]]; then + rm -f "${STATE_FILE}" + ok "State file removed." +fi + +# ─── Done ───────────────────────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}${BOLD}=======================================${NC}" +echo -e "${GREEN}${BOLD} LOCAL TEARDOWN COMPLETE${NC}" +echo -e "${GREEN}${BOLD}=======================================${NC}" +echo "" +info "The SSH tunnel (if still open) must be closed separately" +info "by pressing Ctrl-C in the terminal running usbip-attach-local.sh." +echo "" diff --git a/scripts/remote-radio/usbip-detach-remote.sh b/scripts/remote-radio/usbip-detach-remote.sh new file mode 100755 index 00000000..e8cac60f --- /dev/null +++ b/scripts/remote-radio/usbip-detach-remote.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# ------------------------------ tabstop = 4 ---------------------------------- +# +# If not stated otherwise in this file or this component's LICENSE file the +# following copyright and licenses apply: +# +# Copyright 2026 Comcast Cable Communications Management, LLC +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# +# ------------------------------ tabstop = 4 ---------------------------------- + +############################################################################### +# usbip-detach-remote.sh +# +# Run this on the REMOTE DEV SERVER to tear down the USB-IP attachment +# started by usbip-attach-remote.sh. It: +# +# 1. Reads the saved state file to identify this user's vhci port. +# 2. Detaches ONLY this user's device (does not affect other users). +# 3. Removes the state file. +# +# No arguments required. +# +# This script is safe on shared servers — it only touches the vhci port +# recorded in the current user's state file. +############################################################################### + +set -euo pipefail + +# ─── Constants ──────────────────────────────────────────────────────────────── +STATE_FILE="/tmp/.usbip-radio-remote-$(id -u)" + +# ─── Helpers ────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${BOLD}[usbip-remote]${NC} $*"; } +ok() { echo -e "${GREEN}[usbip-remote] OK:${NC} $*"; } +warn() { echo -e "${YELLOW}[usbip-remote] WARNING:${NC} $*" >&2; } +fail() { echo -e "${RED}[usbip-remote] ERROR:${NC} $*" >&2; } + +die() { + fail "$@" + echo "" + echo -e "${RED}[usbip-remote] FAILED — see errors above.${NC}" + exit 1 +} + +# ─── Load state ────────────────────────────────────────────────────────────── +if [[ ! -f "${STATE_FILE}" ]]; then + die "No state file found at ${STATE_FILE}." \ + "Either usbip-attach-remote.sh was not run, or it was already cleaned up." +fi + +VHCI_PORT=$(grep '^VHCI_PORT=' "${STATE_FILE}" 2>/dev/null | cut -d= -f2 || true) +RADIO_DEVICE=$(grep '^RADIO_DEVICE=' "${STATE_FILE}" 2>/dev/null | cut -d= -f2 || true) +RADIO_BUSID=$(grep '^RADIO_BUSID=' "${STATE_FILE}" 2>/dev/null | cut -d= -f2 || true) + +info "State loaded from ${STATE_FILE}" +info " VHCI port: ${VHCI_PORT:-unknown}" +info " Radio device: ${RADIO_DEVICE:-unknown}" +info " Bus ID: ${RADIO_BUSID:-unknown}" + +# ─── Detach the device ─────────────────────────────────────────────────────── +if [[ -n "${VHCI_PORT}" && "${VHCI_PORT}" != "unknown" ]]; then + info "Detaching vhci port ${VHCI_PORT}..." + + if sudo usbip detach -p "${VHCI_PORT}" 2>/dev/null; then + ok "Device detached from vhci port ${VHCI_PORT}." + else + warn "Detach returned an error — device may already be detached." + fi + + # Wait briefly for the ttyACM device to disappear + if [[ -n "${RADIO_DEVICE}" && -e "${RADIO_DEVICE}" ]]; then + info "Waiting for ${RADIO_DEVICE} to disappear..." + waited=0 + + while [[ -e "${RADIO_DEVICE}" && ${waited} -lt 5 ]]; do + sleep 1 + waited=$((waited + 1)) + done + + if [[ -e "${RADIO_DEVICE}" ]]; then + warn "${RADIO_DEVICE} still exists after detach." + else + ok "${RADIO_DEVICE} removed." + fi + fi +else + warn "No vhci port recorded — cannot safely detach." + warn "Use 'usbip port' to list attached devices and 'sudo usbip detach -p ' manually." +fi + +# ─── Clean up state file ───────────────────────────────────────────────────── +rm -f "${STATE_FILE}" +ok "State file removed." + +# ─── Done ───────────────────────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}${BOLD}=======================================${NC}" +echo -e "${GREEN}${BOLD} REMOTE TEARDOWN COMPLETE${NC}" +echo -e "${GREEN}${BOLD}=======================================${NC}" +echo "" +info "The USB device has been detached from this server." +info "Close the SSH tunnel on your workstation (Ctrl-C) or run" +info "${BOLD}scripts/remote-radio/usbip-detach-local.sh${NC} to unbind and stop usbipd." +echo "" diff --git a/scripts/remote-radio/usbip-validate.sh b/scripts/remote-radio/usbip-validate.sh new file mode 100755 index 00000000..5dfcb029 --- /dev/null +++ b/scripts/remote-radio/usbip-validate.sh @@ -0,0 +1,339 @@ +#!/usr/bin/env bash +# ------------------------------ tabstop = 4 ---------------------------------- +# +# If not stated otherwise in this file or this component's LICENSE file the +# following copyright and licenses apply: +# +# Copyright 2026 Comcast Cable Communications Management, LLC +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# +# ------------------------------ tabstop = 4 ---------------------------------- + +############################################################################### +# usbip-validate.sh +# +# Run this from INSIDE the Barton container (devcontainer or dockerw session) +# after the otbr-radio container is up. It validates the entire chain: +# +# 1. USB device is present on the host +# 2. D-Bus socket is shared and reachable +# 3. otbr-radio container is running (cpcd, bt_host_cpc_hci_bridge, +# btattach, bluetoothd, otbr-agent) +# 4. Thread network is responsive (ot-ctl state via D-Bus) +# 5. BLE adapter is configured and visible +# 6. ble_adapter_id file is present and points to a valid HCI device +# +# No arguments required. +# +# See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for full setup instructions. +############################################################################### + +set -uo pipefail + +# ─── Helpers ────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +NC='\033[0m' + +PASS_COUNT=0 +FAIL_COUNT=0 +WARN_COUNT=0 + +info() { echo -e "${BOLD}[validate]${NC} $*"; } +pass() { echo -e " ${GREEN}PASS${NC} $*"; PASS_COUNT=$((PASS_COUNT + 1)); } +fail() { echo -e " ${RED}FAIL${NC} $*"; FAIL_COUNT=$((FAIL_COUNT + 1)); } +warn() { echo -e " ${YELLOW}WARN${NC} $*"; WARN_COUNT=$((WARN_COUNT + 1)); } +skip() { echo -e " ${YELLOW}SKIP${NC} $*"; } + +# ─── Shared paths ──────────────────────────────────────────────────────────── +DBUS_DIR="/var/run/otbr-dbus" +DBUS_SOCKET="${DBUS_DIR}/system_bus_socket" +BLE_ADAPTER_FILE="${DBUS_DIR}/ble_adapter_id" +HOST_NETNS="/run/host-netns" + +# Docker API access (from inside the container) +DOCKER_SOCK="/var/run/docker.sock" + +echo "" +echo -e "${BOLD}═══════════════════════════════════════${NC}" +echo -e "${BOLD} Thread Radio & BLE Validation${NC}" +echo -e "${BOLD}═══════════════════════════════════════${NC}" +echo "" + +############################################################################### +# 1. D-Bus socket +############################################################################### +info "Checking D-Bus socket..." + +if [[ -S "${DBUS_SOCKET}" ]]; then + pass "D-Bus socket exists: ${DBUS_SOCKET}" +else + fail "D-Bus socket not found: ${DBUS_SOCKET}" + echo " The otbr-radio container may not be running, or the" + echo " dbus-socket volume is not mounted." +fi + +############################################################################### +# 2. otbr-radio container processes (via Docker API) +############################################################################### +info "Checking otbr-radio container..." + +OTBR_CONTAINER_ID="" +EXPECTED_PROCS=("cpcd" "otbr-agent") +OPTIONAL_PROCS=("bt_host_cpc_hci_bridge" "btattach" "bluetoothd") + +if [[ -S "${DOCKER_SOCK}" ]]; then + # Find the otbr-radio container + OTBR_CONTAINER_ID=$(sudo curl -s --unix-socket "${DOCKER_SOCK}" \ + "http://localhost/containers/json" 2>/dev/null | \ + python3 -c " +import sys, json +try: + containers = json.load(sys.stdin) + for c in containers: + for name in c.get('Names', []): + if 'otbr-radio' in name: + print(c['Id'][:12]) + sys.exit(0) +except Exception: + pass +" 2>/dev/null || true) + + if [[ -n "${OTBR_CONTAINER_ID}" ]]; then + pass "otbr-radio container is running (${OTBR_CONTAINER_ID})" + + # Execute ps inside the container + EXEC_ID=$(sudo curl -s --unix-socket "${DOCKER_SOCK}" \ + -X POST -H "Content-Type: application/json" \ + -d '{"Cmd":["ps","aux"],"AttachStdout":true,"AttachStderr":true}' \ + "http://localhost/containers/${OTBR_CONTAINER_ID}/exec" 2>/dev/null | \ + python3 -c "import sys,json; print(json.load(sys.stdin).get('Id',''))" 2>/dev/null || true) + + PS_OUTPUT="" + + if [[ -n "${EXEC_ID}" ]]; then + PS_OUTPUT=$(sudo curl -s --unix-socket "${DOCKER_SOCK}" \ + -X POST -H "Content-Type: application/json" \ + -d '{"Detach":false,"Tty":false}' \ + "http://localhost/exec/${EXEC_ID}/start" 2>/dev/null | strings || true) + fi + + if [[ -n "${PS_OUTPUT}" ]]; then + + for proc in "${EXPECTED_PROCS[@]}"; do + + if echo "${PS_OUTPUT}" | grep -qw "${proc}"; then + pass "${proc} is running" + else + fail "${proc} is NOT running in otbr-radio container" + fi + done + + for proc in "${OPTIONAL_PROCS[@]}"; do + + if echo "${PS_OUTPUT}" | grep -qw "${proc}"; then + pass "${proc} is running" + else + warn "${proc} is not running (BLE may not be available)" + fi + done + else + warn "Could not inspect processes inside otbr-radio container" + fi + else + fail "otbr-radio container is NOT running" + echo " Start it with: RADIO_DEVICE=/dev/ttyACMx ./dockerw -T" + echo " Or rebuild the devcontainer with RADIO_DEVICE set." + fi +else + skip "Docker socket not available — cannot inspect otbr-radio container" +fi + +############################################################################### +# 3. D-Bus connectivity to otbr-agent +############################################################################### +info "Checking D-Bus connectivity to otbr-agent..." + +if [[ -S "${DBUS_SOCKET}" ]] && command -v gdbus &>/dev/null; then + DBUS_INTROSPECT=$(gdbus introspect \ + --address "unix:path=${DBUS_SOCKET}" \ + --dest io.openthread.BorderRouter.wpan0 \ + --object-path /io/openthread/BorderRouter/wpan0 2>&1 || true) + + if echo "${DBUS_INTROSPECT}" | grep -q "interface"; then + pass "otbr-agent D-Bus interface is reachable" + + # Try to read Thread device role + DEVICE_ROLE=$(gdbus call \ + --address "unix:path=${DBUS_SOCKET}" \ + --dest io.openthread.BorderRouter.wpan0 \ + --object-path /io/openthread/BorderRouter/wpan0 \ + --method org.freedesktop.DBus.Properties.Get \ + io.openthread.BorderRouter DeviceRole 2>/dev/null || true) + + if [[ -n "${DEVICE_ROLE}" ]]; then + ROLE_VALUE=$(echo "${DEVICE_ROLE}" | grep -oP "'[^']+'" | head -1 | tr -d "'") + pass "Thread device role: ${ROLE_VALUE:-unknown}" + + if [[ "${ROLE_VALUE}" == "leader" || "${ROLE_VALUE}" == "router" || "${ROLE_VALUE}" == "child" ]]; then + pass "Thread network is active" + elif [[ "${ROLE_VALUE}" == "disabled" ]]; then + pass "Thread state is 'disabled' — Barton will form the network" + elif [[ "${ROLE_VALUE}" == "detached" ]]; then + warn "Thread state is 'detached' — trying to join a network" + fi + else + warn "Could not read Thread DeviceRole property" + fi + else + fail "Cannot reach otbr-agent on D-Bus" + echo " ${DBUS_INTROSPECT}" + fi +elif ! command -v gdbus &>/dev/null; then + skip "gdbus not available — cannot test D-Bus connectivity" +else + skip "D-Bus socket missing — skipping connectivity test" +fi + +############################################################################### +# 4. BLE adapter configuration +############################################################################### +info "Checking BLE configuration..." + +if [[ -f "${BLE_ADAPTER_FILE}" ]]; then + BLE_ADAPTER_ID=$(cat "${BLE_ADAPTER_FILE}" | tr -d '[:space:]') + pass "ble_adapter_id file exists: ${BLE_ADAPTER_FILE}" + info " BLE adapter index: ${BLE_ADAPTER_ID}" + + # Check if the HCI device exists in the host network namespace + if [[ -e "${HOST_NETNS}" ]]; then + HCI_DEVICES=$(sudo nsenter --net="${HOST_NETNS}" hciconfig 2>/dev/null || true) + + if echo "${HCI_DEVICES}" | grep -q "hci${BLE_ADAPTER_ID}"; then + pass "hci${BLE_ADAPTER_ID} is present in host network namespace" + + HCI_STATUS=$(echo "${HCI_DEVICES}" | grep -A2 "hci${BLE_ADAPTER_ID}:" || true) + + if echo "${HCI_STATUS}" | grep -q "UP RUNNING"; then + pass "hci${BLE_ADAPTER_ID} is UP and RUNNING" + else + fail "hci${BLE_ADAPTER_ID} is not UP RUNNING" + echo " ${HCI_STATUS}" + fi + + # Show the BD address for reference + BD_ADDR=$(echo "${HCI_STATUS}" | grep -oP 'BD Address: \K[0-9A-F:]+' || true) + + if [[ -n "${BD_ADDR}" ]]; then + info " BD Address: ${BD_ADDR}" + fi + else + fail "hci${BLE_ADAPTER_ID} is NOT present in host network namespace" + echo " btattach may have failed or the BLE bridge is down." + echo " Available HCI devices:" + echo "${HCI_DEVICES}" | grep "^hci" | sed 's/^/ /' + fi + + # Count total HCI devices + HCI_COUNT=$(echo "${HCI_DEVICES}" | grep -c "^hci" || true) + + if [[ ${HCI_COUNT} -ge 2 ]]; then + pass "Multiple HCI devices present (${HCI_COUNT} total)" + elif [[ ${HCI_COUNT} -eq 1 ]]; then + warn "Only 1 HCI device present — the radio's BLE adapter may not be attached" + else + fail "No HCI devices found" + fi + else + skip "Host network namespace not mounted — cannot verify HCI devices" + fi +else + fail "ble_adapter_id file not found: ${BLE_ADAPTER_FILE}" + echo " The otbr-radio entrypoint may not have completed BLE setup." +fi + +############################################################################### +# 5. BLE adapter reachable via bluetoothctl (host netns) +############################################################################### +info "Checking BLE controller via bluetoothctl..." + +if [[ -e "${HOST_NETNS}" ]] && command -v bluetoothctl &>/dev/null; then + BT_LIST=$(sudo nsenter --net="${HOST_NETNS}" bluetoothctl list 2>/dev/null || true) + + if [[ -n "${BT_LIST}" ]]; then + CONTROLLER_COUNT=$(echo "${BT_LIST}" | wc -l) + pass "bluetoothctl sees ${CONTROLLER_COUNT} controller(s)" + echo "${BT_LIST}" | sed 's/^/ /' + else + warn "bluetoothctl returned no controllers" + fi +else + skip "Cannot run bluetoothctl in host network namespace" +fi + +############################################################################### +# 6. DBUS_SYSTEM_BUS_ADDRESS environment variable +############################################################################### +info "Checking DBUS_SYSTEM_BUS_ADDRESS..." + +if [[ -n "${DBUS_SYSTEM_BUS_ADDRESS:-}" ]]; then + pass "DBUS_SYSTEM_BUS_ADDRESS is set: ${DBUS_SYSTEM_BUS_ADDRESS}" + + if echo "${DBUS_SYSTEM_BUS_ADDRESS}" | grep -q "otbr-dbus"; then + pass "Points to the private otbr-dbus socket" + else + warn "Does not point to the otbr-dbus socket — Barton may use the wrong D-Bus" + fi +else + fail "DBUS_SYSTEM_BUS_ADDRESS is not set" + echo " Barton will use the system D-Bus instead of the otbr-radio private bus." + echo " Export it: export DBUS_SYSTEM_BUS_ADDRESS=unix:path=${DBUS_SOCKET}" +fi + +############################################################################### +# Summary +############################################################################### +echo "" +echo -e "${BOLD}═══════════════════════════════════════${NC}" +echo -e "${BOLD} Results${NC}" +echo -e "${BOLD}═══════════════════════════════════════${NC}" +echo -e " ${GREEN}Passed: ${PASS_COUNT}${NC}" +echo -e " ${YELLOW}Warnings: ${WARN_COUNT}${NC}" +echo -e " ${RED}Failed: ${FAIL_COUNT}${NC}" +echo "" + +if [[ ${FAIL_COUNT} -eq 0 ]]; then + echo -e "${GREEN}${BOLD}=======================================${NC}" + echo -e "${GREEN}${BOLD} ALL CHECKS PASSED${NC}" + echo -e "${GREEN}${BOLD}=======================================${NC}" + echo "" + + if [[ ${WARN_COUNT} -gt 0 ]]; then + echo -e "${YELLOW}Some warnings were reported — review above for details.${NC}" + fi + + exit 0 +else + echo -e "${RED}${BOLD}=======================================${NC}" + echo -e "${RED}${BOLD} VALIDATION FAILED (${FAIL_COUNT} issue(s))${NC}" + echo -e "${RED}${BOLD}=======================================${NC}" + echo "" + echo "Review the failures above and check docs/REMOTE_RADIO_FOR_DEVELOPMENT.md" + echo "for troubleshooting guidance." + exit 1 +fi From cde9e199d0eed2e70d6795bdd037dcba26ee891c Mon Sep 17 00:00:00 2001 From: mvelam850 Date: Tue, 19 May 2026 11:05:58 -0500 Subject: [PATCH 07/17] fix private D-Bus connectivity and bluetoothd host system bus registration --- docker/Dockerfile.otbr-radio | 5 +++-- docker/otbr-agent.conf | 11 +++++++---- docker/otbr-radio-entrypoint.sh | 6 +++--- scripts/remote-radio/usbip-validate.sh | 4 +++- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docker/Dockerfile.otbr-radio b/docker/Dockerfile.otbr-radio index 29a5f4ca..e75c37f9 100644 --- a/docker/Dockerfile.otbr-radio +++ b/docker/Dockerfile.otbr-radio @@ -192,8 +192,9 @@ RUN cd /tmp/gecko_sdk/app/bluetooth/example_host/bt_host_cpc_hci_bridge && \ cp exe/bt_host_cpc_hci_bridge /usr/local/bin/ && \ rm -rf /tmp/gecko_sdk -# D-Bus policy allowing otbr-agent to own its well-known bus name -COPY otbr-agent.conf /etc/dbus-1/system.d/ +# Standalone D-Bus config for the private daemon (used with --config-file, +# not --system, to bypass Ubuntu's mandatory deny policy in system.conf). +COPY otbr-agent.conf /etc/otbr-dbus.conf # Clean up patches RUN rm -rf /tmp/patches diff --git a/docker/otbr-agent.conf b/docker/otbr-agent.conf index 1bcb17b4..43d572bc 100644 --- a/docker/otbr-agent.conf +++ b/docker/otbr-agent.conf @@ -1,13 +1,16 @@ + unix:path=/var/run/otbr-dbus/system_bus_socket + ANONYMOUS + - - - + + - + + diff --git a/docker/otbr-radio-entrypoint.sh b/docker/otbr-radio-entrypoint.sh index e481ecec..8503a77a 100755 --- a/docker/otbr-radio-entrypoint.sh +++ b/docker/otbr-radio-entrypoint.sh @@ -89,7 +89,7 @@ if [ -S "${DBUS_SOCKET_PATH}" ]; then echo "[otbr-radio] Removing stale D-Bus socket ${DBUS_SOCKET_PATH}..." rm -f "${DBUS_SOCKET_PATH}" fi -dbus-daemon --system --fork --nopidfile --address="${DBUS_SYSTEM_BUS_ADDRESS}" +dbus-daemon --config-file=/etc/otbr-dbus.conf --fork --nopidfile echo "[otbr-radio] Private D-Bus started." ############################################################################### @@ -261,8 +261,8 @@ ble_attach() { # Kill any stale bluetoothd before starting a fresh instance. pkill -x bluetoothd 2>/dev/null || true sleep 0.5 - echo "[otbr-radio] Starting bluetoothd (host netns, private D-Bus)..." - nsenter --net="${HOST_NETNS}" bluetoothd & + echo "[otbr-radio] Starting bluetoothd (host netns, host system D-Bus)..." + nsenter --net="${HOST_NETNS}" env -u DBUS_SYSTEM_BUS_ADDRESS bluetoothd & sleep 1 echo "[otbr-radio] bluetoothd started." diff --git a/scripts/remote-radio/usbip-validate.sh b/scripts/remote-radio/usbip-validate.sh index 5dfcb029..f7fe1c28 100755 --- a/scripts/remote-radio/usbip-validate.sh +++ b/scripts/remote-radio/usbip-validate.sh @@ -273,7 +273,9 @@ fi info "Checking BLE controller via bluetoothctl..." if [[ -e "${HOST_NETNS}" ]] && command -v bluetoothctl &>/dev/null; then - BT_LIST=$(sudo nsenter --net="${HOST_NETNS}" bluetoothctl list 2>/dev/null || true) + # bluetoothctl uses D-Bus; unset the private otbr-dbus address so it reaches + # the host system bus where bluetoothd registered itself. + BT_LIST=$(sudo nsenter --net="${HOST_NETNS}" env -u DBUS_SYSTEM_BUS_ADDRESS bluetoothctl list 2>/dev/null || true) if [[ -n "${BT_LIST}" ]]; then CONTROLLER_COUNT=$(echo "${BT_LIST}" | wc -l) From 5fceafff320a5e5cdfa922d397d0b4bc07018a0a Mon Sep 17 00:00:00 2001 From: Thomas Lea Date: Wed, 20 May 2026 19:52:21 +0000 Subject: [PATCH 08/17] fix(docker): remove hcitool usage to prevent BLE MGMT socket poisoning hcitool uses the legacy HCI socket interface which permanently conflicts with bluetoothd's MGMT socket. Starting an LE scan via hcitool and killing it leaves the kernel MGMT layer stuck, causing all subsequent StartDiscovery calls to fail with InProgress. Changes: - Rewrite ble_verify_scan() to use hciconfig instead of hcitool lescan - Make ble_health_check() passive (read D-Bus properties only, no StartDiscovery/StopDiscovery) to avoid conflicts with Matter SDK scans - Remove hciconfig down and hcitool cmd HCI_Reset from init sequence; simplify to hciconfig up with down/up recovery cycle - Update usbip-validate.sh checks for revised BLE stack behavior --- docker/otbr-radio-entrypoint.sh | 236 +++++++++++++++++++++++-- scripts/remote-radio/usbip-validate.sh | 17 +- 2 files changed, 234 insertions(+), 19 deletions(-) diff --git a/docker/otbr-radio-entrypoint.sh b/docker/otbr-radio-entrypoint.sh index 8503a77a..89edac8c 100755 --- a/docker/otbr-radio-entrypoint.sh +++ b/docker/otbr-radio-entrypoint.sh @@ -258,13 +258,52 @@ ble_attach() { echo "${radioHciIndex}" > "${DBUS_DIR}/ble_adapter_id" echo "[otbr-radio] Wrote BLE adapter index ${radioHciIndex} to ${DBUS_DIR}/ble_adapter_id" + # Allow the kernel's initial HCI probe to complete before we send + # any commands. The kernel sends HCI_Reset, Read_Local_Features, + # etc. immediately after btattach registers the device. Sending + # our own commands too early races with those and can leave the + # Silicon Labs CPC-BLE bridge in a broken state where LE scanning + # returns "Command Disallowed" or I/O errors. + echo "[otbr-radio] Waiting for kernel HCI initialization to settle..." + sleep 3 + + # Bring the device up cleanly. Do NOT use hcitool here — it uses + # the legacy HCI socket which permanently poisons the MGMT layer. + echo "[otbr-radio] Bringing ${radioHci} up..." + nsenter --net="${HOST_NETNS}" hciconfig "${radioHci}" up 2>/dev/null || true + sleep 1 + + # Verify the adapter is accessible. + if ! ble_verify_scan "${radioHci}"; then + echo "[otbr-radio] WARNING: HCI verification failed after initial up." >&2 + echo "[otbr-radio] Attempting down/up recovery cycle..." >&2 + nsenter --net="${HOST_NETNS}" hciconfig "${radioHci}" down 2>/dev/null || true + sleep 2 + nsenter --net="${HOST_NETNS}" hciconfig "${radioHci}" up 2>/dev/null || true + sleep 2 + + if ! ble_verify_scan "${radioHci}"; then + echo "[otbr-radio] ERROR: HCI still not accessible after recovery." >&2 + return 1 + fi + fi + + echo "[otbr-radio] LE scan verification passed for ${radioHci}." + # Kill any stale bluetoothd before starting a fresh instance. pkill -x bluetoothd 2>/dev/null || true sleep 0.5 - echo "[otbr-radio] Starting bluetoothd (host netns, host system D-Bus)..." - nsenter --net="${HOST_NETNS}" env -u DBUS_SYSTEM_BUS_ADDRESS bluetoothd & + echo "[otbr-radio] Starting bluetoothd (host netns, private D-Bus)..." + nsenter --net="${HOST_NETNS}" bluetoothd & + BLUETOOTHD_PID=$! sleep 1 - echo "[otbr-radio] bluetoothd started." + + if ! kill -0 ${BLUETOOTHD_PID} 2>/dev/null; then + echo "[otbr-radio] ERROR: bluetoothd (PID ${BLUETOOTHD_PID}) exited immediately." >&2 + return 1 + fi + + echo "[otbr-radio] bluetoothd started (PID ${BLUETOOTHD_PID})." return 0 fi @@ -279,12 +318,99 @@ ble_attach() { return 1 } -# ble_monitor — background loop that restarts btattach (and bluetoothd) -# whenever btattach exits. This handles USB-IP disconnect/reconnect -# cycles where cpcd and bt_host_cpc_hci_bridge survive but btattach -# loses the HCI device and exits. +# ble_verify_scan — test that the HCI adapter exists and is responsive. +# Returns 0 if the adapter is detected by hciconfig, 1 if it fails. +# Uses a hard 4-second timeout to avoid blocking if the CPC-BLE bridge is +# completely unresponsive. +# IMPORTANT: Do NOT use hcitool lescan here. hcitool uses the legacy HCI socket +# interface which permanently conflicts with bluetoothd's MGMT socket — starting +# an LE scan via hcitool and killing it leaves the MGMT layer stuck, causing all +# subsequent StartDiscovery calls to fail with "InProgress". +ble_verify_scan() { + local hciDev="$1" + local output + output=$(timeout 4 nsenter --net="${HOST_NETNS}" hciconfig "${hciDev}" 2>&1) || true + + if echo "${output}" | grep -qi "no such device"; then + echo "[otbr-radio] ble_verify_scan: device not found — ${output}" >&2 + return 1 + fi + + # As long as hciconfig recognizes the device, the btattach→HCI chain + # is functional. bluetoothd will power it on via MGMT when it starts. + if ! echo "${output}" | grep -qi "Bus:"; then + echo "[otbr-radio] ble_verify_scan: unexpected output — ${output}" >&2 + return 1 + fi + + return 0 +} + +# ble_health_check — Passive D-Bus-based health check suitable for use while +# bluetoothd is running. Verifies the adapter is reachable and powered via +# D-Bus property reads. Does NOT start/stop discovery to avoid conflicting +# with Matter SDK BLE scans. Returns 0 if the adapter is powered, 1 on failure. +ble_health_check() { + local adapterPath="/org/bluez/${1}" + + # Verify bluetoothd is reachable and adapter is powered. + local powered + powered=$(timeout 5 dbus-send --system --dest=org.bluez \ + --print-reply "${adapterPath}" \ + org.freedesktop.DBus.Properties.Get \ + string:org.bluez.Adapter1 string:Powered 2>&1) || true + + if echo "${powered}" | grep -qi "error\|no reply\|not found"; then + echo "[otbr-radio] ble_health_check: adapter not reachable on D-Bus" >&2 + return 1 + fi + + if ! echo "${powered}" | grep -q "boolean true"; then + echo "[otbr-radio] ble_health_check: adapter not powered" >&2 + return 1 + fi + + # Verify adapter address is resolvable (validates HCI transport). + local address + address=$(timeout 5 dbus-send --system --dest=org.bluez \ + --print-reply "${adapterPath}" \ + org.freedesktop.DBus.Properties.Get \ + string:org.bluez.Adapter1 string:Address 2>&1) || true + + if echo "${address}" | grep -qi "error\|no reply\|not found"; then + echo "[otbr-radio] ble_health_check: cannot read adapter address" >&2 + return 1 + fi + + return 0 +} + +# ble_monitor — background loop that monitors BLE health and restarts the +# stack when needed. Handles two failure modes: +# 1. btattach exits (e.g. USB-IP disconnect/reconnect cycle) +# 2. btattach alive but CPC-BLE bridge in broken state (scan fails) +# +# The health check runs every BLE_HEALTH_INTERVAL seconds and verifies that +# LE scanning works. If scanning fails, it kills the BLE chain and restarts +# it from the CPC bridge level. +# +# A startup grace period (BLE_HEALTH_GRACE) allows Thread traffic to settle +# after otbr-agent forms/joins the network before the first health check. +# During initial Thread establishment, CPC bandwidth is saturated and BLE +# commands may time out — this is transient, not a real failure. +BLE_HEALTH_INTERVAL=60 +BLE_HEALTH_GRACE=90 + ble_monitor() { local backoff=2 + local healthCounter=0 + local radioHci="" + local graceRemaining=${BLE_HEALTH_GRACE} + + # Resolve the radio HCI device name from the adapter index file. + if [ -f "${DBUS_DIR}/ble_adapter_id" ]; then + radioHci="hci$(cat "${DBUS_DIR}/ble_adapter_id" | tr -d '[:space:]')" + fi while true; do @@ -293,9 +419,19 @@ ble_monitor() { # If bt_host_cpc_hci_bridge also died, wait for it to come back. if ! kill -0 ${BT_BRIDGE_PID} 2>/dev/null; then - echo "[otbr-radio] bt_host_cpc_hci_bridge also exited; waiting for it to restart..." >&2 - sleep "${backoff}" - continue + echo "[otbr-radio] bt_host_cpc_hci_bridge also exited; restarting bridge..." >&2 + ble_restart_bridge + + if ! kill -0 ${BT_BRIDGE_PID} 2>/dev/null; then + echo "[otbr-radio] Bridge restart failed; retrying in ${backoff}s..." >&2 + sleep "${backoff}" + + if [ ${backoff} -lt 30 ]; then + backoff=$((backoff * 2)) + fi + + continue + fi fi # pts_hci must still be valid. @@ -308,6 +444,12 @@ ble_monitor() { if ble_attach; then echo "[otbr-radio] BLE stack restarted successfully." backoff=2 + healthCounter=0 + + # Update the HCI device name after restart (index may change). + if [ -f "${DBUS_DIR}/ble_adapter_id" ]; then + radioHci="hci$(cat "${DBUS_DIR}/ble_adapter_id" | tr -d '[:space:]')" + fi else echo "[otbr-radio] BLE stack restart failed; retrying in ${backoff}s..." >&2 @@ -315,10 +457,82 @@ ble_monitor() { backoff=$((backoff * 2)) fi fi + else + # btattach is alive — periodically verify scanning still works. + # Skip health checks during the startup grace period. + if [ ${graceRemaining} -gt 0 ]; then + graceRemaining=$((graceRemaining - 1)) + else + healthCounter=$((healthCounter + 1)) + + if [ ${healthCounter} -ge ${BLE_HEALTH_INTERVAL} ] && [ -n "${radioHci}" ]; then + healthCounter=0 + + if ! ble_health_check "${radioHci}"; then + echo "[otbr-radio] BLE health check FAILED — scan broken. Restarting full BLE chain..." >&2 + # Kill the entire BLE chain. + kill ${BTATTACH_PID} 2>/dev/null || true + pkill -x bluetoothd 2>/dev/null || true + kill ${BT_BRIDGE_PID} 2>/dev/null || true + sleep 5 + + # Restart the bridge with retries. + ble_restart_bridge + sleep 1 + continue + fi + fi + fi + fi + + sleep 1 + done +} + +# ble_restart_bridge — restart bt_host_cpc_hci_bridge with retries. +# cpcd may take 5-20s to release the BLE endpoint after the old bridge exits. +# Retry up to 4 times with increasing pre-delays (5s, 10s, 15s, 20s). +ble_restart_bridge() { + local attempt=0 + local maxAttempts=4 + + while [ ${attempt} -lt ${maxAttempts} ]; do + attempt=$((attempt + 1)) + local delay=$((attempt * 5)) + echo "[otbr-radio] Restarting bt_host_cpc_hci_bridge (attempt ${attempt}/${maxAttempts}, pre-delay ${delay}s)..." + + # Wait for cpcd to release the BLE endpoint. First attempt gets a + # short delay; subsequent attempts wait longer. + sleep ${delay} + rm -f "${BT_BRIDGE_DIR}/pts_hci" + cd "${BT_BRIDGE_DIR}" + bt_host_cpc_hci_bridge & + BT_BRIDGE_PID=$! + + # Wait for pts_hci symlink. + local bridgeWait=0 + + while [ ! -L "${BT_BRIDGE_DIR}/pts_hci" ] && [ ${bridgeWait} -lt 10 ]; do + + if ! kill -0 ${BT_BRIDGE_PID} 2>/dev/null; then + break + fi + + sleep 1 + bridgeWait=$((bridgeWait + 1)) + done + + if [ -L "${BT_BRIDGE_DIR}/pts_hci" ]; then + echo "[otbr-radio] bt_host_cpc_hci_bridge ready (PID ${BT_BRIDGE_PID})." + return 0 fi - sleep "${backoff}" + echo "[otbr-radio] bt_host_cpc_hci_bridge attempt ${attempt} failed." >&2 + kill ${BT_BRIDGE_PID} 2>/dev/null || true done + + echo "[otbr-radio] ERROR: bt_host_cpc_hci_bridge failed after ${maxAttempts} attempts." >&2 + return 1 } if [ ! -e "${HOST_NETNS}" ]; then diff --git a/scripts/remote-radio/usbip-validate.sh b/scripts/remote-radio/usbip-validate.sh index f7fe1c28..a7f3d478 100755 --- a/scripts/remote-radio/usbip-validate.sh +++ b/scripts/remote-radio/usbip-validate.sh @@ -94,8 +94,8 @@ fi info "Checking otbr-radio container..." OTBR_CONTAINER_ID="" -EXPECTED_PROCS=("cpcd" "otbr-agent") -OPTIONAL_PROCS=("bt_host_cpc_hci_bridge" "btattach" "bluetoothd") +EXPECTED_PROCS=("cpcd" "otbr-agent" "bt_host_cpc_hci_bridge" "btattach" "bluetoothd") +OPTIONAL_PROCS=() if [[ -S "${DOCKER_SOCK}" ]]; then # Find the otbr-radio container @@ -170,7 +170,7 @@ fi info "Checking D-Bus connectivity to otbr-agent..." if [[ -S "${DBUS_SOCKET}" ]] && command -v gdbus &>/dev/null; then - DBUS_INTROSPECT=$(gdbus introspect \ + DBUS_INTROSPECT=$(timeout 10 gdbus introspect \ --address "unix:path=${DBUS_SOCKET}" \ --dest io.openthread.BorderRouter.wpan0 \ --object-path /io/openthread/BorderRouter/wpan0 2>&1 || true) @@ -179,7 +179,7 @@ if [[ -S "${DBUS_SOCKET}" ]] && command -v gdbus &>/dev/null; then pass "otbr-agent D-Bus interface is reachable" # Try to read Thread device role - DEVICE_ROLE=$(gdbus call \ + DEVICE_ROLE=$(timeout 10 gdbus call \ --address "unix:path=${DBUS_SOCKET}" \ --dest io.openthread.BorderRouter.wpan0 \ --object-path /io/openthread/BorderRouter/wpan0 \ @@ -273,16 +273,17 @@ fi info "Checking BLE controller via bluetoothctl..." if [[ -e "${HOST_NETNS}" ]] && command -v bluetoothctl &>/dev/null; then - # bluetoothctl uses D-Bus; unset the private otbr-dbus address so it reaches - # the host system bus where bluetoothd registered itself. - BT_LIST=$(sudo nsenter --net="${HOST_NETNS}" env -u DBUS_SYSTEM_BUS_ADDRESS bluetoothctl list 2>/dev/null || true) + # bluetoothctl uses D-Bus; bluetoothd registers on our private D-Bus so + # we use the same DBUS_SYSTEM_BUS_ADDRESS that the container inherits. + # Use timeout because bluetoothctl blocks if bluetoothd is not running. + BT_LIST=$(timeout 5 sudo nsenter --net="${HOST_NETNS}" bluetoothctl list 2>/dev/null || true) if [[ -n "${BT_LIST}" ]]; then CONTROLLER_COUNT=$(echo "${BT_LIST}" | wc -l) pass "bluetoothctl sees ${CONTROLLER_COUNT} controller(s)" echo "${BT_LIST}" | sed 's/^/ /' else - warn "bluetoothctl returned no controllers" + fail "bluetoothctl returned no controllers" fi else skip "Cannot run bluetoothctl in host network namespace" From 03b88c1139235cb1459655be5636e507115f7b6e Mon Sep 17 00:00:00 2001 From: Thomas Lea Date: Tue, 26 May 2026 23:30:43 +0000 Subject: [PATCH 09/17] feat(docker): add remote CPC support and fix BLE via CPC firmware bugs Add infrastructure for running the otbr-radio container against a remote cpcd instance over TCP, and work around two Silicon Labs CPC BLE firmware bugs that prevent BLE scanning and connections from working through the CPC radio. Remote CPC mode =============== When CPC_REMOTE_HOST is set, the entrypoint starts CPC socket proxy clients instead of running cpcd locally. The proxy tunnels CPC SEQPACKET Unix sockets over TCP connections to a remote host running cpc_remote_access.sh in server mode. Six proxy instances are started: ctrl, reset, and data+event for both the Bluetooth RCP (endpoint 14) and Thread/Spinel (endpoint 12) endpoints. New files: - scripts/remote-radio/cpc_socket_proxy.py: Bidirectional SEQPACKET-over-TCP proxy with self-healing reconnect, TCP keepalive for fast stale-session detection, and automatic session eviction in server mode (CPC endpoints allow only one client). - scripts/remote-radio/cpc_remote_access.sh: Server-side watcher that starts and monitors proxy server instances on the remote host where cpcd runs. CPC BLE firmware workarounds (hci_pty_proxy.py) =============================================== The Silicon Labs BRD2703 xG24 CPC BLE firmware has two HCI compatibility bugs that prevent BLE from working with the Linux kernel (tested on 6.8.0 / BlueZ 5.72): Bug 1: Extended Scan Disable wrong opcode The firmware advertises LE Extended Advertising support in its feature set, but when the kernel sends LE Set Extended Scan Enable (opcode 0x2042) to disable scanning, the firmware responds with the wrong opcode (legacy 0x200c instead of 0x2042). The kernel discards this unexpected response and the 0x2042 command times out. Because scanning cannot be stopped, all subsequent LE Create Connection commands are rejected with "Command Disallowed" (HCI status 0x0c). Fix: The proxy intercepts the LE Read Local Supported Features response during controller init and clears the Extended Advertising feature bit (byte 1, bit 4 = 0x10). This forces the kernel to use legacy BLE scan commands (0x200b/0x200c) which the firmware handles correctly. Bug 2: Enhanced Connection Complete v2 event After clearing Extended Advertising support, the kernel only registers a handler for the legacy LE Connection Complete event (subevent 0x01). However, the firmware sends LE Enhanced Connection Complete v2 (subevent 0x29, a BT 5.2+ event) when a connection is established. The kernel silently discards this unknown subevent, the connection times out (HCI status 0x08), and the firmware is left with a phantom connection that generates periodic "ACL packet for unknown connection handle" errors in dmesg. Fix: The proxy translates Enhanced Connection Complete events (subevent 0x29 for v2, 0x0A for v1) into the legacy format (subevent 0x01) by stripping the Resolvable Private Address fields (12 bytes), Advertising_Handle (1 byte), and Sync_Handle (2 bytes), and rewriting the parameter length accordingly. New file: - scripts/remote-radio/hci_pty_proxy.py: HCI UART (H4) PTY proxy that sits between bt_host_cpc_hci_bridge and btattach. Supports optional debug logging via HCI_PROXY_DEBUG env var for troubleshooting HCI event flow. Entrypoint changes (otbr-radio-entrypoint.sh) ============================================= - Add remote CPC mode: when CPC_REMOTE_HOST is set, start CPC socket proxy clients and skip local cpcd startup. - Integrate HCI PTY proxy between bt_host_cpc_hci_bridge and btattach (section 5b). Falls back gracefully if the proxy script is not mounted. - Remove stale pts_hci symlink before starting bt_host_cpc_hci_bridge to prevent the PTY wait loop from seeing a stale file from a previous run. - Simplify BLE init sequence: replace ble_verify_scan() and ble_health_check() with a deterministic down/up/HCI-Reset cycle followed by bluetoothd start. The previous approach used hcitool lescan for verification which poisoned the MGMT layer. - Simplify ble_monitor(): remove D-Bus health checks and bridge restart logic; just watch btattach and restart the BLE chain when it exits. Compose changes (compose.otbr-radio.yaml) ========================================== - Bind-mount cpc_socket_proxy.py, hci_pty_proxy.py, and the entrypoint script into the container. - Add CPC_REMOTE_HOST, CPC_REMOTE_PORT, CPC_INSTANCE env vars. Other changes ============= - CommissioningOrchestrator.cpp: Close BLE connection after ThreadNetworkEnable completes to free the CPC radio for Thread traffic. BLE and Thread share the single CPC radio and cannot operate simultaneously. - devcontainer.json: Set DEBUGINFOD_URLS="" to prevent GDB from hanging on symbol downloads from the Ubuntu debuginfod server. --- .devcontainer/devcontainer.json | 6 +- .../matter/CommissioningOrchestrator.cpp | 6 + docker/compose.otbr-radio.yaml | 14 + docker/otbr-radio-entrypoint.sh | 484 ++++++++---------- scripts/remote-radio/cpc_remote_access.sh | 209 ++++++++ scripts/remote-radio/cpc_socket_proxy.py | 303 +++++++++++ scripts/remote-radio/hci_pty_proxy.py | 386 ++++++++++++++ 7 files changed, 1136 insertions(+), 272 deletions(-) create mode 100755 scripts/remote-radio/cpc_remote_access.sh create mode 100644 scripts/remote-radio/cpc_socket_proxy.py create mode 100644 scripts/remote-radio/hci_pty_proxy.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 02c79c9a..aefc6227 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -52,7 +52,11 @@ // when loaded via GObject introspection in Python. Without this, VS Code's pytest // discovery crashes because ASAN kills the process. This is harmless for non-ASAN // processes — they simply ignore the option. - "ASAN_OPTIONS": "verify_asan_link_order=0" + "ASAN_OPTIONS": "verify_asan_link_order=0", + + // Disable debuginfod URL lookups — GDB hangs trying to download symbols from + // the Ubuntu debuginfod server when this is set. + "DEBUGINFOD_URLS": "" }, // specify the non-root user to use in the container diff --git a/core/src/subsystems/matter/CommissioningOrchestrator.cpp b/core/src/subsystems/matter/CommissioningOrchestrator.cpp index 0f82c196..63e586ad 100644 --- a/core/src/subsystems/matter/CommissioningOrchestrator.cpp +++ b/core/src/subsystems/matter/CommissioningOrchestrator.cpp @@ -370,6 +370,12 @@ namespace barton peerId.GetNodeId(), StageToString(stageCompleted), ErrorStr(error)); + + if (stageCompleted == chip::Controller::CommissioningStage::kThreadNetworkEnable && error == CHIP_NO_ERROR) + { + icInfo("Closing BLE connection after ThreadNetworkEnable to free CPC radio for Thread"); + Matter::GetInstance().GetCommissioner()->CloseBleConnection(); + } } void CommissioningOrchestrator::OnReadCommissioningInfo(const chip::Controller::ReadCommissioningInfo &info) diff --git a/docker/compose.otbr-radio.yaml b/docker/compose.otbr-radio.yaml index fbf0bfd7..4463612f 100644 --- a/docker/compose.otbr-radio.yaml +++ b/docker/compose.otbr-radio.yaml @@ -105,15 +105,29 @@ services: # AF_BLUETOOTH sockets are only available in the initial (host) # network namespace. - /proc/1/ns/net:/run/host-netns:ro + # Mount the CPC socket proxy script for remote CPC mode. + - ../scripts/remote-radio/cpc_socket_proxy.py:/opt/cpc-proxy/cpc_socket_proxy.py:ro + # Mount the HCI PTY proxy that strips Extended Advertising from + # the LE features response, working around a CPC firmware bug. + - ../scripts/remote-radio/hci_pty_proxy.py:/opt/cpc-proxy/hci_pty_proxy.py:ro + # Bind-mount the entrypoint for development iteration without + # rebuilding the Docker image. + - ./otbr-radio-entrypoint.sh:/entrypoint.sh:ro environment: # RADIO_DEVICE must be set explicitly before starting this overlay. # On a shared build server each user's radio may attach at a # different path (e.g. /dev/ttyACM8). The entrypoint validates the # path at runtime and exits with a clear error if it is unset or # the device does not exist. + # Not required when CPC_REMOTE_HOST is set. - RADIO_DEVICE=${RADIO_DEVICE:-} - BACKBONE_IF=${BACKBONE_IF:-} - DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/otbr-dbus/system_bus_socket + # Remote CPC mode: set CPC_REMOTE_HOST to connect to a remote cpcd + # via the CPC socket proxy instead of running cpcd locally. + - CPC_REMOTE_HOST=${CPC_REMOTE_HOST:-} + - CPC_REMOTE_PORT=${CPC_REMOTE_PORT:-15000} + - CPC_INSTANCE=${CPC_INSTANCE:-cpcd_0} # Restart automatically if cpcd/otbr-agent crash, but limit retries so # a missing/invalid RADIO_DEVICE does not cause an infinite restart # loop and log spam when this overlay is included without hardware. diff --git a/docker/otbr-radio-entrypoint.sh b/docker/otbr-radio-entrypoint.sh index 89edac8c..f66fc758 100755 --- a/docker/otbr-radio-entrypoint.sh +++ b/docker/otbr-radio-entrypoint.sh @@ -27,11 +27,20 @@ # 1. D-Bus system bus # 2. avahi-daemon (mDNS/DNS-SD — required by otbr-agent built with OTBR_MDNS=avahi) # 3. cpcd (CPC daemon — USB serial ↔ CPC socket) +# OR CPC socket proxy client (when CPC_REMOTE_HOST is set) # 4. otbr-agent (Thread Border Router — CPC socket ↔ D-Bus API) # # Environment variables: # RADIO_DEVICE - host path of the USB serial device. The compose # overlay requires this to be set explicitly. +# Not required when CPC_REMOTE_HOST is set. +# CPC_REMOTE_HOST - when set, connect to a remote cpcd via the CPC +# socket proxy instead of running cpcd locally. +# The remote host must be running +# cpc_remote_access.sh in server mode. +# CPC_REMOTE_PORT - base port for the CPC socket proxy +# (default: 15000) +# CPC_INSTANCE - CPC daemon instance name (default: cpcd_0) # BACKBONE_IF - network interface to use as the Thread backbone, # e.g. eth0 # CPCD_CONF - path to cpcd config file @@ -46,8 +55,11 @@ set -e # RADIO_DEVICE is intentionally not defaulted here. # If it is unset or empty the validation section below will exit with a -# clear error message. +# clear error message (unless CPC_REMOTE_HOST is set). RADIO_DEVICE="${RADIO_DEVICE}" +CPC_REMOTE_HOST="${CPC_REMOTE_HOST:-}" +CPC_REMOTE_PORT="${CPC_REMOTE_PORT:-15000}" +CPC_INSTANCE="${CPC_INSTANCE:-cpcd_0}" CPCD_CONF="${CPCD_CONF:-/usr/local/etc/cpcd.conf}" DBUS_DIR="${DBUS_DIR:-/var/run/otbr-dbus}" DBUS_SOCKET_PATH="${DBUS_SOCKET_PATH:-${DBUS_DIR}/system_bus_socket}" @@ -93,22 +105,104 @@ dbus-daemon --config-file=/etc/otbr-dbus.conf --fork --nopidfile echo "[otbr-radio] Private D-Bus started." ############################################################################### -# 2. Validate the USB radio device +# 2. Validate radio access and start CPC +# +# Two modes are supported: +# a) Local radio (default): RADIO_DEVICE must point to a USB serial device. +# cpcd is started locally to manage the CPC link. +# b) Remote CPC: CPC_REMOTE_HOST is set. A CPC socket proxy client connects +# to a remote cpcd over TCP, creating local Unix sockets that libcpc +# applications (bt_host_cpc_hci_bridge, otbr-agent) use transparently. ############################################################################### -if [ -z "${RADIO_DEVICE}" ]; then - echo "[otbr-radio] ERROR: RADIO_DEVICE is not set." >&2 - echo "[otbr-radio] Set it in docker/.env or export before starting:" >&2 - echo "[otbr-radio] export RADIO_DEVICE=/dev/ttyACM0" >&2 - echo "[otbr-radio] See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for setup instructions." >&2 - exit 1 -fi -if [ ! -e "${RADIO_DEVICE}" ]; then - echo "[otbr-radio] ERROR: USB radio device '${RADIO_DEVICE}' not found." >&2 - echo "[otbr-radio] Check that the thread radio is connected and USB-IP is attached." >&2 - echo "[otbr-radio] See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for setup instructions." >&2 - exit 1 +CPC_SOCKET_DIR="${CPC_SOCKET_DIR:-/dev/shm}" + +if [ -n "${CPC_REMOTE_HOST}" ]; then + #-------------------------------------------------------------------------- + # Remote CPC mode — connect to cpcd running on a remote host via the + # CPC socket proxy. + #-------------------------------------------------------------------------- + echo "[otbr-radio] Remote CPC mode: connecting to cpcd on ${CPC_REMOTE_HOST}:${CPC_REMOTE_PORT}" + + CPC_PROXY_SCRIPT="/opt/cpc-proxy/cpc_socket_proxy.py" + CPC_SOCKET_BASE="${CPC_SOCKET_DIR}/cpcd/${CPC_INSTANCE}" + mkdir -p "${CPC_SOCKET_BASE}" + + # Start proxy for the control socket. + echo "[otbr-radio] Starting CPC proxy: ctrl.cpcd.sock (port ${CPC_REMOTE_PORT})" + python3 "${CPC_PROXY_SCRIPT}" client \ + -s "${CPC_SOCKET_BASE}/ctrl.cpcd.sock" \ + -H "${CPC_REMOTE_HOST}" -p "${CPC_REMOTE_PORT}" & + + # Start proxy for the reset socket. + CPC_RESET_PORT=$((CPC_REMOTE_PORT + 1)) + echo "[otbr-radio] Starting CPC proxy: reset.cpcd.sock (port ${CPC_RESET_PORT})" + python3 "${CPC_PROXY_SCRIPT}" client \ + -s "${CPC_SOCKET_BASE}/reset.cpcd.sock" \ + -H "${CPC_REMOTE_HOST}" -p "${CPC_RESET_PORT}" & + + # Start proxies for the Bluetooth RCP endpoint. + # bt_host_cpc_hci_bridge opens SL_CPC_ENDPOINT_BLUETOOTH_RCP which is 14 + # in cpcd v4.4.6 (host library sl_cpc.h). + CPC_BT_EP=14 + CPC_BT_DATA_PORT=$((CPC_REMOTE_PORT + 100 + CPC_BT_EP)) + CPC_BT_EVENT_PORT=$((CPC_REMOTE_PORT + 400 + CPC_BT_EP)) + echo "[otbr-radio] Starting CPC proxy: ep${CPC_BT_EP}.cpcd.sock (port ${CPC_BT_DATA_PORT})" + python3 "${CPC_PROXY_SCRIPT}" client \ + -s "${CPC_SOCKET_BASE}/ep${CPC_BT_EP}.cpcd.sock" \ + -H "${CPC_REMOTE_HOST}" -p "${CPC_BT_DATA_PORT}" & + echo "[otbr-radio] Starting CPC proxy: ep${CPC_BT_EP}.event.cpcd.sock (port ${CPC_BT_EVENT_PORT})" + python3 "${CPC_PROXY_SCRIPT}" client \ + -s "${CPC_SOCKET_BASE}/ep${CPC_BT_EP}.event.cpcd.sock" \ + -H "${CPC_REMOTE_HOST}" -p "${CPC_BT_EVENT_PORT}" & + + # Start proxies for the OpenThread/Spinel endpoint. + # otbr-agent uses spinel+cpc://cpcd_0 which maps to + # SL_CPC_ENDPOINT_15_4 (12) in cpcd v4.4.6 for multi-PAN RCP firmware. + CPC_SPINEL_EP=12 + CPC_SPINEL_DATA_PORT=$((CPC_REMOTE_PORT + 100 + CPC_SPINEL_EP)) + CPC_SPINEL_EVENT_PORT=$((CPC_REMOTE_PORT + 400 + CPC_SPINEL_EP)) + echo "[otbr-radio] Starting CPC proxy: ep${CPC_SPINEL_EP}.cpcd.sock (port ${CPC_SPINEL_DATA_PORT})" + python3 "${CPC_PROXY_SCRIPT}" client \ + -s "${CPC_SOCKET_BASE}/ep${CPC_SPINEL_EP}.cpcd.sock" \ + -H "${CPC_REMOTE_HOST}" -p "${CPC_SPINEL_DATA_PORT}" & + echo "[otbr-radio] Starting CPC proxy: ep${CPC_SPINEL_EP}.event.cpcd.sock (port ${CPC_SPINEL_EVENT_PORT})" + python3 "${CPC_PROXY_SCRIPT}" client \ + -s "${CPC_SOCKET_BASE}/ep${CPC_SPINEL_EP}.event.cpcd.sock" \ + -H "${CPC_REMOTE_HOST}" -p "${CPC_SPINEL_EVENT_PORT}" & + + # Give the proxies a moment to establish connections. + sleep 2 + + # Verify the control socket was created. + if [ ! -S "${CPC_SOCKET_BASE}/ctrl.cpcd.sock" ]; then + echo "[otbr-radio] ERROR: CPC proxy failed to create ctrl.cpcd.sock." >&2 + echo "[otbr-radio] Check that ${CPC_REMOTE_HOST}:${CPC_REMOTE_PORT} is reachable" >&2 + echo "[otbr-radio] and cpc_remote_access.sh server is running." >&2 + exit 1 + fi + echo "[otbr-radio] CPC proxy client connected to ${CPC_REMOTE_HOST}." + +else + #-------------------------------------------------------------------------- + # Local radio mode — validate RADIO_DEVICE and start cpcd. + #-------------------------------------------------------------------------- + if [ -z "${RADIO_DEVICE}" ]; then + echo "[otbr-radio] ERROR: Neither RADIO_DEVICE nor CPC_REMOTE_HOST is set." >&2 + echo "[otbr-radio] Set RADIO_DEVICE for a local radio:" >&2 + echo "[otbr-radio] export RADIO_DEVICE=/dev/ttyACM0" >&2 + echo "[otbr-radio] Or set CPC_REMOTE_HOST for a remote cpcd:" >&2 + echo "[otbr-radio] export CPC_REMOTE_HOST=10.0.0.1" >&2 + exit 1 + fi + + if [ ! -e "${RADIO_DEVICE}" ]; then + echo "[otbr-radio] ERROR: USB radio device '${RADIO_DEVICE}' not found." >&2 + echo "[otbr-radio] Check that the thread radio is connected and USB-IP is attached." >&2 + echo "[otbr-radio] See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for setup instructions." >&2 + exit 1 + fi + echo "[otbr-radio] Using USB radio device: ${RADIO_DEVICE}" fi -echo "[otbr-radio] Using USB radio device: ${RADIO_DEVICE}" ############################################################################### # 3. Start Avahi daemon (required by otbr-agent — built with OTBR_MDNS=avahi) @@ -119,49 +213,53 @@ avahi-daemon --daemonize --no-chroot echo "[otbr-radio] avahi-daemon started." ############################################################################### -# 4. Configure and start cpcd +# 4. Configure and start cpcd (local radio mode only) # # Always write a fresh config so we fully control all keys. # The cpcd installed config uses 'uart_device_file' (not 'uart_device'), so # patching the existing file would silently fail leaving the wrong device path. +# +# Skipped when CPC_REMOTE_HOST is set — the CPC proxy handles connectivity. ############################################################################### -echo "[otbr-radio] Writing cpcd config to ${CPCD_CONF}..." -mkdir -p "$(dirname "${CPCD_CONF}")" -cat > "${CPCD_CONF}" < "${CPCD_CONF}" < "${CPCD_LOG}" -cpcd --conf "${CPCD_CONF}" > >(tee "${CPCD_LOG}") 2>&1 & -CPCD_PID=$! - -# Wait until cpcd logs its ready message, meaning it has fully connected to the -# secondary and is accepting client connections. -CPCD_WAIT_MAX=30 -cpcdWaitCount=0 -echo "[otbr-radio] Waiting for cpcd to be ready..." -while ! grep -q "Daemon startup was successful" "${CPCD_LOG}" 2>/dev/null; do - if ! kill -0 ${CPCD_PID} 2>/dev/null; then - echo "[otbr-radio] ERROR: cpcd exited before becoming ready. Check device and firmware." >&2 - cat "${CPCD_LOG}" >&2 - exit 1 - fi - if [ ${cpcdWaitCount} -ge ${CPCD_WAIT_MAX} ]; then - echo "[otbr-radio] ERROR: cpcd did not become ready after ${CPCD_WAIT_MAX}s." >&2 - cat "${CPCD_LOG}" >&2 - exit 1 - fi - sleep 1 - cpcdWaitCount=$((cpcdWaitCount + 1)) -done -echo "[otbr-radio] cpcd ready (PID ${CPCD_PID}, waited ${cpcdWaitCount}s)." + echo "[otbr-radio] Starting cpcd (instance: ${CPC_INSTANCE}, device: ${RADIO_DEVICE})..." + CPCD_LOG="/tmp/cpcd.log" + : > "${CPCD_LOG}" + cpcd --conf "${CPCD_CONF}" > >(tee "${CPCD_LOG}") 2>&1 & + CPCD_PID=$! + + # Wait until cpcd logs its ready message, meaning it has fully connected to the + # secondary and is accepting client connections. + CPCD_WAIT_MAX=30 + cpcdWaitCount=0 + echo "[otbr-radio] Waiting for cpcd to be ready..." + while ! grep -q "Daemon startup was successful" "${CPCD_LOG}" 2>/dev/null; do + if ! kill -0 ${CPCD_PID} 2>/dev/null; then + echo "[otbr-radio] ERROR: cpcd exited before becoming ready. Check device and firmware." >&2 + cat "${CPCD_LOG}" >&2 + exit 1 + fi + if [ ${cpcdWaitCount} -ge ${CPCD_WAIT_MAX} ]; then + echo "[otbr-radio] ERROR: cpcd did not become ready after ${CPCD_WAIT_MAX}s." >&2 + cat "${CPCD_LOG}" >&2 + exit 1 + fi + sleep 1 + cpcdWaitCount=$((cpcdWaitCount + 1)) + done + echo "[otbr-radio] cpcd ready (PID ${CPCD_PID}, waited ${cpcdWaitCount}s)." +fi ############################################################################### # 5. Start bt_host_cpc_hci_bridge (CPC → virtual HCI serial device) @@ -174,6 +272,9 @@ echo "[otbr-radio] cpcd ready (PID ${CPCD_PID}, waited ${cpcdWaitCount}s)." echo "[otbr-radio] Starting bt_host_cpc_hci_bridge..." BT_BRIDGE_DIR="/var/run/bt-hci-bridge" mkdir -p "${BT_BRIDGE_DIR}" +# Remove any stale symlink from a previous run so the wait loop below +# blocks until bt_host_cpc_hci_bridge creates a fresh one. +rm -f "${BT_BRIDGE_DIR}/pts_hci" cd "${BT_BRIDGE_DIR}" bt_host_cpc_hci_bridge & BT_BRIDGE_PID=$! @@ -197,6 +298,38 @@ done PTS_DEVICE=$(readlink -f "${BT_BRIDGE_DIR}/pts_hci") echo "[otbr-radio] bt_host_cpc_hci_bridge ready (PID ${BT_BRIDGE_PID}, pts: ${PTS_DEVICE}, waited ${btBridgeWaitCount}s)." +############################################################################### +# 5b. Start HCI PTY proxy (strip LE Extended Advertising feature bit) +# +# The Silicon Labs CPC BLE firmware reports Extended Advertising support but +# responds to Extended Scan Disable (0x2042) with the legacy opcode (0x200c). +# The kernel treats this as unexpected, times out, and cannot stop scanning — +# which prevents LE connections from being established. +# +# This proxy sits between bt_host_cpc_hci_bridge and btattach, modifying the +# LE Read Local Supported Features response to clear the Extended Advertising +# bit. The kernel then uses legacy BLE commands that the firmware handles +# correctly. +############################################################################### +HCI_PROXY_SCRIPT="/opt/cpc-proxy/hci_pty_proxy.py" + +if [ -f "${HCI_PROXY_SCRIPT}" ]; then + echo "[otbr-radio] Starting HCI PTY proxy (strip Extended Advertising)..." + python3 "${HCI_PROXY_SCRIPT}" "${PTS_DEVICE}" "${BT_BRIDGE_DIR}/pts_hci" & + HCI_PROXY_PID=$! + sleep 1 + + if ! kill -0 ${HCI_PROXY_PID} 2>/dev/null; then + echo "[otbr-radio] WARNING: HCI PTY proxy exited early — continuing without it." >&2 + else + # btattach will now open the proxy's PTY instead of the original. + PTS_DEVICE=$(readlink -f "${BT_BRIDGE_DIR}/pts_hci") + echo "[otbr-radio] HCI PTY proxy ready (PID ${HCI_PROXY_PID}, new pts: ${PTS_DEVICE})." + fi +else + echo "[otbr-radio] HCI PTY proxy not found — skipping (Extended BLE commands may fail)." +fi + ############################################################################### # 6. Attach the virtual HCI device and start bluetoothd # @@ -258,52 +391,36 @@ ble_attach() { echo "${radioHciIndex}" > "${DBUS_DIR}/ble_adapter_id" echo "[otbr-radio] Wrote BLE adapter index ${radioHciIndex} to ${DBUS_DIR}/ble_adapter_id" - # Allow the kernel's initial HCI probe to complete before we send - # any commands. The kernel sends HCI_Reset, Read_Local_Features, - # etc. immediately after btattach registers the device. Sending - # our own commands too early races with those and can leave the - # Silicon Labs CPC-BLE bridge in a broken state where LE scanning - # returns "Command Disallowed" or I/O errors. - echo "[otbr-radio] Waiting for kernel HCI initialization to settle..." - sleep 3 - - # Bring the device up cleanly. Do NOT use hcitool here — it uses - # the legacy HCI socket which permanently poisons the MGMT layer. - echo "[otbr-radio] Bringing ${radioHci} up..." - nsenter --net="${HOST_NETNS}" hciconfig "${radioHci}" up 2>/dev/null || true - sleep 1 - - # Verify the adapter is accessible. - if ! ble_verify_scan "${radioHci}"; then - echo "[otbr-radio] WARNING: HCI verification failed after initial up." >&2 - echo "[otbr-radio] Attempting down/up recovery cycle..." >&2 - nsenter --net="${HOST_NETNS}" hciconfig "${radioHci}" down 2>/dev/null || true - sleep 2 - nsenter --net="${HOST_NETNS}" hciconfig "${radioHci}" up 2>/dev/null || true - sleep 2 - - if ! ble_verify_scan "${radioHci}"; then - echo "[otbr-radio] ERROR: HCI still not accessible after recovery." >&2 - return 1 - fi - fi - - echo "[otbr-radio] LE scan verification passed for ${radioHci}." - # Kill any stale bluetoothd before starting a fresh instance. pkill -x bluetoothd 2>/dev/null || true sleep 0.5 - echo "[otbr-radio] Starting bluetoothd (host netns, private D-Bus)..." - nsenter --net="${HOST_NETNS}" bluetoothd & - BLUETOOTHD_PID=$! - sleep 1 - if ! kill -0 ${BLUETOOTHD_PID} 2>/dev/null; then - echo "[otbr-radio] ERROR: bluetoothd (PID ${BLUETOOTHD_PID}) exited immediately." >&2 - return 1 - fi + # Fix the Silicon Labs CPC BLE controller initialization issue. + # + # Problem: the kernel's initial HCI probe (triggered by btattach) + # sends commands that the CPC firmware responds to with malformed + # responses, leaving the controller in a state where LE scanning + # returns "Command Disallowed" (0x0C). + # + # Solution: cycle the adapter down→up to clear the kernel's stale + # HCI state, then send a raw HCI Reset to fix the controller. + # After that, the adapter is in a clean state and bluetoothd can + # start without conflicts. Starting bluetoothd AFTER the reset + # is critical — if bluetoothd runs when the HCI Reset is sent, + # bluetoothd's internal state becomes inconsistent (InProgress + # errors on StartDiscovery). + echo "[otbr-radio] Resetting ${radioHci} (down/up/HCI Reset)..." + nsenter --net="${HOST_NETNS}" hciconfig "${radioHci}" down 2>/dev/null || true + sleep 0.5 + nsenter --net="${HOST_NETNS}" hciconfig "${radioHci}" up 2>/dev/null || true + sleep 0.5 + nsenter --net="${HOST_NETNS}" hcitool -i "${radioHci}" cmd 0x03 0x0003 >/dev/null 2>&1 || true + sleep 0.5 - echo "[otbr-radio] bluetoothd started (PID ${BLUETOOTHD_PID})." + echo "[otbr-radio] Starting bluetoothd (host netns, private D-Bus)..." + nsenter --net="${HOST_NETNS}" bluetoothd & + sleep 3 + echo "[otbr-radio] BLE stack ready (${radioHci})." return 0 fi @@ -318,99 +435,12 @@ ble_attach() { return 1 } -# ble_verify_scan — test that the HCI adapter exists and is responsive. -# Returns 0 if the adapter is detected by hciconfig, 1 if it fails. -# Uses a hard 4-second timeout to avoid blocking if the CPC-BLE bridge is -# completely unresponsive. -# IMPORTANT: Do NOT use hcitool lescan here. hcitool uses the legacy HCI socket -# interface which permanently conflicts with bluetoothd's MGMT socket — starting -# an LE scan via hcitool and killing it leaves the MGMT layer stuck, causing all -# subsequent StartDiscovery calls to fail with "InProgress". -ble_verify_scan() { - local hciDev="$1" - local output - output=$(timeout 4 nsenter --net="${HOST_NETNS}" hciconfig "${hciDev}" 2>&1) || true - - if echo "${output}" | grep -qi "no such device"; then - echo "[otbr-radio] ble_verify_scan: device not found — ${output}" >&2 - return 1 - fi - - # As long as hciconfig recognizes the device, the btattach→HCI chain - # is functional. bluetoothd will power it on via MGMT when it starts. - if ! echo "${output}" | grep -qi "Bus:"; then - echo "[otbr-radio] ble_verify_scan: unexpected output — ${output}" >&2 - return 1 - fi - - return 0 -} - -# ble_health_check — Passive D-Bus-based health check suitable for use while -# bluetoothd is running. Verifies the adapter is reachable and powered via -# D-Bus property reads. Does NOT start/stop discovery to avoid conflicting -# with Matter SDK BLE scans. Returns 0 if the adapter is powered, 1 on failure. -ble_health_check() { - local adapterPath="/org/bluez/${1}" - - # Verify bluetoothd is reachable and adapter is powered. - local powered - powered=$(timeout 5 dbus-send --system --dest=org.bluez \ - --print-reply "${adapterPath}" \ - org.freedesktop.DBus.Properties.Get \ - string:org.bluez.Adapter1 string:Powered 2>&1) || true - - if echo "${powered}" | grep -qi "error\|no reply\|not found"; then - echo "[otbr-radio] ble_health_check: adapter not reachable on D-Bus" >&2 - return 1 - fi - - if ! echo "${powered}" | grep -q "boolean true"; then - echo "[otbr-radio] ble_health_check: adapter not powered" >&2 - return 1 - fi - - # Verify adapter address is resolvable (validates HCI transport). - local address - address=$(timeout 5 dbus-send --system --dest=org.bluez \ - --print-reply "${adapterPath}" \ - org.freedesktop.DBus.Properties.Get \ - string:org.bluez.Adapter1 string:Address 2>&1) || true - - if echo "${address}" | grep -qi "error\|no reply\|not found"; then - echo "[otbr-radio] ble_health_check: cannot read adapter address" >&2 - return 1 - fi - - return 0 -} - -# ble_monitor — background loop that monitors BLE health and restarts the -# stack when needed. Handles two failure modes: -# 1. btattach exits (e.g. USB-IP disconnect/reconnect cycle) -# 2. btattach alive but CPC-BLE bridge in broken state (scan fails) -# -# The health check runs every BLE_HEALTH_INTERVAL seconds and verifies that -# LE scanning works. If scanning fails, it kills the BLE chain and restarts -# it from the CPC bridge level. -# -# A startup grace period (BLE_HEALTH_GRACE) allows Thread traffic to settle -# after otbr-agent forms/joins the network before the first health check. -# During initial Thread establishment, CPC bandwidth is saturated and BLE -# commands may time out — this is transient, not a real failure. -BLE_HEALTH_INTERVAL=60 -BLE_HEALTH_GRACE=90 - +# ble_monitor — background loop that restarts btattach (and bluetoothd) +# whenever btattach exits. This handles USB-IP disconnect/reconnect +# cycles where cpcd and bt_host_cpc_hci_bridge survive but btattach +# loses the HCI device and exits. ble_monitor() { local backoff=2 - local healthCounter=0 - local radioHci="" - local graceRemaining=${BLE_HEALTH_GRACE} - - # Resolve the radio HCI device name from the adapter index file. - if [ -f "${DBUS_DIR}/ble_adapter_id" ]; then - radioHci="hci$(cat "${DBUS_DIR}/ble_adapter_id" | tr -d '[:space:]')" - fi while true; do @@ -419,19 +449,9 @@ ble_monitor() { # If bt_host_cpc_hci_bridge also died, wait for it to come back. if ! kill -0 ${BT_BRIDGE_PID} 2>/dev/null; then - echo "[otbr-radio] bt_host_cpc_hci_bridge also exited; restarting bridge..." >&2 - ble_restart_bridge - - if ! kill -0 ${BT_BRIDGE_PID} 2>/dev/null; then - echo "[otbr-radio] Bridge restart failed; retrying in ${backoff}s..." >&2 - sleep "${backoff}" - - if [ ${backoff} -lt 30 ]; then - backoff=$((backoff * 2)) - fi - - continue - fi + echo "[otbr-radio] bt_host_cpc_hci_bridge also exited; waiting for it to restart..." >&2 + sleep "${backoff}" + continue fi # pts_hci must still be valid. @@ -444,12 +464,6 @@ ble_monitor() { if ble_attach; then echo "[otbr-radio] BLE stack restarted successfully." backoff=2 - healthCounter=0 - - # Update the HCI device name after restart (index may change). - if [ -f "${DBUS_DIR}/ble_adapter_id" ]; then - radioHci="hci$(cat "${DBUS_DIR}/ble_adapter_id" | tr -d '[:space:]')" - fi else echo "[otbr-radio] BLE stack restart failed; retrying in ${backoff}s..." >&2 @@ -457,84 +471,12 @@ ble_monitor() { backoff=$((backoff * 2)) fi fi - else - # btattach is alive — periodically verify scanning still works. - # Skip health checks during the startup grace period. - if [ ${graceRemaining} -gt 0 ]; then - graceRemaining=$((graceRemaining - 1)) - else - healthCounter=$((healthCounter + 1)) - - if [ ${healthCounter} -ge ${BLE_HEALTH_INTERVAL} ] && [ -n "${radioHci}" ]; then - healthCounter=0 - - if ! ble_health_check "${radioHci}"; then - echo "[otbr-radio] BLE health check FAILED — scan broken. Restarting full BLE chain..." >&2 - # Kill the entire BLE chain. - kill ${BTATTACH_PID} 2>/dev/null || true - pkill -x bluetoothd 2>/dev/null || true - kill ${BT_BRIDGE_PID} 2>/dev/null || true - sleep 5 - - # Restart the bridge with retries. - ble_restart_bridge - sleep 1 - continue - fi - fi - fi fi - sleep 1 + sleep "${backoff}" done } -# ble_restart_bridge — restart bt_host_cpc_hci_bridge with retries. -# cpcd may take 5-20s to release the BLE endpoint after the old bridge exits. -# Retry up to 4 times with increasing pre-delays (5s, 10s, 15s, 20s). -ble_restart_bridge() { - local attempt=0 - local maxAttempts=4 - - while [ ${attempt} -lt ${maxAttempts} ]; do - attempt=$((attempt + 1)) - local delay=$((attempt * 5)) - echo "[otbr-radio] Restarting bt_host_cpc_hci_bridge (attempt ${attempt}/${maxAttempts}, pre-delay ${delay}s)..." - - # Wait for cpcd to release the BLE endpoint. First attempt gets a - # short delay; subsequent attempts wait longer. - sleep ${delay} - rm -f "${BT_BRIDGE_DIR}/pts_hci" - cd "${BT_BRIDGE_DIR}" - bt_host_cpc_hci_bridge & - BT_BRIDGE_PID=$! - - # Wait for pts_hci symlink. - local bridgeWait=0 - - while [ ! -L "${BT_BRIDGE_DIR}/pts_hci" ] && [ ${bridgeWait} -lt 10 ]; do - - if ! kill -0 ${BT_BRIDGE_PID} 2>/dev/null; then - break - fi - - sleep 1 - bridgeWait=$((bridgeWait + 1)) - done - - if [ -L "${BT_BRIDGE_DIR}/pts_hci" ]; then - echo "[otbr-radio] bt_host_cpc_hci_bridge ready (PID ${BT_BRIDGE_PID})." - return 0 - fi - - echo "[otbr-radio] bt_host_cpc_hci_bridge attempt ${attempt} failed." >&2 - kill ${BT_BRIDGE_PID} 2>/dev/null || true - done - - echo "[otbr-radio] ERROR: bt_host_cpc_hci_bridge failed after ${maxAttempts} attempts." >&2 - return 1 -} - if [ ! -e "${HOST_NETNS}" ]; then echo "[otbr-radio] WARNING: Host network namespace not mounted at ${HOST_NETNS}." >&2 echo "[otbr-radio] Bluetooth support will not be available." >&2 @@ -566,4 +508,4 @@ exec otbr-agent \ -B "${BACKBONE_IF}" \ -d 7 \ -v \ - "spinel+cpc://cpcd_0?iid=1&iid-list=0" + "spinel+cpc://${CPC_INSTANCE}?iid=1&iid-list=0" diff --git a/scripts/remote-radio/cpc_remote_access.sh b/scripts/remote-radio/cpc_remote_access.sh new file mode 100755 index 00000000..d1d7ae77 --- /dev/null +++ b/scripts/remote-radio/cpc_remote_access.sh @@ -0,0 +1,209 @@ +#!/bin/bash +# +# CPC Remote Access - Start all socket proxies for remote libcpc access +# +# This script starts proxy processes to tunnel all cpcd Unix sockets over TCP, +# allowing libcpc clients to run on remote machines. +# +# Usage: +# On cpcd host: ./cpc_remote_access.sh server [instance_name] [base_port] +# On remote client: ./cpc_remote_access.sh client [instance_name] [base_port] +# +# Port assignments (from base_port): +# base_port + 0: ctrl.cpcd.sock +# base_port + 1: reset.cpcd.sock +# base_port + 100-355: ep0-ep255.cpcd.sock +# base_port + 400-655: ep0-ep255.event.cpcd.sock +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROXY_SCRIPT="$SCRIPT_DIR/cpc_socket_proxy.py" + +MODE="${1:-}" +INSTANCE="${3:-cpcd_0}" +BASE_PORT="${4:-15000}" +SOCKET_DIR="${CPC_SOCKET_DIR:-/dev/shm}" + +usage() { + echo "Usage:" + echo " On cpcd host: $0 server [instance_name] [base_port]" + echo " On remote client: $0 client [instance_name] [base_port]" + echo "" + echo "Options:" + echo " instance_name CPC daemon instance name (default: cpcd_0)" + echo " base_port Starting port number (default: 15000)" + echo "" + echo "Environment:" + echo " CPC_SOCKET_DIR Socket directory (default: /dev/shm)" + exit 1 +} + +cleanup() { + echo "Stopping all proxy processes..." + jobs -p | xargs -r kill 2>/dev/null || true + wait 2>/dev/null || true + echo "Cleanup complete" +} + +trap cleanup EXIT INT TERM + +start_server() { + local socket_base="$SOCKET_DIR/cpcd/$INSTANCE" + + if [[ ! -d "$socket_base" ]]; then + echo "Error: Socket directory $socket_base does not exist" + echo "Is cpcd running with instance name '$INSTANCE'?" + exit 1 + fi + + echo "Starting CPC remote access server for instance '$INSTANCE'" + echo "Socket directory: $socket_base" + echo "Base port: $BASE_PORT" + echo "" + + # Track socket_name -> PID so we can detect and restart dead proxies. + declare -A PROXIED_PIDS + + start_proxy() { + local sock="$1" port="$2" + local filename + filename=$(basename "$sock") + python3 "$PROXY_SCRIPT" server -s "$sock" -p "$port" & + PROXIED_PIDS["$filename"]=$! + echo " $filename -> port $port (PID $!)" + } + + # Control socket + echo "Starting core socket proxies:" + start_proxy "$socket_base/ctrl.cpcd.sock" "$BASE_PORT" + + # Reset socket + start_proxy "$socket_base/reset.cpcd.sock" "$((BASE_PORT + 1))" + + # proxy_endpoint_sockets scans the socket directory and starts a proxy for + # any endpoint socket that doesn't have one yet. It also restarts proxies + # that have died. cpcd creates endpoint sockets on-demand when clients + # open endpoints, so we must poll for new ones. + proxy_endpoint_sockets() { + for sock in "$socket_base"/ep*.cpcd.sock; do + [[ -S "$sock" ]] || continue + local filename + filename=$(basename "$sock") + + # If we already started a proxy, check if it's still alive. + if [[ -n "${PROXIED_PIDS[$filename]+x}" ]]; then + local pid="${PROXIED_PIDS[$filename]}" + + if kill -0 "$pid" 2>/dev/null; then + continue + fi + + echo "Proxy for $filename (PID $pid) died — restarting" + unset 'PROXIED_PIDS[$filename]' + fi + + if [[ "$filename" =~ ^ep([0-9]+)\.event\.cpcd\.sock$ ]]; then + local ep_num="${BASH_REMATCH[1]}" + local event_port=$((BASE_PORT + 400 + ep_num)) + start_proxy "$sock" "$event_port" + elif [[ "$filename" =~ ^ep([0-9]+)\.cpcd\.sock$ ]]; then + local ep_num="${BASH_REMATCH[1]}" + local ep_port=$((BASE_PORT + 100 + ep_num)) + start_proxy "$sock" "$ep_port" + fi + done + } + + # Also monitor the core proxies (ctrl, reset) and restart them if dead. + check_core_proxies() { + for filename in ctrl.cpcd.sock reset.cpcd.sock; do + [[ -n "${PROXIED_PIDS[$filename]+x}" ]] || continue + local pid="${PROXIED_PIDS[$filename]}" + + if ! kill -0 "$pid" 2>/dev/null; then + echo "Core proxy for $filename (PID $pid) died — restarting" + unset 'PROXIED_PIDS[$filename]' + local port + case "$filename" in + ctrl.cpcd.sock) port="$BASE_PORT" ;; + reset.cpcd.sock) port="$((BASE_PORT + 1))" ;; + esac + start_proxy "$socket_base/$filename" "$port" + fi + done + } + + # Proxy any endpoint sockets that already exist. + proxy_endpoint_sockets + + echo "" + echo "Server started. Watching for new/dead endpoint sockets..." + echo "" + echo "On the remote client, run:" + echo " $0 client $INSTANCE $BASE_PORT" + + # Poll for new endpoint sockets and restart dead proxies. + while true; do + sleep 2 + proxy_endpoint_sockets + check_core_proxies + done +} + +start_client() { + local remote_host="$2" + + if [[ -z "$remote_host" ]]; then + echo "Error: Remote host not specified" + usage + fi + + local socket_base="$SOCKET_DIR/cpcd/$INSTANCE" + + echo "Starting CPC remote access client for instance '$INSTANCE'" + echo "Remote host: $remote_host" + echo "Socket directory: $socket_base" + echo "Base port: $BASE_PORT" + echo "" + + # Create socket directory + mkdir -p "$socket_base" + + # Control socket + echo "Starting proxy for ctrl.cpcd.sock from port $BASE_PORT" + python3 "$PROXY_SCRIPT" client -s "$socket_base/ctrl.cpcd.sock" -H "$remote_host" -p "$BASE_PORT" & + + # Reset socket + local reset_port=$((BASE_PORT + 1)) + echo "Starting proxy for reset.cpcd.sock from port $reset_port" + python3 "$PROXY_SCRIPT" client -s "$socket_base/reset.cpcd.sock" -H "$remote_host" -p "$reset_port" & + + echo "" + echo "Client started with core sockets (ctrl, reset)." + echo "Endpoint sockets will be created on-demand." + echo "" + echo "To add endpoint proxies manually:" + echo " # For endpoint N data socket:" + echo " python3 $PROXY_SCRIPT client -s $socket_base/epN.cpcd.sock -H $remote_host -p \$((BASE_PORT + 100 + N))" + echo "" + echo " # For endpoint N event socket:" + echo " python3 $PROXY_SCRIPT client -s $socket_base/epN.event.cpcd.sock -H $remote_host -p \$((BASE_PORT + 400 + N))" + echo "" + echo "Press Ctrl+C to stop." + + wait +} + +case "$MODE" in + server) + start_server "$@" + ;; + client) + start_client "$@" + ;; + *) + usage + ;; +esac diff --git a/scripts/remote-radio/cpc_socket_proxy.py b/scripts/remote-radio/cpc_socket_proxy.py new file mode 100644 index 00000000..77239b15 --- /dev/null +++ b/scripts/remote-radio/cpc_socket_proxy.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +CPC Socket Proxy - Tunnels SEQPACKET Unix sockets over TCP with message framing. + +Self-healing proxy that automatically recovers from: + - TCP connection drops (client or server container restart) + - Unix socket disconnections (cpcd or application restart) + - Stale sessions from previous connections + +Each message is framed with a 4-byte network-order length prefix to preserve +SEQPACKET message boundaries over TCP. + +Server mode (on cpcd host): + - Listens on a TCP port and relays to/from a local CPC Unix socket + - Only one active session per endpoint (CPC limitation); a new TCP client + automatically terminates the previous session + - TCP keepalive detects dead clients within ~25 seconds + +Client mode (on remote host): + - Creates a local Unix SEQPACKET socket mimicking cpcd + - Each local connection gets a fresh TCP session to the server + - Retries TCP connections (30 attempts) to handle server-side startup races + +Usage: + Server: ./cpc_socket_proxy.py server -s /dev/shm/cpcd/cpcd_0/ctrl.cpcd.sock -p 5000 + Client: ./cpc_socket_proxy.py client -s /dev/shm/cpcd/cpcd_0/ctrl.cpcd.sock -H -p 5000 +""" + +import argparse +import os +import socket +import struct +import sys +import threading +import time +from pathlib import Path + + +def enable_tcp_keepalive(sock: socket.socket): + """Enable aggressive TCP keepalive for fast stale-connection detection.""" + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 5) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) + + +def close_sockets(*sockets): + """Shut down and close sockets, ignoring errors.""" + for s in sockets: + try: + s.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + try: + s.close() + except OSError: + pass + + +def recv_framed(tcp_sock: socket.socket) -> bytes | None: + """Receive a length-prefixed message from TCP socket.""" + header = b'' + + while len(header) < 4: + chunk = tcp_sock.recv(4 - len(header)) + + if not chunk: + return None + + header += chunk + + length = struct.unpack('!I', header)[0] + + if length == 0: + return b'' + + data = b'' + + while len(data) < length: + chunk = tcp_sock.recv(min(length - len(data), 65536)) + + if not chunk: + return None + + data += chunk + + return data + + +def send_framed(tcp_sock: socket.socket, data: bytes) -> bool: + """Send a length-prefixed message to TCP socket.""" + try: + header = struct.pack('!I', len(data)) + tcp_sock.sendall(header + data) + return True + except (BrokenPipeError, ConnectionResetError, OSError): + return False + + +def relay_unix_to_tcp(unix_sock, tcp_sock, name, done): + """Forward Unix -> TCP. Closes both sockets on exit so the peer exits.""" + try: + while True: + data = unix_sock.recv(65536) + + if not data: + break + + if not send_framed(tcp_sock, data): + break + except (ConnectionResetError, BrokenPipeError, OSError): + pass + finally: + close_sockets(unix_sock, tcp_sock) + done.set() + + print(f"[{name}] Unix->TCP stopped", flush=True) + + +def relay_tcp_to_unix(tcp_sock, unix_sock, name, done): + """Forward TCP -> Unix. Closes both sockets on exit so the peer exits.""" + try: + while True: + data = recv_framed(tcp_sock) + + if data is None: + break + + unix_sock.send(data) + except (ConnectionResetError, BrokenPipeError, OSError): + pass + finally: + close_sockets(unix_sock, tcp_sock) + done.set() + + print(f"[{name}] TCP->Unix stopped", flush=True) + + +def run_relay_pair(unix_sock, tcp_sock, name): + """Start bidirectional relay threads and block until the session ends.""" + done = threading.Event() + + t1 = threading.Thread( + target=relay_unix_to_tcp, + args=(unix_sock, tcp_sock, name, done), + daemon=True, + ) + t2 = threading.Thread( + target=relay_tcp_to_unix, + args=(tcp_sock, unix_sock, name, done), + daemon=True, + ) + t1.start() + t2.start() + + # Block until either direction fails. + done.wait() + + # Ensure both sockets are closed so the surviving thread also exits. + close_sockets(unix_sock, tcp_sock) + t1.join(timeout=5) + t2.join(timeout=5) + + print(f"[{name}] Session ended", flush=True) + + +def run_server(socket_path: str, port: int, bind_addr: str = '0.0.0.0'): + """ + Server mode: accept TCP clients and relay to the local CPC Unix socket. + + CPC only allows one client per endpoint socket, so when a new TCP client + connects we kill the previous session first. This handles client-side + container restarts cleanly — the new client evicts the stale session. + """ + name = os.path.basename(socket_path) + print(f"[{name}] Server: TCP :{port} <-> {socket_path}", flush=True) + + tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + tcp_server.bind((bind_addr, port)) + tcp_server.listen(1) + + # Mutable container so the accept loop can evict a stale session. + active = {'unix': None, 'tcp': None, 'thread': None} + active_lock = threading.Lock() + + while True: + tcp_client, addr = tcp_server.accept() + enable_tcp_keepalive(tcp_client) + print(f"[{name}] TCP connection from {addr}", flush=True) + + # Evict any stale session — close its sockets so relay threads exit. + with active_lock: + + if active['thread'] and active['thread'].is_alive(): + print(f"[{name}] Evicting stale session", flush=True) + close_sockets(active['unix'], active['tcp']) + active['thread'].join(timeout=5) + + try: + unix_sock = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) + unix_sock.connect(socket_path) + except Exception as e: + print(f"[{name}] Unix connect failed: {e}", flush=True) + tcp_client.close() + continue + + def session(us=unix_sock, ts=tcp_client, n=name): + run_relay_pair(us, ts, n) + + t = threading.Thread(target=session, daemon=True) + t.start() + + with active_lock: + active['unix'] = unix_sock + active['tcp'] = tcp_client + active['thread'] = t + + +def run_client(socket_path: str, host: str, port: int): + """ + Client mode: create a local CPC-compatible Unix socket and relay to TCP. + + When a relay session dies (TCP drop, Unix disconnect, container restart), + the proxy goes back to accept() and waits for the next local client. + The CPC library inside the application will reconnect automatically. + """ + name = os.path.basename(socket_path) + print(f"[{name}] Client: {socket_path} <-> TCP {host}:{port}", flush=True) + + socket_dir = os.path.dirname(socket_path) + Path(socket_dir).mkdir(parents=True, exist_ok=True) + + try: + os.unlink(socket_path) + except FileNotFoundError: + pass + + unix_server = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) + unix_server.bind(socket_path) + unix_server.listen(5) + os.chmod(socket_path, 0o777) + + while True: + unix_client, _ = unix_server.accept() + print(f"[{name}] Local client connected", flush=True) + + tcp_sock = None + + for attempt in range(30): + + try: + tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp_sock.connect((host, port)) + enable_tcp_keepalive(tcp_sock) + break + except Exception as e: + tcp_sock.close() + tcp_sock = None + + if attempt < 29: + time.sleep(1) + else: + print(f"[{name}] TCP connect to {host}:{port} " + f"failed after 30 attempts: {e}", flush=True) + + if tcp_sock is None: + unix_client.close() + continue + + print(f"[{name}] Connected to {host}:{port}", flush=True) + + def session(us=unix_client, ts=tcp_sock, n=name): + run_relay_pair(us, ts, n) + + threading.Thread(target=session, daemon=True).start() + + +def main(): + parser = argparse.ArgumentParser(description='CPC Socket Proxy') + subparsers = parser.add_subparsers(dest='mode', required=True) + + server_parser = subparsers.add_parser('server', help='Run as server (on cpcd host)') + server_parser.add_argument('--socket', '-s', required=True, help='Unix socket path') + server_parser.add_argument('--port', '-p', type=int, required=True, help='TCP port') + server_parser.add_argument('--bind', '-b', default='0.0.0.0', help='Bind address') + + client_parser = subparsers.add_parser('client', help='Run as client (on remote host)') + client_parser.add_argument('--socket', '-s', required=True, help='Unix socket path to create') + client_parser.add_argument('--host', '-H', required=True, help='Remote cpcd host') + client_parser.add_argument('--port', '-p', type=int, required=True, help='Remote TCP port') + + args = parser.parse_args() + + if args.mode == 'server': + run_server(args.socket, args.port, args.bind) + else: + run_client(args.socket, args.host, args.port) + + +if __name__ == '__main__': + main() diff --git a/scripts/remote-radio/hci_pty_proxy.py b/scripts/remote-radio/hci_pty_proxy.py new file mode 100644 index 00000000..d758e848 --- /dev/null +++ b/scripts/remote-radio/hci_pty_proxy.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +# ------------------------------ tabstop = 4 ---------------------------------- +# +# If not stated otherwise in this file or this component's LICENSE file the +# following copyright and licenses apply: +# +# Copyright 2026 Comcast Cable Communications Management, LLC +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# +# ------------------------------ tabstop = 4 ---------------------------------- + +""" +HCI UART (H4) PTY proxy that strips the LE Extended Advertising feature +bit from the LE Read Local Supported Features response. + +This forces the Linux kernel to use legacy BLE scan and connection +commands (0x200b/0x200c/0x200d) instead of the extended versions +(0x2041/0x2042/0x2039). + +Background +---------- +The Silicon Labs CPC BLE firmware reports Extended Advertising support +in its LE feature set, but when the kernel sends LE Set Extended Scan +Enable (0x2042) to *disable* scanning, the firmware responds with the +legacy opcode 0x200c. The kernel treats this as an unexpected event, +discards the response, and the 0x2042 command times out. Because +scanning cannot be stopped, LE Create Connection is rejected with +"Command Disallowed" (0x0c), and BLE connections never succeed. + +By clearing the Extended Advertising feature bit during controller +initialisation the kernel never issues Extended commands and the +firmware's legacy responses are handled correctly. + +Usage +----- + hci_pty_proxy.py + + bridge_pts — PTY device created by bt_host_cpc_hci_bridge + (e.g. /dev/pts/1) + symlink_path — Convenience symlink that btattach will open + (e.g. /var/run/bt-hci-bridge/pts_hci) + +The proxy opens *bridge_pts*, creates a new PTY pair, updates +*symlink_path* to point at the new slave, then relays traffic +bidirectionally. btattach should be started *after* this proxy. +""" + +import os +import pty +import select +import sys +import termios +import time +import tty + + +# Feature bit to clear — byte offset 1, bit 4 (overall bit 12) +# in the 8-byte LE features array. +# +# Byte 1, bit 4 = HCI_LE_EXT_ADV (0x10) +# +# Kernel reference: include/net/bluetooth/hci.h +_LE_FEAT_BYTE = 1 +_LE_EXT_ADV_BIT = 0x10 + +# HCI Command Complete event for LE Read Local Supported Features +# with success status. +# +# 04 — H4 event packet indicator +# 0e — HCI_EV_CMD_COMPLETE +# 0c — parameter length (12) +# 01 — Num_HCI_Command_Packets +# 03 20 — opcode 0x2003 (LE Read Local Supported Features) +# 00 — status Success +# +# The next 8 bytes are the LE features. +_MATCH_PREFIX = bytes([0x04, 0x0E, 0x0C, 0x01, 0x03, 0x20, 0x00]) +_FEATURES_OFFSET = len(_MATCH_PREFIX) # start of LE features in the match + +# LE Meta Event (0x3E) subevent codes for connection complete events. +# +# 0x01 — LE Connection Complete (legacy, 19 param bytes) +# 0x0A — LE Enhanced Connection Complete [v1] (31 param bytes) +# 0x29 — LE Enhanced Connection Complete [v2] (34 param bytes) +# +# Both enhanced variants include Local/Peer Resolvable Private Address +# fields (6 bytes each = 12 extra bytes) that the legacy event lacks. +# v2 additionally includes Advertising_Handle (1) and Sync_Handle (2). +# +# When we strip the Extended Advertising feature bit the kernel only +# registers a handler for subevent 0x01, so we must translate 0x0A and +# 0x29 into 0x01 by removing the extra fields. +_SUBEVENT_CONN_COMPLETE = 0x01 +_SUBEVENT_ENH_CONN_COMPLETE_V1 = 0x0A +_SUBEVENT_ENH_CONN_COMPLETE_V2 = 0x29 + +# Offsets within the LE Meta Event *parameters* (after subevent byte): +# Status(1) + Handle(2) + Role(1) + AddrType(1) + Addr(6) = 11 bytes +# The RPA fields start at parameter offset 11+1 (subevent byte). +_CONN_COMMON_PREFIX_LEN = 11 # bytes before the RPA fields (after subevent) +_RPA_TOTAL_LEN = 12 # Local RPA (6) + Peer RPA (6) +_V2_EXTRA_LEN = 3 # Advertising_Handle (1) + Sync_Handle (2) + +# Buffer size for reads — kept small for low-latency relay. +_BUFSIZE = 4096 + +# Set to True for verbose HCI event logging. +_DEBUG = os.environ.get("HCI_PROXY_DEBUG", "") != "" + + +def _log_hci_event(data: bytes, direction: str) -> None: + """Log HCI events for debugging, with special attention to LE Meta Events.""" + if not _DEBUG: + return + + i = 0 + while i < len(data): + if data[i] == 0x04 and i + 2 < len(data): + # HCI Event packet + evt_code = data[i + 1] + plen = data[i + 2] + pkt_end = i + 3 + plen + + if evt_code == 0x3E and i + 3 < len(data): + # LE Meta Event — subevent code is first param byte + subevent = data[i + 3] + names = { + 0x01: "LE Connection Complete", + 0x02: "LE Advertising Report", + 0x03: "LE Connection Update Complete", + 0x04: "LE Read Remote Features Complete", + 0x05: "LE Long Term Key Request", + 0x0A: "LE Enhanced Connection Complete", + 0x0D: "LE Extended Advertising Report", + } + name = names.get(subevent, f"Unknown(0x{subevent:02X})") + pkt_hex = data[i : min(pkt_end, i + 30)].hex() + print( + f"[hci-proxy] {direction} LE Meta subevent=0x{subevent:02X} " + f"({name}) plen={plen} hex={pkt_hex}", + flush=True, + ) + elif evt_code == 0x0F: + # Command Status + if i + 6 < len(data): + status = data[i + 3] + opcode = data[i + 5] << 8 | data[i + 4] + print( + f"[hci-proxy] {direction} Command Status: " + f"status=0x{status:02X} opcode=0x{opcode:04X}", + flush=True, + ) + elif evt_code == 0x0E: + # Command Complete + if i + 6 < len(data): + opcode = data[i + 5] << 8 | data[i + 4] + print( + f"[hci-proxy] {direction} Command Complete: " + f"opcode=0x{opcode:04X}", + flush=True, + ) + elif evt_code not in (0x3E,): + print( + f"[hci-proxy] {direction} Event 0x{evt_code:02X} plen={plen}", + flush=True, + ) + + i = max(pkt_end, i + 1) + elif data[i] == 0x02 and i + 4 < len(data): + # ACL data packet + handle = (data[i + 2] << 8 | data[i + 1]) & 0x0FFF + acl_len = data[i + 4] << 8 | data[i + 3] + print( + f"[hci-proxy] {direction} ACL handle={handle} len={acl_len}", + flush=True, + ) + i += 5 + acl_len + else: + i += 1 + + +def _configure_raw(fd: int) -> None: + """Put a PTY file descriptor into raw mode (no echo, no line editing).""" + try: + tty.setraw(fd) + except termios.error: + pass # Not a tty — skip. + + +def _strip_ext_adv(data: bytes) -> bytes: + """If *data* contains the LE features response, clear the Extended + Advertising bit so the kernel uses legacy BLE commands.""" + idx = data.find(_MATCH_PREFIX) + + if idx < 0: + return data + + feat_start = idx + _FEATURES_OFFSET + + if feat_start + 8 > len(data): + return data # Incomplete — leave unchanged (rare edge-case). + + original = data[feat_start + _LE_FEAT_BYTE] + + if not (original & _LE_EXT_ADV_BIT): + return data # Already clear — nothing to do. + + buf = bytearray(data) + buf[feat_start + _LE_FEAT_BYTE] = original & ~_LE_EXT_ADV_BIT + print( + f"[hci-proxy] Cleared LE Extended Advertising feature bit " + f"(0x{original:02X} -> 0x{buf[feat_start + _LE_FEAT_BYTE]:02X})", + flush=True, + ) + + return bytes(buf) + + +def _translate_conn_complete(data: bytes) -> bytes: + """Translate LE Enhanced Connection Complete v1/v2 events into the + legacy LE Connection Complete event so the kernel processes them. + + Enhanced variants contain Local/Peer Resolvable Private Address + fields and (in v2) Advertising_Handle + Sync_Handle that the legacy + event lacks. We strip those extra fields and rewrite the subevent + code and parameter length. + + The function scans for *all* occurrences in *data* (there is almost + always at most one, but this keeps the logic robust). + """ + # Quick reject — must contain an LE Meta Event. + if b"\x04\x3e" not in data: + return data + + buf = bytearray(data) + out = bytearray() + i = 0 + + while i < len(buf): + # Look for H4 Event indicator (0x04) followed by LE Meta (0x3E). + if ( + buf[i] == 0x04 + and i + 3 < len(buf) + and buf[i + 1] == 0x3E + ): + plen = buf[i + 2] + pkt_end = i + 3 + plen + + if pkt_end > len(buf): + # Incomplete packet at end of buffer — pass through as-is. + out.extend(buf[i:]) + break + + subevent = buf[i + 3] + + if subevent == _SUBEVENT_ENH_CONN_COMPLETE_V2 and plen >= 34: + # v2: subevent(1) + common(11) + RPAs(12) + interval/lat/to/acc(7) + adv(1) + sync(2) = 34 + common = buf[i + 4 : i + 4 + _CONN_COMMON_PREFIX_LEN] # 11 bytes + tail_start = i + 4 + _CONN_COMMON_PREFIX_LEN + _RPA_TOTAL_LEN + tail_end = pkt_end - _V2_EXTRA_LEN + tail = buf[tail_start:tail_end] # interval + latency + timeout + accuracy = 7 bytes + new_plen = 1 + _CONN_COMMON_PREFIX_LEN + len(tail) # 1 + 11 + 7 = 19 + + out.append(0x04) # H4 event + out.append(0x3E) # LE Meta Event + out.append(new_plen) # parameter length + out.append(_SUBEVENT_CONN_COMPLETE) # subevent 0x01 + out.extend(common) # status + handle + role + addrtype + addr + out.extend(tail) # interval + latency + timeout + accuracy + + print( + f"[hci-proxy] Translated Enhanced Connection Complete v2 " + f"(0x{subevent:02X}) -> legacy (0x01) " + f"handle={common[1] | (common[2] << 8)} " + f"status=0x{common[0]:02X}", + flush=True, + ) + i = pkt_end + continue + + if subevent == _SUBEVENT_ENH_CONN_COMPLETE_V1 and plen >= 31: + # v1: subevent(1) + common(11) + RPAs(12) + interval/lat/to/acc(7) = 31 + common = buf[i + 4 : i + 4 + _CONN_COMMON_PREFIX_LEN] + tail_start = i + 4 + _CONN_COMMON_PREFIX_LEN + _RPA_TOTAL_LEN + tail = buf[tail_start:pkt_end] # interval + latency + timeout + accuracy = 7 + new_plen = 1 + _CONN_COMMON_PREFIX_LEN + len(tail) + + out.append(0x04) + out.append(0x3E) + out.append(new_plen) + out.append(_SUBEVENT_CONN_COMPLETE) + out.extend(common) + out.extend(tail) + + print( + f"[hci-proxy] Translated Enhanced Connection Complete v1 " + f"(0x{subevent:02X}) -> legacy (0x01) " + f"handle={common[1] | (common[2] << 8)} " + f"status=0x{common[0]:02X}", + flush=True, + ) + i = pkt_end + continue + + # Default: copy byte as-is. + out.append(buf[i]) + i += 1 + + return bytes(out) + + +def main() -> None: + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + + bridge_pts = sys.argv[1] + symlink_path = sys.argv[2] + + # Open the PTY created by bt_host_cpc_hci_bridge. + bridge_fd = os.open(bridge_pts, os.O_RDWR | os.O_NOCTTY) + _configure_raw(bridge_fd) + + # Create a new PTY pair for btattach. + master_fd, slave_fd = pty.openpty() + slave_name = os.ttyname(slave_fd) + _configure_raw(master_fd) + + # Update the convenience symlink so btattach finds the new PTY. + try: + os.unlink(symlink_path) + except FileNotFoundError: + pass + os.symlink(slave_name, symlink_path) + + print( + f"[hci-proxy] Relaying: {bridge_pts} <-> {slave_name} " + f"(symlink {symlink_path})", + flush=True, + ) + + try: + while True: + readable, _, _ = select.select([bridge_fd, master_fd], [], []) + + for fd in readable: + try: + data = os.read(fd, _BUFSIZE) + except OSError: + data = b"" + + if not data: + print("[hci-proxy] EOF — exiting.", flush=True) + return + + if fd == bridge_fd: + # Firmware → kernel: filter features and translate events. + _log_hci_event(data, "FW→HOST") + data = _strip_ext_adv(data) + data = _translate_conn_complete(data) + os.write(master_fd, data) + else: + # Kernel → firmware: pass through unchanged. + _log_hci_event(data, "HOST→FW") + os.write(bridge_fd, data) + finally: + os.close(bridge_fd) + os.close(master_fd) + os.close(slave_fd) + + +if __name__ == "__main__": + main() From f8f0f47055dbe0bb9249a1c74bc40c4ec300d7d5 Mon Sep 17 00:00:00 2001 From: Thomas Lea Date: Wed, 27 May 2026 22:29:25 +0000 Subject: [PATCH 10/17] fix(matter,remote-radio): fix BLE/Thread commissioning and harden remote radio stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove CloseBleConnection() after ThreadNetworkEnable ------------------------------------------------------ CPC multiplexes BLE and Thread independently via separate endpoints (ep14 for Bluetooth RCP, ep12 for 802.15.4/Spinel), so closing the BLE connection after Thread network enable is unnecessary. Worse, it prevents the Matter SDK from extending the commissioning failsafe over BLE during CASE-over-Thread retries, causing commissioning to fail when the first CASE attempt doesn't succeed immediately. HCI PTY proxy: intercept Read Local Name (0x0C14) -------------------------------------------------- The CPC firmware does NOT support HCI Read Local Name. It returns a 1-byte "Unknown HCI Command" error instead of the expected 249-byte response. The kernel logs "unexpected cc 0x0c14 length: 1 < 249" and mishandles the command completion — this corrupts the HCI command queue, causes background scan-stop timeouts (-ETIMEDOUT), and leaves bluetoothd in a permanently stuck state where StartDiscovery() returns "InProgress" even though no scan is active. The proxy now intercepts outgoing Read Local Name commands and returns a synthetic success response with a placeholder name so the unsupported command never reaches the CPC firmware. Also adds Extended Advertising command interception (bytes 36-37 opcode stripping) and LE feature bit stripping. CPC socket proxy: support concurrent clients with retry -------------------------------------------------------- Previously the proxy evicted stale sessions on new connections. Now multiple TCP clients can connect concurrently (e.g. both the HCI bridge and otbr-agent need their own ctrl session). Each TCP client gets its own independent Unix socket connection to cpcd. Added retry logic with backoff for endpoint sockets that cpcd creates on-demand after the remote client opens them via the control socket. otbr-radio entrypoint: resilient BLE bridge lifecycle ------------------------------------------------------ - Increase bt_host_cpc_hci_bridge wait timeout from 15s to 30s for remote CPC connections - Convert bridge startup failures from fatal errors to warnings; the BLE monitor loop will retry - Add BLE monitor loop that detects bridge/proxy crashes and restarts the full BLE stack (bridge -> proxy -> btattach -> bluetoothd) - Add process reaping for zombie children - Use python3 -u for unbuffered proxy output CPC remote access: pre-start well-known endpoint proxies --------------------------------------------------------- Pre-start TCP listeners for well-known CPC endpoints (ep12 for 802.15.4, ep14 for Bluetooth) before any remote client connects. The proxy retries the local Unix socket connection with backoff, giving cpcd time to create endpoint sockets on-demand. Added health check loop to restart well-known proxies if they die. setupDockerEnv.sh: persist CPC remote radio settings ----------------------------------------------------- Preserve CPC_REMOTE_HOST, CPC_REMOTE_PORT, and CPC_INSTANCE in docker/.env across setupDockerEnv.sh invocations, matching the existing behavior for RADIO_DEVICE and BACKBONE_IF. compose.otbr-radio.yaml: mount cpc_validate.sh ----------------------------------------------- Bind-mount the CPC validation/diagnostic script into the otbr-radio container at /opt/cpc-proxy/cpc_validate.sh. Add cpc_validate.sh diagnostic script -------------------------------------- Comprehensive validation script for the full CPC + BLE + Thread stack in the otbr-radio container. Checks CPC connectivity, BLE bridge, HCI proxy, BlueZ stack, BLE scanning, Thread interface, and multi-user container detection safety. --- .../matter/CommissioningOrchestrator.cpp | 6 - docker/compose.otbr-radio.yaml | 2 + docker/otbr-radio-entrypoint.sh | 335 +++++-- docker/setupDockerEnv.sh | 22 + scripts/remote-radio/cpc_remote_access.sh | 50 +- scripts/remote-radio/cpc_socket_proxy.py | 61 +- scripts/remote-radio/cpc_validate.sh | 885 ++++++++++++++++++ scripts/remote-radio/hci_pty_proxy.py | 356 ++++++- 8 files changed, 1611 insertions(+), 106 deletions(-) create mode 100755 scripts/remote-radio/cpc_validate.sh diff --git a/core/src/subsystems/matter/CommissioningOrchestrator.cpp b/core/src/subsystems/matter/CommissioningOrchestrator.cpp index 63e586ad..0f82c196 100644 --- a/core/src/subsystems/matter/CommissioningOrchestrator.cpp +++ b/core/src/subsystems/matter/CommissioningOrchestrator.cpp @@ -370,12 +370,6 @@ namespace barton peerId.GetNodeId(), StageToString(stageCompleted), ErrorStr(error)); - - if (stageCompleted == chip::Controller::CommissioningStage::kThreadNetworkEnable && error == CHIP_NO_ERROR) - { - icInfo("Closing BLE connection after ThreadNetworkEnable to free CPC radio for Thread"); - Matter::GetInstance().GetCommissioner()->CloseBleConnection(); - } } void CommissioningOrchestrator::OnReadCommissioningInfo(const chip::Controller::ReadCommissioningInfo &info) diff --git a/docker/compose.otbr-radio.yaml b/docker/compose.otbr-radio.yaml index 4463612f..17dcd63b 100644 --- a/docker/compose.otbr-radio.yaml +++ b/docker/compose.otbr-radio.yaml @@ -110,6 +110,8 @@ services: # Mount the HCI PTY proxy that strips Extended Advertising from # the LE features response, working around a CPC firmware bug. - ../scripts/remote-radio/hci_pty_proxy.py:/opt/cpc-proxy/hci_pty_proxy.py:ro + # Validation/diagnostic script for the full CPC + BLE chain. + - ../scripts/remote-radio/cpc_validate.sh:/opt/cpc-proxy/cpc_validate.sh:ro # Bind-mount the entrypoint for development iteration without # rebuilding the Docker image. - ./otbr-radio-entrypoint.sh:/entrypoint.sh:ro diff --git a/docker/otbr-radio-entrypoint.sh b/docker/otbr-radio-entrypoint.sh index f66fc758..ff69e5e8 100755 --- a/docker/otbr-radio-entrypoint.sh +++ b/docker/otbr-radio-entrypoint.sh @@ -280,21 +280,28 @@ bt_host_cpc_hci_bridge & BT_BRIDGE_PID=$! # Wait for the pts_hci symlink that bt_host_cpc_hci_bridge creates once ready. -BT_BRIDGE_WAIT_MAX=15 +# Remote CPC (over TCP proxy) may take longer to negotiate the endpoint. +BT_BRIDGE_WAIT_MAX=30 btBridgeWaitCount=0 while [ ! -L "${BT_BRIDGE_DIR}/pts_hci" ]; do if ! kill -0 ${BT_BRIDGE_PID} 2>/dev/null; then - echo "[otbr-radio] ERROR: bt_host_cpc_hci_bridge exited unexpectedly." >&2 - exit 1 + echo "[otbr-radio] WARNING: bt_host_cpc_hci_bridge exited during startup — BLE monitor will retry." >&2 + break fi if [ ${btBridgeWaitCount} -ge ${BT_BRIDGE_WAIT_MAX} ]; then - echo "[otbr-radio] ERROR: bt_host_cpc_hci_bridge did not create pts_hci after ${BT_BRIDGE_WAIT_MAX}s." >&2 - exit 1 + echo "[otbr-radio] WARNING: bt_host_cpc_hci_bridge did not create pts_hci after ${BT_BRIDGE_WAIT_MAX}s — BLE monitor will retry." >&2 + kill ${BT_BRIDGE_PID} 2>/dev/null || true + break fi sleep 1 btBridgeWaitCount=$((btBridgeWaitCount + 1)) done + +# Only proceed with the proxy if the bridge actually created pts_hci. +# If the bridge failed during startup, the BLE monitor will retry later. +if [ -L "${BT_BRIDGE_DIR}/pts_hci" ]; then + PTS_DEVICE=$(readlink -f "${BT_BRIDGE_DIR}/pts_hci") echo "[otbr-radio] bt_host_cpc_hci_bridge ready (PID ${BT_BRIDGE_PID}, pts: ${PTS_DEVICE}, waited ${btBridgeWaitCount}s)." @@ -315,7 +322,7 @@ HCI_PROXY_SCRIPT="/opt/cpc-proxy/hci_pty_proxy.py" if [ -f "${HCI_PROXY_SCRIPT}" ]; then echo "[otbr-radio] Starting HCI PTY proxy (strip Extended Advertising)..." - python3 "${HCI_PROXY_SCRIPT}" "${PTS_DEVICE}" "${BT_BRIDGE_DIR}/pts_hci" & + python3 -u "${HCI_PROXY_SCRIPT}" "${PTS_DEVICE}" "${BT_BRIDGE_DIR}/pts_hci" & HCI_PROXY_PID=$! sleep 1 @@ -330,6 +337,8 @@ else echo "[otbr-radio] HCI PTY proxy not found — skipping (Extended BLE commands may fail)." fi +fi # end: bridge created pts_hci successfully + ############################################################################### # 6. Attach the virtual HCI device and start bluetoothd # @@ -341,12 +350,16 @@ fi # host-side bluetoothd that may be running on the host system bus. # # The host machine may have its own built-in Bluetooth adapter (e.g. hci0). -# btattach creates a new HCI device for the radio (e.g. hci1). We detect +# btattach creates a new HCI device for the CPC radio (e.g. hci1). We detect # its index and write it to the shared D-Bus volume so Barton can read it # at runtime to configure the Matter SDK's BLE adapter selection. # -# A background monitor watches btattach and restarts the BLE stack if it -# exits (e.g. after a USB-IP disconnect/reconnect cycle). +# To prevent Matter/bluetoothd from accidentally using the host's built-in +# adapter, we explicitly power down every non-CPC HCI device in the host +# network namespace after btattach creates the CPC device. +# +# A background monitor watches the full BLE chain (bridge, proxy, btattach) +# and restarts it if any component dies. ############################################################################### HOST_NETNS="/run/host-netns" @@ -395,27 +408,14 @@ ble_attach() { pkill -x bluetoothd 2>/dev/null || true sleep 0.5 - # Fix the Silicon Labs CPC BLE controller initialization issue. - # - # Problem: the kernel's initial HCI probe (triggered by btattach) - # sends commands that the CPC firmware responds to with malformed - # responses, leaving the controller in a state where LE scanning - # returns "Command Disallowed" (0x0C). - # - # Solution: cycle the adapter down→up to clear the kernel's stale - # HCI state, then send a raw HCI Reset to fix the controller. - # After that, the adapter is in a clean state and bluetoothd can - # start without conflicts. Starting bluetoothd AFTER the reset - # is critical — if bluetoothd runs when the HCI Reset is sent, - # bluetoothd's internal state becomes inconsistent (InProgress - # errors on StartDiscovery). - echo "[otbr-radio] Resetting ${radioHci} (down/up/HCI Reset)..." - nsenter --net="${HOST_NETNS}" hciconfig "${radioHci}" down 2>/dev/null || true - sleep 0.5 - nsenter --net="${HOST_NETNS}" hciconfig "${radioHci}" up 2>/dev/null || true - sleep 0.5 - nsenter --net="${HOST_NETNS}" hcitool -i "${radioHci}" cmd 0x03 0x0003 >/dev/null 2>&1 || true - sleep 0.5 + # Log any non-CPC adapters present. We do NOT disable them + # (that would be disruptive to the host). Instead we rely on + # ble_adapter_id to steer Matter/bluetoothd to the right one. + for hostHci in $(ls /sys/class/bluetooth/ 2>/dev/null); do + if [ "${hostHci}" != "${radioHci}" ]; then + echo "[otbr-radio] NOTE: host adapter ${hostHci} also present (not CPC bridge)." + fi + done echo "[otbr-radio] Starting bluetoothd (host netns, private D-Bus)..." nsenter --net="${HOST_NETNS}" bluetoothd & @@ -435,41 +435,215 @@ ble_attach() { return 1 } -# ble_monitor — background loop that restarts btattach (and bluetoothd) -# whenever btattach exits. This handles USB-IP disconnect/reconnect -# cycles where cpcd and bt_host_cpc_hci_bridge survive but btattach -# loses the HCI device and exits. +# ble_restart_bridge — restart the entire BLE chain from the CPC bridge +# onwards (bridge → HCI proxy → btattach → bluetoothd). Called by the +# monitor when bt_host_cpc_hci_bridge dies. +ble_restart_bridge() { + echo "[otbr-radio] Restarting full BLE chain (bridge → proxy → btattach)..." + + # Tear down everything downstream and reap zombies. + [ -n "${BTATTACH_PID:-}" ] && kill ${BTATTACH_PID} 2>/dev/null || true + [ -n "${HCI_PROXY_PID:-}" ] && kill ${HCI_PROXY_PID} 2>/dev/null || true + pkill -x bluetoothd 2>/dev/null || true + wait ${BT_BRIDGE_PID} 2>/dev/null || true + [ -n "${BTATTACH_PID:-}" ] && wait ${BTATTACH_PID} 2>/dev/null || true + [ -n "${HCI_PROXY_PID:-}" ] && wait ${HCI_PROXY_PID} 2>/dev/null || true + sleep 1 + + # Bring down any stale HCI devices that were created by previous + # btattach instances. This prevents hci index accumulation + # (hci1, hci2, hci3...) across restarts. + local adapterFile="${DBUS_DIR}/ble_adapter_id" + if [ -f "$adapterFile" ]; then + local oldIdx + oldIdx=$(cat "$adapterFile" 2>/dev/null) + if [ -n "$oldIdx" ]; then + nsenter --net="${HOST_NETNS}" hciconfig "hci${oldIdx}" down 2>/dev/null || true + fi + fi + + # Start a fresh bridge. The CPC endpoint (ep14) may take time to be + # released by cpcd after the previous session ended. Retry up to + # BT_BRIDGE_RETRY_MAX times with a short delay to ride out transient + # EAGAIN / "Resource temporarily unavailable" failures. + local BT_BRIDGE_RETRY_MAX=5 + local bridgeAttempt=0 + + while [ ${bridgeAttempt} -lt ${BT_BRIDGE_RETRY_MAX} ]; do + rm -f "${BT_BRIDGE_DIR}/pts_hci" + cd "${BT_BRIDGE_DIR}" + bt_host_cpc_hci_bridge & + BT_BRIDGE_PID=$! + + local waited=0 + + while [ ! -L "${BT_BRIDGE_DIR}/pts_hci" ]; do + + if ! kill -0 ${BT_BRIDGE_PID} 2>/dev/null; then + break + fi + + if [ ${waited} -ge ${BT_BRIDGE_WAIT_MAX} ]; then + kill ${BT_BRIDGE_PID} 2>/dev/null || true + break + fi + + sleep 1 + waited=$((waited + 1)) + done + + if [ -L "${BT_BRIDGE_DIR}/pts_hci" ]; then + break # Bridge started successfully. + fi + + # Bridge failed — wait for zombie and retry. + wait ${BT_BRIDGE_PID} 2>/dev/null || true + bridgeAttempt=$((bridgeAttempt + 1)) + + if [ ${bridgeAttempt} -lt ${BT_BRIDGE_RETRY_MAX} ]; then + local retryDelay=$((bridgeAttempt * 3)) + echo "[otbr-radio] Bridge attempt ${bridgeAttempt}/${BT_BRIDGE_RETRY_MAX} failed (EAGAIN?); retrying in ${retryDelay}s..." >&2 + sleep "${retryDelay}" + fi + done + + if [ ! -L "${BT_BRIDGE_DIR}/pts_hci" ]; then + echo "[otbr-radio] bt_host_cpc_hci_bridge failed after ${BT_BRIDGE_RETRY_MAX} attempts." >&2 + return 1 + fi + + PTS_DEVICE=$(readlink -f "${BT_BRIDGE_DIR}/pts_hci") + echo "[otbr-radio] Bridge restarted (PID ${BT_BRIDGE_PID}, pts: ${PTS_DEVICE})." + + # Restart the HCI PTY proxy if the script is available. + if [ -f "${HCI_PROXY_SCRIPT}" ]; then + python3 -u "${HCI_PROXY_SCRIPT}" "${PTS_DEVICE}" "${BT_BRIDGE_DIR}/pts_hci" & + HCI_PROXY_PID=$! + sleep 1 + + if kill -0 ${HCI_PROXY_PID} 2>/dev/null; then + PTS_DEVICE=$(readlink -f "${BT_BRIDGE_DIR}/pts_hci") + echo "[otbr-radio] HCI proxy restarted (PID ${HCI_PROXY_PID}, pts: ${PTS_DEVICE})." + else + echo "[otbr-radio] WARNING: HCI proxy exited during restart." >&2 + fi + fi + + return 0 +} + +# is_process_alive — check if a process is alive and not a zombie. +# kill -0 returns true for zombie processes, which misleads the monitor +# into thinking a dead bridge is still running. +is_process_alive() { + local pid=$1 + kill -0 "$pid" 2>/dev/null || return 1 + local state + state=$(awk '/^State:/ {print $2}' /proc/"$pid"/status 2>/dev/null) + [[ "$state" != "Z" ]] +} + +# hci_transport_healthy — verify the HCI transport is responsive by +# sending a Read BD ADDR command (0x04|0x0009) and checking for a +# valid response. Returns non-zero if the adapter doesn't respond +# within 5 seconds (dead transport or hung CPC endpoint). +# +# NOTE: The Silicon Labs CPC BLE firmware does NOT support some common +# HCI commands like Read Local Name (0x03|0x0014). Using those would +# cause false "unhealthy" results. Read BD ADDR is always supported. +hci_transport_healthy() { + local adapterFile="${DBUS_DIR}/ble_adapter_id" + [ -f "$adapterFile" ] || return 1 + local idx + idx=$(cat "$adapterFile" 2>/dev/null) + [ -n "$idx" ] || return 1 + # Timeout protects against hung transports. + timeout 5 nsenter --net="${HOST_NETNS}" \ + hcitool -i "hci${idx}" cmd 0x04 0x0009 >/dev/null 2>&1 +} + +# ble_monitor — background loop that watches the full BLE chain +# (bridge, HCI proxy, btattach) and restarts from the point of failure. +# If the bridge dies, the entire chain is restarted. If only btattach +# dies (e.g. USB-IP reconnect), only the attach step is retried. +# Additionally performs periodic HCI transport health checks to catch +# cases where the bridge process is alive but the CPC endpoint died. ble_monitor() { local backoff=2 + local healthCheckInterval=0 + local healthFailCount=0 + # Grace period — skip health checks for the first 30 seconds after + # startup or a successful restart to let the adapter stabilise. + local graceUntil=$((SECONDS + 30)) while true; do + local needRestart=false + local bridgeDied=false + + if ! is_process_alive ${BT_BRIDGE_PID}; then + needRestart=true + bridgeDied=true + elif [ -n "${HCI_PROXY_PID:-}" ] && ! is_process_alive ${HCI_PROXY_PID}; then + # The proxy owns the PTY that btattach is connected to. + # If the proxy dies, the PTY becomes invalid — full restart. + echo "[otbr-radio] HCI PTY proxy (PID ${HCI_PROXY_PID}) died." >&2 + needRestart=true + bridgeDied=true + kill ${BT_BRIDGE_PID} 2>/dev/null || true + elif [ -n "${BTATTACH_PID:-}" ] && ! is_process_alive ${BTATTACH_PID}; then + needRestart=true + elif [ ${SECONDS} -ge ${graceUntil} ] && [ ${healthCheckInterval} -ge 5 ]; then + # Every ~10s (5 iterations × 2s sleep), verify the HCI + # transport is actually responsive. This catches dead CPC + # endpoints where the bridge PID is still alive. + # Require 3 consecutive failures to avoid false positives + # from transient slowness. + healthCheckInterval=0 + + if ! hci_transport_healthy; then + healthFailCount=$((healthFailCount + 1)) + + if [ ${healthFailCount} -ge 3 ]; then + echo "[otbr-radio] HCI transport unresponsive (${healthFailCount} consecutive failures) — restarting BLE chain." >&2 + needRestart=true + bridgeDied=true + healthFailCount=0 + # Kill the stale bridge so ble_restart_bridge can start fresh. + kill ${BT_BRIDGE_PID} 2>/dev/null || true + else + echo "[otbr-radio] HCI health check failed (${healthFailCount}/3)." >&2 + fi + else + healthFailCount=0 + fi + else + healthCheckInterval=$((healthCheckInterval + 1)) + fi - if ! kill -0 ${BTATTACH_PID} 2>/dev/null; then - echo "[otbr-radio] btattach (PID ${BTATTACH_PID}) exited — restarting BLE stack..." >&2 + if ${needRestart}; then + healthCheckInterval=0 + healthFailCount=0 - # If bt_host_cpc_hci_bridge also died, wait for it to come back. - if ! kill -0 ${BT_BRIDGE_PID} 2>/dev/null; then - echo "[otbr-radio] bt_host_cpc_hci_bridge also exited; waiting for it to restart..." >&2 - sleep "${backoff}" - continue - fi + if ${bridgeDied}; then + echo "[otbr-radio] bt_host_cpc_hci_bridge (PID ${BT_BRIDGE_PID}) died." >&2 - # pts_hci must still be valid. - if [ ! -L "${BT_BRIDGE_DIR}/pts_hci" ]; then - echo "[otbr-radio] pts_hci symlink missing; waiting..." >&2 - sleep "${backoff}" - continue + if ! ble_restart_bridge; then + backoff=$((backoff < 30 ? backoff * 2 : 30)) + echo "[otbr-radio] Bridge restart failed; retrying in ${backoff}s..." >&2 + sleep "${backoff}" + continue + fi + else + echo "[otbr-radio] btattach (PID ${BTATTACH_PID}) died — restarting BLE attach..." >&2 fi if ble_attach; then echo "[otbr-radio] BLE stack restarted successfully." backoff=2 + graceUntil=$((SECONDS + 30)) else - echo "[otbr-radio] BLE stack restart failed; retrying in ${backoff}s..." >&2 - - if [ ${backoff} -lt 30 ]; then - backoff=$((backoff * 2)) - fi + backoff=$((backoff < 30 ? backoff * 2 : 30)) + echo "[otbr-radio] BLE restart failed; retrying in ${backoff}s..." >&2 fi fi @@ -482,30 +656,63 @@ if [ ! -e "${HOST_NETNS}" ]; then echo "[otbr-radio] Bluetooth support will not be available." >&2 echo "[otbr-radio] Add '- /proc/1/ns/net:/run/host-netns:ro' to volumes." >&2 else - BTATTACH_PID=0 + BTATTACH_PID= + HCI_PROXY_PID=${HCI_PROXY_PID:-} + + if [ -L "${BT_BRIDGE_DIR}/pts_hci" ]; then - if ble_attach; then - echo "[otbr-radio] BLE stack initialized." + if ble_attach; then + echo "[otbr-radio] BLE stack initialized." + else + echo "[otbr-radio] WARNING: Initial BLE attach failed; monitor will retry." >&2 + fi else - echo "[otbr-radio] WARNING: Initial BLE attach failed; monitor will retry." >&2 + echo "[otbr-radio] WARNING: Bridge did not create pts_hci during startup; monitor will retry." >&2 fi - # Start background monitor to handle USB-IP reconnects. + # Start background monitor to handle bridge/btattach failures. ble_monitor & BLE_MONITOR_PID=$! echo "[otbr-radio] BLE monitor started (PID ${BLE_MONITOR_PID})." fi ############################################################################### -# 7. Start otbr-agent +# 7. Start otbr-agent with auto-restart # # Uses spinel+cpc:// instead of the simulated spinel+hdlc+forkpty:// URI # that is used by scripts/start-simulated-otbr.sh. +# +# otbr-agent runs in the foreground inside a restart loop so the container +# survives transient CPC failures (e.g. cpcd restart, firmware EAGAIN). +# The loop backs off exponentially up to 30 seconds between retries. ############################################################################### echo "[otbr-radio] Starting otbr-agent (backbone: ${BACKBONE_IF})..." -exec otbr-agent \ - -I wpan0 \ - -B "${BACKBONE_IF}" \ - -d 7 \ - -v \ - "spinel+cpc://${CPC_INSTANCE}?iid=1&iid-list=0" + +otbr_backoff=2 + +while true; do + otbr_start=$(date +%s) + otbr-agent \ + -I wpan0 \ + -B "${BACKBONE_IF}" \ + -d 7 \ + -v \ + "spinel+cpc://${CPC_INSTANCE}?iid=1&iid-list=0" & + OTBR_PID=$! + set +e + wait ${OTBR_PID} 2>/dev/null + otbr_exit=$? + set -e + + # Reset backoff if otbr-agent ran for at least 60 seconds (not a + # crash loop). + otbr_elapsed=$(( $(date +%s) - otbr_start )) + + if [ "${otbr_elapsed}" -ge 60 ]; then + otbr_backoff=2 + fi + + echo "[otbr-radio] otbr-agent exited (code ${otbr_exit}) after ${otbr_elapsed}s; restarting in ${otbr_backoff}s..." >&2 + sleep "${otbr_backoff}" + otbr_backoff=$((otbr_backoff < 30 ? otbr_backoff * 2 : 30)) +done diff --git a/docker/setupDockerEnv.sh b/docker/setupDockerEnv.sh index 513a4953..b801031c 100755 --- a/docker/setupDockerEnv.sh +++ b/docker/setupDockerEnv.sh @@ -80,10 +80,16 @@ IMAGE_TAG=$HIGHEST_BUILDER_TAG BUILDER_TAG_CHANGED=false existingRadioDevice="" existingBackboneIf="" +existingCpcRemoteHost="" +existingCpcRemotePort="" +existingCpcInstance="" if [ -f "$OUTFILE" ]; then existingRadioDevice=$(grep '^RADIO_DEVICE=' "$OUTFILE" | sed 's/^RADIO_DEVICE=//' || true) existingBackboneIf=$(grep '^BACKBONE_IF=' "$OUTFILE" | sed 's/^BACKBONE_IF=//' || true) + existingCpcRemoteHost=$(grep '^CPC_REMOTE_HOST=' "$OUTFILE" | sed 's/^CPC_REMOTE_HOST=//' || true) + existingCpcRemotePort=$(grep '^CPC_REMOTE_PORT=' "$OUTFILE" | sed 's/^CPC_REMOTE_PORT=//' || true) + existingCpcInstance=$(grep '^CPC_INSTANCE=' "$OUTFILE" | sed 's/^CPC_INSTANCE=//' || true) CURRENT_BUILDER_TAG=$(grep "CURRENT_BUILDER_TAG=" "$OUTFILE" | sed 's/CURRENT_BUILDER_TAG=//') @@ -205,6 +211,16 @@ echo "LIB_BARTON_SHARED_PATH=/usr/local/lib" >> $OUTFILE # by exporting BACKBONE_IF before running setupDockerEnv.sh or dockerw, # e.g.: export BACKBONE_IF=enp6s0 # +# CPC_REMOTE_HOST: when set, connect to a remote cpcd via the CPC socket proxy. +# - If already present in docker/.env, preserve that value unless overridden +# by exporting CPC_REMOTE_HOST before running setupDockerEnv.sh or dockerw. +# +# CPC_REMOTE_PORT: base port for the CPC socket proxy (default: 15000). +# - If already present in docker/.env, preserve that value unless overridden. +# +# CPC_INSTANCE: CPC daemon instance name (default: cpcd_0). +# - If already present in docker/.env, preserve that value unless overridden. +# # These variables are only consumed when compose.otbr-radio.yaml is included # in the compose stack (dockerw -T, or devcontainer override). # Auto-detect the default-route network interface for the Thread backbone. @@ -217,9 +233,15 @@ fi radioDeviceValue="${RADIO_DEVICE:-$existingRadioDevice}" backboneIfValue="${BACKBONE_IF:-${existingBackboneIf:-$detectedBackboneIf}}" +cpcRemoteHostValue="${CPC_REMOTE_HOST:-$existingCpcRemoteHost}" +cpcRemotePortValue="${CPC_REMOTE_PORT:-${existingCpcRemotePort:-15000}}" +cpcInstanceValue="${CPC_INSTANCE:-${existingCpcInstance:-cpcd_0}}" echo "RADIO_DEVICE=$radioDeviceValue" >> $OUTFILE echo "BACKBONE_IF=$backboneIfValue" >> $OUTFILE +echo "CPC_REMOTE_HOST=$cpcRemoteHostValue" >> $OUTFILE +echo "CPC_REMOTE_PORT=$cpcRemotePortValue" >> $OUTFILE +echo "CPC_INSTANCE=$cpcInstanceValue" >> $OUTFILE ############################################################################## # Ensure the container network exists diff --git a/scripts/remote-radio/cpc_remote_access.sh b/scripts/remote-radio/cpc_remote_access.sh index d1d7ae77..d46da702 100755 --- a/scripts/remote-radio/cpc_remote_access.sh +++ b/scripts/remote-radio/cpc_remote_access.sh @@ -135,7 +135,26 @@ start_server() { done } - # Proxy any endpoint sockets that already exist. + # Pre-start TCP listeners for well-known CPC endpoints that remote + # clients need. cpcd creates endpoint sockets on-demand when a client + # opens them, but the proxy TCP listener must be ready BEFORE the + # remote client tries to connect. The proxy server retries the local + # unix socket connection, giving cpcd time to create it after the + # remote client opens the endpoint via the control socket. + # + # Silicon Labs CPC v4.4.x well-known endpoints: + # 12 SL_CPC_ENDPOINT_15_4 (802.15.4 / Spinel / OpenThread) + # 14 SL_CPC_ENDPOINT_BLUETOOTH (Bluetooth RCP / HCI bridge) + WELL_KNOWN_ENDPOINTS="12 14" + echo "" + echo "Starting well-known endpoint proxies:" + + for ep in ${WELL_KNOWN_ENDPOINTS}; do + start_proxy "$socket_base/ep${ep}.cpcd.sock" "$((BASE_PORT + 100 + ep))" + start_proxy "$socket_base/ep${ep}.event.cpcd.sock" "$((BASE_PORT + 400 + ep))" + done + + # Proxy any other endpoint sockets that already exist. proxy_endpoint_sockets echo "" @@ -144,11 +163,40 @@ start_server() { echo "On the remote client, run:" echo " $0 client $INSTANCE $BASE_PORT" + # check_wellknown_proxies — restart pre-started endpoint proxies if dead. + # Unlike proxy_endpoint_sockets (which only checks existing socket files), + # this always checks the well-known endpoints regardless of whether the + # socket file exists on disk. + check_wellknown_proxies() { + for ep in ${WELL_KNOWN_ENDPOINTS}; do + + for suffix in ".cpcd.sock" ".event.cpcd.sock"; do + local filename="ep${ep}${suffix}" + [[ -n "${PROXIED_PIDS[$filename]+x}" ]] || continue + local pid="${PROXIED_PIDS[$filename]}" + + if ! kill -0 "$pid" 2>/dev/null; then + echo "Well-known proxy for $filename (PID $pid) died — restarting" + unset 'PROXIED_PIDS[$filename]' + local port + + if [[ "$suffix" == ".event.cpcd.sock" ]]; then + port=$((BASE_PORT + 400 + ep)) + else + port=$((BASE_PORT + 100 + ep)) + fi + start_proxy "$socket_base/$filename" "$port" + fi + done + done + } + # Poll for new endpoint sockets and restart dead proxies. while true; do sleep 2 proxy_endpoint_sockets check_core_proxies + check_wellknown_proxies done } diff --git a/scripts/remote-radio/cpc_socket_proxy.py b/scripts/remote-radio/cpc_socket_proxy.py index 77239b15..092d7427 100644 --- a/scripts/remote-radio/cpc_socket_proxy.py +++ b/scripts/remote-radio/cpc_socket_proxy.py @@ -169,53 +169,62 @@ def run_server(socket_path: str, port: int, bind_addr: str = '0.0.0.0'): """ Server mode: accept TCP clients and relay to the local CPC Unix socket. - CPC only allows one client per endpoint socket, so when a new TCP client - connects we kill the previous session first. This handles client-side - container restarts cleanly — the new client evicts the stale session. + Multiple clients may connect concurrently (e.g. both the HCI bridge and + otbr-agent need their own ctrl session). Each TCP client gets its own + independent Unix socket connection to cpcd and a dedicated relay pair. """ + name = os.path.basename(socket_path) print(f"[{name}] Server: TCP :{port} <-> {socket_path}", flush=True) tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) tcp_server.bind((bind_addr, port)) - tcp_server.listen(1) - - # Mutable container so the accept loop can evict a stale session. - active = {'unix': None, 'tcp': None, 'thread': None} - active_lock = threading.Lock() + tcp_server.listen(5) while True: tcp_client, addr = tcp_server.accept() enable_tcp_keepalive(tcp_client) print(f"[{name}] TCP connection from {addr}", flush=True) - # Evict any stale session — close its sockets so relay threads exit. - with active_lock: + # cpcd creates endpoint sockets on-demand when a client opens + # them via the control socket. In the remote-access scenario the + # open-endpoint command arrives over the proxied ctrl socket, so + # the endpoint socket may not exist yet when the TCP client for + # that endpoint connects moments later. Retry with backoff to + # give cpcd time to create the socket (the firmware can take + # several seconds to make an endpoint available after a reset). + unix_sock = None + max_attempts = 20 + retry_delay = 0.5 - if active['thread'] and active['thread'].is_alive(): - print(f"[{name}] Evicting stale session", flush=True) - close_sockets(active['unix'], active['tcp']) - active['thread'].join(timeout=5) + for attempt in range(max_attempts): - try: - unix_sock = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) - unix_sock.connect(socket_path) - except Exception as e: - print(f"[{name}] Unix connect failed: {e}", flush=True) + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) + s.connect(socket_path) + unix_sock = s + break + except Exception as e: + s.close() + + if attempt < max_attempts - 1: + time.sleep(retry_delay) + else: + print( + f"[{name}] Unix connect to {socket_path} " + f"failed after {attempt + 1} attempts: {e}", + flush=True, + ) + + if unix_sock is None: tcp_client.close() continue def session(us=unix_sock, ts=tcp_client, n=name): run_relay_pair(us, ts, n) - t = threading.Thread(target=session, daemon=True) - t.start() - - with active_lock: - active['unix'] = unix_sock - active['tcp'] = tcp_client - active['thread'] = t + threading.Thread(target=session, daemon=True).start() def run_client(socket_path: str, host: str, port: int): diff --git a/scripts/remote-radio/cpc_validate.sh b/scripts/remote-radio/cpc_validate.sh new file mode 100755 index 00000000..21505ade --- /dev/null +++ b/scripts/remote-radio/cpc_validate.sh @@ -0,0 +1,885 @@ +#!/bin/bash +# ------------------------------ tabstop = 4 ---------------------------------- +# +# CPC Remote Radio Validation Script +# +# Performs a detailed check of all requirements for the CPC remote radio +# chain to function correctly. Run this inside the Barton or otbr-radio +# container to diagnose connectivity, BLE chain, and runtime issues. +# +# Usage: +# ./cpc_validate.sh # Run all checks +# ./cpc_validate.sh --fix # Attempt to fix common issues +# ./cpc_validate.sh --json # Output results as JSON (for automation) +# +# Exit codes: +# 0 All checks passed +# 1 One or more checks failed +# 2 Script error +# +# ------------------------------ tabstop = 4 ---------------------------------- + +set -euo pipefail + +############################################################################### +# Auto-detect container and re-exec if needed +# +# This script must run inside the otbr-radio container. If we detect we're +# in the Barton devcontainer (or elsewhere), find the otbr-radio container +# and re-exec there automatically. +############################################################################### +if [ ! -f /entrypoint.sh ] || ! grep -q "otbr-radio" /entrypoint.sh 2>/dev/null; then + # Not inside the otbr-radio container. Try to find it and re-exec. + # + # Multiple users may share the same Docker host. Each user's containers + # belong to a distinct Compose project whose name includes the username. + # We scope the search to our own project to avoid matching another user's + # otbr-radio container. + OTBR_CONTAINER="" + + # Determine docker command (with or without sudo). + DOCKER_CMD="" + if command -v docker >/dev/null 2>&1 && docker ps >/dev/null 2>&1; then + DOCKER_CMD="docker" + elif command -v sudo >/dev/null 2>&1 && sudo docker ps >/dev/null 2>&1; then + DOCKER_CMD="sudo docker" + fi + + if [ -n "$DOCKER_CMD" ]; then + # If we're inside a Compose-managed container, read the project label + # from our own container to scope the search. + COMPOSE_PROJECT=$($DOCKER_CMD inspect "$(hostname)" \ + --format '{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null) || true + + if [ -n "$COMPOSE_PROJECT" ]; then + OTBR_CONTAINER=$($DOCKER_CMD ps \ + --filter "label=com.docker.compose.project=${COMPOSE_PROJECT}" \ + --filter "name=otbr-radio" \ + --format '{{.Names}}' 2>/dev/null | head -1) || true + fi + + # Fallback: broad search if project detection failed (e.g. running + # from the host outside any container). + if [ -z "$OTBR_CONTAINER" ]; then + OTBR_CONTAINER=$($DOCKER_CMD ps \ + --filter "name=otbr-radio" \ + --format '{{.Names}}' 2>/dev/null | head -1) || true + fi + fi + + if [ -n "$OTBR_CONTAINER" ]; then + echo "Not in otbr-radio container — re-executing inside ${OTBR_CONTAINER}..." + echo "" + # Pass the script via stdin and forward arguments. + exec $DOCKER_CMD exec -i "$OTBR_CONTAINER" bash -s -- "$@" < "$0" + else + echo "ERROR: Not inside the otbr-radio container and could not find one running." >&2 + echo " Start the otbr-radio container first, or run this script inside it:" >&2 + echo " docker exec -i bash < $0" >&2 + exit 2 + fi +fi + +############################################################################### +# Configuration +############################################################################### +CPC_INSTANCE="${CPC_INSTANCE:-cpcd_0}" +CPC_REMOTE_HOST="${CPC_REMOTE_HOST:-}" +CPC_REMOTE_PORT="${CPC_REMOTE_PORT:-15000}" +CPC_SOCKET_DIR="${CPC_SOCKET_DIR:-/dev/shm}" +CPC_SOCKET_BASE="${CPC_SOCKET_DIR}/cpcd/${CPC_INSTANCE}" +DBUS_DIR="${DBUS_DIR:-/var/run/otbr-dbus}" +DBUS_SOCKET_PATH="${DBUS_SOCKET_PATH:-${DBUS_DIR}/system_bus_socket}" +HOST_NETNS="/run/host-netns" +BT_BRIDGE_DIR="/var/run/bt-hci-bridge" +HCI_PROXY_SCRIPT="/opt/cpc-proxy/hci_pty_proxy.py" +CPC_PROXY_SCRIPT="/opt/cpc-proxy/cpc_socket_proxy.py" + +FIX_MODE=false +JSON_MODE=false +for arg in "$@"; do + case "$arg" in + --fix) FIX_MODE=true ;; + --json) JSON_MODE=true ;; + esac +done + +############################################################################### +# Output helpers +############################################################################### +PASS_COUNT=0 +FAIL_COUNT=0 +WARN_COUNT=0 +SKIP_COUNT=0 +RESULTS=() + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + RESULTS+=("PASS|$1|$2") + if ! $JSON_MODE; then + printf " \033[32m✓ PASS\033[0m %-40s %s\n" "$1" "$2" + fi +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + RESULTS+=("FAIL|$1|$2") + if ! $JSON_MODE; then + printf " \033[31m✗ FAIL\033[0m %-40s %s\n" "$1" "$2" + fi +} + +warn() { + WARN_COUNT=$((WARN_COUNT + 1)) + RESULTS+=("WARN|$1|$2") + if ! $JSON_MODE; then + printf " \033[33m⚠ WARN\033[0m %-40s %s\n" "$1" "$2" + fi +} + +skip() { + SKIP_COUNT=$((SKIP_COUNT + 1)) + RESULTS+=("SKIP|$1|$2") + if ! $JSON_MODE; then + printf " \033[36m- SKIP\033[0m %-40s %s\n" "$1" "$2" + fi +} + +section() { + if ! $JSON_MODE; then + echo "" + printf "\033[1m[%s]\033[0m\n" "$1" + fi +} + +# is_process_alive — true if PID exists and is not a zombie. +is_process_alive() { + local pid=$1 + kill -0 "$pid" 2>/dev/null || return 1 + local state + state=$(awk '/^State:/ {print $2}' /proc/"$pid"/status 2>/dev/null) || return 1 + [[ "$state" != "Z" ]] +} + +############################################################################### +# Section 1: Container Environment +############################################################################### +check_container_env() { + section "Container Environment" + + # Privileged mode (needed for btattach, iptables, wpan0 creation) + if ip link add dummy_priv_test type dummy 2>/dev/null; then + ip link del dummy_priv_test 2>/dev/null + pass "Privileged mode" "Container is privileged" + else + fail "Privileged mode" "Container is NOT privileged (required for btattach/iptables)" + fi + + # Host network namespace mount + if [ -e "${HOST_NETNS}" ]; then + pass "Host netns mount" "${HOST_NETNS} exists" + # Verify it's actually a network namespace + if nsenter --net="${HOST_NETNS}" ip link show lo >/dev/null 2>&1; then + pass "Host netns accessible" "Can enter host network namespace" + else + fail "Host netns accessible" "Cannot nsenter into ${HOST_NETNS}" + fi + else + fail "Host netns mount" "${HOST_NETNS} not found — BLE will not work" + fi + + # Required bind-mounts + if [ -f "${CPC_PROXY_SCRIPT}" ]; then + pass "CPC proxy script" "${CPC_PROXY_SCRIPT} present" + else + fail "CPC proxy script" "${CPC_PROXY_SCRIPT} not found" + fi + + if [ -f "${HCI_PROXY_SCRIPT}" ]; then + pass "HCI PTY proxy script" "${HCI_PROXY_SCRIPT} present" + else + warn "HCI PTY proxy script" "${HCI_PROXY_SCRIPT} not found — Extended Advertising workaround unavailable" + fi + + # Required commands + for cmd in btattach bluetoothd hcitool hciconfig nsenter bt_host_cpc_hci_bridge python3; do + if command -v "$cmd" >/dev/null 2>&1; then + pass "Command: $cmd" "$(command -v "$cmd")" + else + fail "Command: $cmd" "Not found in PATH" + fi + done +} + +############################################################################### +# Section 2: D-Bus +############################################################################### +check_dbus() { + section "D-Bus" + + # D-Bus socket directory + if [ -d "${DBUS_DIR}" ]; then + pass "D-Bus directory" "${DBUS_DIR} exists" + else + fail "D-Bus directory" "${DBUS_DIR} not found" + return + fi + + # D-Bus socket + if [ -S "${DBUS_SOCKET_PATH}" ]; then + pass "D-Bus socket" "${DBUS_SOCKET_PATH} exists" + else + fail "D-Bus socket" "${DBUS_SOCKET_PATH} not found" + fi + + # D-Bus daemon process + local dbus_pid + dbus_pid=$(pgrep -x dbus-daemon 2>/dev/null | head -1) || true + if [ -n "$dbus_pid" ]; then + pass "D-Bus daemon" "PID $dbus_pid" + else + fail "D-Bus daemon" "dbus-daemon not running" + fi + + # Verify we can talk to D-Bus + if command -v dbus-send >/dev/null 2>&1; then + if DBUS_SYSTEM_BUS_ADDRESS="unix:path=${DBUS_SOCKET_PATH}" \ + dbus-send --system --dest=org.freedesktop.DBus --print-reply \ + /org/freedesktop/DBus org.freedesktop.DBus.ListNames >/dev/null 2>&1; then + pass "D-Bus connectivity" "Can list bus names" + else + fail "D-Bus connectivity" "Cannot communicate with D-Bus" + fi + else + skip "D-Bus connectivity" "dbus-send not available" + fi +} + +############################################################################### +# Section 3: CPC Connectivity +############################################################################### +check_cpc() { + section "CPC Connectivity" + + if [ -z "${CPC_REMOTE_HOST}" ]; then + skip "CPC remote mode" "CPC_REMOTE_HOST not set — local mode" + # Check for local cpcd + local cpcd_pid + cpcd_pid=$(pgrep -x cpcd 2>/dev/null | head -1) || true + if [ -n "$cpcd_pid" ]; then + pass "cpcd process" "PID $cpcd_pid" + else + fail "cpcd process" "cpcd not running" + fi + else + pass "CPC remote mode" "Remote host: ${CPC_REMOTE_HOST}:${CPC_REMOTE_PORT}" + + # TCP connectivity to remote host + if timeout 3 bash -c "echo >/dev/tcp/${CPC_REMOTE_HOST}/${CPC_REMOTE_PORT}" 2>/dev/null; then + pass "Remote ctrl port" "TCP ${CPC_REMOTE_HOST}:${CPC_REMOTE_PORT} reachable" + else + fail "Remote ctrl port" "Cannot connect to ${CPC_REMOTE_HOST}:${CPC_REMOTE_PORT}" + fi + + local reset_port=$((CPC_REMOTE_PORT + 1)) + if timeout 3 bash -c "echo >/dev/tcp/${CPC_REMOTE_HOST}/${reset_port}" 2>/dev/null; then + pass "Remote reset port" "TCP ${CPC_REMOTE_HOST}:${reset_port} reachable" + else + fail "Remote reset port" "Cannot connect to ${CPC_REMOTE_HOST}:${reset_port}" + fi + + # Check well-known endpoint ports + local ep14_port=$((CPC_REMOTE_PORT + 100 + 14)) + if timeout 3 bash -c "echo >/dev/tcp/${CPC_REMOTE_HOST}/${ep14_port}" 2>/dev/null; then + pass "Remote ep14 port (BLE)" "TCP ${CPC_REMOTE_HOST}:${ep14_port} reachable" + else + fail "Remote ep14 port (BLE)" "Cannot connect to ${CPC_REMOTE_HOST}:${ep14_port} — BLE bridge will fail" + fi + + local ep12_port=$((CPC_REMOTE_PORT + 100 + 12)) + if timeout 3 bash -c "echo >/dev/tcp/${CPC_REMOTE_HOST}/${ep12_port}" 2>/dev/null; then + pass "Remote ep12 port (Thread)" "TCP ${CPC_REMOTE_HOST}:${ep12_port} reachable" + else + fail "Remote ep12 port (Thread)" "Cannot connect to ${CPC_REMOTE_HOST}:${ep12_port} — otbr-agent will fail" + fi + fi + + # CPC socket directory + if [ -d "${CPC_SOCKET_BASE}" ]; then + pass "CPC socket directory" "${CPC_SOCKET_BASE} exists" + else + fail "CPC socket directory" "${CPC_SOCKET_BASE} not found" + return + fi + + # Individual sockets + for sock in ctrl.cpcd.sock reset.cpcd.sock; do + local sockpath="${CPC_SOCKET_BASE}/${sock}" + if [ -S "$sockpath" ]; then + pass "CPC socket: $sock" "Present" + else + fail "CPC socket: $sock" "Not found at ${sockpath}" + fi + done + + # Endpoint sockets (ep14 = BLE, ep12 = Thread/Spinel) + for ep in 14 12; do + local sockpath="${CPC_SOCKET_BASE}/ep${ep}.cpcd.sock" + if [ -S "$sockpath" ]; then + pass "CPC socket: ep${ep} data" "Present" + else + fail "CPC socket: ep${ep} data" "Not found — endpoint may be in EAGAIN state" + fi + + local eventsock="${CPC_SOCKET_BASE}/ep${ep}.event.cpcd.sock" + if [ -S "$eventsock" ]; then + pass "CPC socket: ep${ep} event" "Present" + else + warn "CPC socket: ep${ep} event" "Not found (events may not be available)" + fi + done + + # CPC proxy processes (remote mode) + if [ -n "${CPC_REMOTE_HOST}" ]; then + local proxy_count + proxy_count=$(pgrep -cf "cpc_socket_proxy.py client" 2>/dev/null) || proxy_count=0 + if [ "$proxy_count" -ge 6 ]; then + pass "CPC proxy processes" "${proxy_count} running (expected 6: ctrl, reset, ep12/ep14 data+event)" + elif [ "$proxy_count" -gt 0 ]; then + warn "CPC proxy processes" "Only ${proxy_count} running (expected 6)" + else + fail "CPC proxy processes" "No CPC proxy client processes running" + fi + + # Check for zombie proxies + local zombie_count=0 + for pid in $(pgrep -f "cpc_socket_proxy.py client" 2>/dev/null); do + local state + state=$(awk '/^State:/ {print $2}' /proc/"$pid"/status 2>/dev/null) || continue + if [[ "$state" == "Z" ]]; then + zombie_count=$((zombie_count + 1)) + fi + done + if [ "$zombie_count" -gt 0 ]; then + fail "CPC proxy zombies" "${zombie_count} zombie proxy process(es)" + else + pass "CPC proxy zombies" "None" + fi + fi +} + +############################################################################### +# Section 4: BLE Chain +############################################################################### +check_ble_chain() { + section "BLE Chain (bridge → pty_proxy → btattach → bluetoothd)" + + # bt_host_cpc_hci_bridge + # NOTE: pgrep -x can't match this — Linux /proc/PID/comm truncates to + # 15 chars ("bt_host_cpc_hci"). Use pgrep -f for the full command line. + local bridge_pid + bridge_pid=$(pgrep -f "bt_host_cpc_hci_bridge" 2>/dev/null | head -1) || true + if [ -n "$bridge_pid" ]; then + if is_process_alive "$bridge_pid"; then + pass "bt_host_cpc_hci_bridge" "PID $bridge_pid (alive)" + else + fail "bt_host_cpc_hci_bridge" "PID $bridge_pid (ZOMBIE)" + fi + else + fail "bt_host_cpc_hci_bridge" "Not running" + fi + + # PTY from bridge + if [ -L "${BT_BRIDGE_DIR}/pts_hci" ]; then + local pts_target + pts_target=$(readlink -f "${BT_BRIDGE_DIR}/pts_hci") + if [ -c "$pts_target" ]; then + pass "Bridge PTY (pts_hci)" "${pts_target}" + else + fail "Bridge PTY (pts_hci)" "Symlink exists but ${pts_target} is not a character device" + fi + else + fail "Bridge PTY (pts_hci)" "Symlink not found at ${BT_BRIDGE_DIR}/pts_hci" + fi + + # HCI PTY proxy + local proxy_pid + proxy_pid=$(pgrep -f "hci_pty_proxy.py" 2>/dev/null | head -1) || true + if [ -n "$proxy_pid" ]; then + if is_process_alive "$proxy_pid"; then + pass "HCI PTY proxy" "PID $proxy_pid (alive)" + else + fail "HCI PTY proxy" "PID $proxy_pid (ZOMBIE)" + fi + else + if [ -f "${HCI_PROXY_SCRIPT}" ]; then + fail "HCI PTY proxy" "Not running (script present but process missing)" + else + skip "HCI PTY proxy" "Script not installed — Extended Advertising workaround unavailable" + fi + fi + + # btattach + local btattach_pid + btattach_pid=$(pgrep -x btattach 2>/dev/null | head -1) || true + if [ -n "$btattach_pid" ]; then + if is_process_alive "$btattach_pid"; then + pass "btattach" "PID $btattach_pid (alive)" + else + fail "btattach" "PID $btattach_pid (ZOMBIE)" + fi + else + fail "btattach" "Not running — no HCI device will be created" + fi + + # bluetoothd + local bluetoothd_pid + bluetoothd_pid=$(pgrep -x bluetoothd 2>/dev/null | head -1) || true + if [ -n "$bluetoothd_pid" ]; then + if is_process_alive "$bluetoothd_pid"; then + pass "bluetoothd" "PID $bluetoothd_pid (alive)" + else + fail "bluetoothd" "PID $bluetoothd_pid (ZOMBIE)" + fi + else + fail "bluetoothd" "Not running — BLE operations will fail" + fi + + # BLE monitor — runs as a backgrounded bash function inside the + # entrypoint script, so it appears as a child /bin/bash process of PID 1. + # We detect it by looking for child bash processes whose /proc/PID/cmdline + # contains "/entrypoint.sh" (i.e. subshells forked from the entrypoint). + local monitor_found=false + for child_pid in $(pgrep -P 1 2>/dev/null); do + local child_cmd + child_cmd=$(cat /proc/"$child_pid"/cmdline 2>/dev/null | tr '\0' ' ') || continue + if echo "$child_cmd" | grep -q "entrypoint.sh"; then + pass "BLE monitor" "PID $child_pid (entrypoint subshell)" + monitor_found=true + break + fi + done + if ! $monitor_found; then + warn "BLE monitor" "No monitor subshell found — BLE chain may not auto-recover" + fi +} + +############################################################################### +# Section 5: HCI Adapter & Transport +############################################################################### +check_hci() { + section "HCI Adapter & Transport" + + if [ ! -e "${HOST_NETNS}" ]; then + skip "HCI checks" "Host netns not available" + return + fi + + # List all HCI adapters in host netns + local hci_list + hci_list=$(ls /sys/class/bluetooth/ 2>/dev/null) || true + if [ -z "$hci_list" ]; then + fail "HCI adapters" "No HCI adapters found in host netns" + return + fi + + # BLE adapter ID file + local adapter_file="${DBUS_DIR}/ble_adapter_id" + local expected_idx="" + if [ -f "$adapter_file" ]; then + expected_idx=$(cat "$adapter_file" 2>/dev/null) + if [ -n "$expected_idx" ]; then + pass "BLE adapter ID file" "Index ${expected_idx} (hci${expected_idx})" + else + fail "BLE adapter ID file" "File exists but is empty" + fi + else + fail "BLE adapter ID file" "${adapter_file} not found — Matter won't know which adapter to use" + fi + + for hci in $hci_list; do + local idx="${hci#hci}" + local bus + bus=$(cat "/sys/class/bluetooth/${hci}/device/subsystem" 2>/dev/null | xargs basename 2>/dev/null) || bus="unknown" + # Try to get the vendor from hciconfig or sysfs + local info="" + local is_cpc=false + + # The CPC bridge adapter uses UART bus type + if nsenter --net="${HOST_NETNS}" hciconfig "$hci" 2>/dev/null | grep -q "Bus: UART"; then + is_cpc=true + info="UART (CPC bridge)" + else + local usb_product + usb_product=$(cat "/sys/class/bluetooth/${hci}/device/../product" 2>/dev/null) || usb_product="" + info="USB${usb_product:+ ($usb_product)}" + fi + + local up_state + if nsenter --net="${HOST_NETNS}" hciconfig "$hci" 2>/dev/null | grep -q "UP RUNNING"; then + up_state="UP" + else + up_state="DOWN" + fi + + if [ "$idx" = "$expected_idx" ]; then + if $is_cpc; then + pass "HCI adapter: ${hci}" "${info} — state=${up_state} [SELECTED]" + else + warn "HCI adapter: ${hci}" "${info} — state=${up_state} [SELECTED but not CPC!]" + fi + else + if $is_cpc; then + warn "HCI adapter: ${hci}" "${info} — state=${up_state} (CPC adapter not selected?)" + else + # Non-CPC adapter that's not selected — just informational + if [ "$up_state" = "UP" ]; then + warn "HCI adapter: ${hci}" "${info} — state=${up_state} (host adapter — may confuse bluetoothd)" + else + pass "HCI adapter: ${hci}" "${info} — state=${up_state} (host adapter, powered down)" + fi + fi + fi + done + + # HCI transport health — use Read BD ADDR (0x04|0x0009). + # IMPORTANT: Do NOT use hciconfig name / Read Local Name (0x03|0x0014). + # The Silicon Labs CPC BLE firmware does NOT support it and returns + # "Unknown HCI Command", which hciconfig misreports as "I/O error". + # This was the root cause of false health-check failures that cascaded + # into bridge restarts and ep14 EAGAIN lockups. + if [ -n "$expected_idx" ]; then + local target_hci="hci${expected_idx}" + if nsenter --net="${HOST_NETNS}" hciconfig "${target_hci}" 2>/dev/null | grep -q "UP RUNNING"; then + if timeout 5 nsenter --net="${HOST_NETNS}" \ + hcitool -i "${target_hci}" cmd 0x04 0x0009 >/dev/null 2>&1; then + pass "HCI transport (Read BD ADDR)" "Responsive on ${target_hci}" + + # Extract the BD address from the response for display + local bd_addr + bd_addr=$(timeout 5 nsenter --net="${HOST_NETNS}" \ + hcitool -i "${target_hci}" cmd 0x04 0x0009 2>/dev/null \ + | grep "HCI Event" -A1 | tail -1 | awk '{ + # Response: status(1) + addr(6 bytes LE) + # Skip first 4 bytes (evt fields), addr starts at byte 5 + printf "%s:%s:%s:%s:%s:%s", $10, $9, $8, $7, $6, $5 + }') || bd_addr="" + if [ -n "$bd_addr" ]; then + pass "BLE BD Address" "$bd_addr" + fi + else + fail "HCI transport (Read BD ADDR)" "No response from ${target_hci} within 5s — transport dead" + fi + + # Verify Read Local Version works (basic command, widely supported) + if timeout 5 nsenter --net="${HOST_NETNS}" \ + hcitool -i "${target_hci}" cmd 0x04 0x0001 >/dev/null 2>&1; then + pass "HCI transport (Read Version)" "Responsive" + else + warn "HCI transport (Read Version)" "No response (unusual)" + fi + + # Explicitly check Read Local Name is unsupported (known CPC firmware limitation) + local name_result + name_result=$(timeout 3 nsenter --net="${HOST_NETNS}" \ + hciconfig "${target_hci}" name 2>&1) || true + if echo "$name_result" | grep -qi "error\|can't read"; then + pass "Read Local Name (known unsupported)" "Correctly rejected by firmware — do NOT use for health checks" + else + pass "Read Local Name" "Supported (unexpected for CPC firmware)" + fi + else + fail "HCI transport" "${target_hci} is not UP — btattach may have died" + fi + fi +} + +############################################################################### +# Section 6: BLE Scanning Capability +############################################################################### +check_ble_scan() { + section "BLE Scanning" + + local adapter_file="${DBUS_DIR}/ble_adapter_id" + if [ ! -f "$adapter_file" ]; then + skip "BLE scan test" "No adapter ID file — cannot determine adapter" + return + fi + + local idx + idx=$(cat "$adapter_file" 2>/dev/null) + if [ -z "$idx" ]; then + skip "BLE scan test" "Empty adapter ID" + return + fi + + local target_hci="hci${idx}" + + # Use bluetoothctl for scanning (via D-Bus → bluetoothd). + # + # IMPORTANT: Do NOT use raw hcitool lescan here. Raw HCI scanning + # bypasses bluetoothd and corrupts its internal state machine. When the + # Matter SDK later tries to start BLE discovery via D-Bus, bluetoothd + # rejects it with "org.bluez.Error.InProgress" because it doesn't know + # the raw scan stopped. This blocks commissioning entirely. + # + # bluetoothctl goes through D-Bus → bluetoothd, keeping state consistent. + # We must explicitly select the CPC adapter because bluetoothd may + # default to a host USB adapter (e.g. hci0). + local bd_addr + bd_addr=$(nsenter --net="${HOST_NETNS}" hciconfig "${target_hci}" 2>/dev/null \ + | awk '/BD Address:/{print $3}') || bd_addr="" + + if [ -z "$bd_addr" ]; then + fail "BLE LE scan" "Cannot determine BD address for ${target_hci}" + return + fi + + # Start scan, collect output for a few seconds, then stop cleanly. + # + # CRITICAL: Use a SINGLE bluetoothctl session for both scan on and + # scan off. Using two separate instances creates a D-Bus session + # mismatch — the second instance's "scan off" is ignored because + # bluetoothd tracks discovery per-client and the second client never + # started one. The first client's scan persists until bluetoothd's + # auto-cleanup fires (which may be delayed or incomplete), leaving + # the adapter in Discovering=yes and causing "InProgress" errors + # for the Matter SDK. + local btctl_output + btctl_output=$( + { + echo "select ${bd_addr}" + echo "scan on" + sleep 4 + echo "scan off" + sleep 1 + echo "quit" + } | DBUS_SYSTEM_BUS_ADDRESS="unix:path=${DBUS_SOCKET_PATH}" \ + timeout 10 nsenter --net="${HOST_NETNS}" \ + bluetoothctl --agent=NoInputNoOutput 2>&1 + ) || true + + if echo "$btctl_output" | grep -qi "NEW.*Device\|CHG.*RSSI"; then + local btctl_count + btctl_count=$(echo "$btctl_output" | grep -ciE "NEW.*Device") || btctl_count=0 + pass "BLE LE scan" "Found ${btctl_count} device(s) via D-Bus on ${target_hci}" + elif echo "$btctl_output" | grep -qi "SetDiscoveryFilter\|Discovery started\|scan on"; then + warn "BLE LE scan" "Scan started on ${target_hci} but no devices found in 6s window" + elif echo "$btctl_output" | grep -qi "InProgress"; then + fail "BLE LE scan" "bluetoothd reports scan InProgress — a stale scan may be stuck" + elif echo "$btctl_output" | grep -qi "not available\|not found"; then + fail "BLE LE scan" "Controller ${target_hci} (${bd_addr}) not available to bluetoothd" + else + fail "BLE LE scan" "Unexpected: $(echo "$btctl_output" | tail -3 | tr '\n' ' ')" + fi +} + +############################################################################### +# Section 7: otbr-agent +############################################################################### +check_otbr() { + section "otbr-agent (Thread Border Router)" + + local otbr_pid + otbr_pid=$(pgrep -x otbr-agent 2>/dev/null | head -1) || true + if [ -n "$otbr_pid" ]; then + if is_process_alive "$otbr_pid"; then + pass "otbr-agent process" "PID $otbr_pid (alive)" + + # Check how long it's been running (stability indicator) + local etime + etime=$(ps -o etime= -p "$otbr_pid" 2>/dev/null | tr -d ' ') || etime="" + if [ -n "$etime" ]; then + pass "otbr-agent uptime" "$etime" + fi + else + fail "otbr-agent process" "PID $otbr_pid (ZOMBIE)" + fi + else + fail "otbr-agent process" "Not running" + fi + + # Check wpan0 interface + if ip link show wpan0 >/dev/null 2>&1; then + local wpan_state + wpan_state=$(ip -br link show wpan0 2>/dev/null | awk '{print $2}') || wpan_state="unknown" + pass "wpan0 interface" "State: ${wpan_state}" + + # Check for IPv6 addresses on wpan0 (Thread mesh connectivity) + local v6_addrs + v6_addrs=$(ip -6 addr show dev wpan0 scope global 2>/dev/null | grep -c "inet6") || v6_addrs=0 + if [ "$v6_addrs" -gt 0 ]; then + pass "wpan0 IPv6 addresses" "${v6_addrs} global address(es)" + else + warn "wpan0 IPv6 addresses" "No global IPv6 — Thread network may not be formed" + fi + else + fail "wpan0 interface" "Not found — otbr-agent may not be running or CPC link is down" + fi + + # Check avahi-daemon (required by otbr-agent with OTBR_MDNS=avahi) + local avahi_pid + avahi_pid=$(pgrep -x avahi-daemon 2>/dev/null | head -1) || true + if [ -n "$avahi_pid" ]; then + pass "avahi-daemon" "PID $avahi_pid" + else + warn "avahi-daemon" "Not running (otbr-agent may fail with OTBR_MDNS=avahi)" + fi +} + +############################################################################### +# Section 8: Known Pitfalls +############################################################################### +check_known_pitfalls() { + section "Known Pitfalls & Lessons Learned" + + # Check for stale PID files or sockets from previous runs + if [ -S "${CPC_SOCKET_BASE}/ctrl.cpcd.sock" ]; then + # Verify the socket is actually connected (not stale) + local ctrl_established + ctrl_established=$(ss -x 2>/dev/null | grep -c "ctrl.cpcd.sock" 2>/dev/null) || ctrl_established=0 + if [ "$ctrl_established" -gt 0 ]; then + pass "ctrl.cpcd.sock liveness" "${ctrl_established} established connection(s)" + else + warn "ctrl.cpcd.sock liveness" "Socket file exists but no established connections — may be stale" + fi + fi + + # Check for ep14 EAGAIN state: if the bridge is repeatedly failing to open + # the endpoint, cpcd needs to be restarted on the server side. + local bridge_pid + bridge_pid=$(pgrep -f "bt_host_cpc_hci_bridge" 2>/dev/null | head -1) || true + if [ -z "$bridge_pid" ]; then + warn "ep14 EAGAIN check" "Bridge not running — if it keeps failing, restart cpcd on the server" + else + pass "ep14 EAGAIN check" "Bridge alive (PID $bridge_pid)" + fi + + # Check for multiple HCI adapters (can confuse bluetoothd default adapter selection) + local hci_count + hci_count=$(ls /sys/class/bluetooth/ 2>/dev/null | wc -w) || hci_count=0 + if [ "$hci_count" -gt 1 ]; then + local adapter_file="${DBUS_DIR}/ble_adapter_id" + if [ -f "$adapter_file" ] && [ -n "$(cat "$adapter_file" 2>/dev/null)" ]; then + warn "Multiple HCI adapters" "${hci_count} adapters present — ble_adapter_id is set, but bluetoothd may default to wrong one" + else + fail "Multiple HCI adapters" "${hci_count} adapters but no ble_adapter_id — Matter will likely use wrong adapter" + fi + elif [ "$hci_count" -eq 1 ]; then + pass "Single HCI adapter" "Only one adapter — no confusion risk" + fi + + # Check if there's a health check using the wrong HCI command + # (hciconfig name / Read Local Name is unsupported by CPC firmware) + if grep -rq "hciconfig.*name" /entrypoint.sh 2>/dev/null; then + fail "Health check command" "entrypoint.sh uses 'hciconfig name' — this ALWAYS fails on CPC firmware (use hcitool cmd 0x04 0x0009)" + else + pass "Health check command" "Not using unsupported 'hciconfig name'" + fi + + # Check if entrypoint has the dangerous HCI Reset sequence + if grep -q "hcitool.*cmd.*0x03.*0x0003" /entrypoint.sh 2>/dev/null; then + warn "HCI Reset in entrypoint" "Found HCI Reset command — this can kill the CPC transport" + else + pass "No HCI Reset in entrypoint" "Dangerous reset sequence not present" + fi + + # Check for the kill-0 bug (BTATTACH_PID=0 initialization) + if grep -q 'BTATTACH_PID=0' /entrypoint.sh 2>/dev/null; then + fail "BTATTACH_PID init" "Initialized to 0 — 'kill 0' will SIGTERM the entire process group!" + else + pass "BTATTACH_PID init" "Not initialized to 0 (safe)" + fi + + # Check for raw HCI scan commands (hcitool lescan) in scripts. + # These bypass bluetoothd and corrupt its state machine, causing + # "org.bluez.Error.InProgress" when the Matter SDK tries to scan. + if grep -rq "hcitool.*lescan" /entrypoint.sh 2>/dev/null; then + fail "Raw HCI scan in entrypoint" "hcitool lescan corrupts bluetoothd state — use bluetoothctl instead" + else + pass "No raw HCI scan in entrypoint" "Not using hcitool lescan (safe for bluetoothd)" + fi +} + +############################################################################### +# Summary +############################################################################### +print_summary() { + if $JSON_MODE; then + echo "{" + echo " \"pass\": $PASS_COUNT," + echo " \"fail\": $FAIL_COUNT," + echo " \"warn\": $WARN_COUNT," + echo " \"skip\": $SKIP_COUNT," + echo " \"results\": [" + local first=true + for r in "${RESULTS[@]}"; do + IFS='|' read -r status name detail <<< "$r" + if $first; then first=false; else echo ","; fi + printf ' {"status":"%s","check":"%s","detail":"%s"}' \ + "$status" "$name" "$(echo "$detail" | sed 's/"/\\"/g')" + done + echo "" + echo " ]" + echo "}" + else + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + printf " \033[32m%d passed\033[0m " "$PASS_COUNT" + if [ "$FAIL_COUNT" -gt 0 ]; then + printf "\033[31m%d failed\033[0m " "$FAIL_COUNT" + else + printf "0 failed " + fi + if [ "$WARN_COUNT" -gt 0 ]; then + printf "\033[33m%d warnings\033[0m " "$WARN_COUNT" + else + printf "0 warnings " + fi + printf "%d skipped\n" "$SKIP_COUNT" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + if [ "$FAIL_COUNT" -gt 0 ]; then + echo "" + printf "\033[31mFailed checks:\033[0m\n" + for r in "${RESULTS[@]}"; do + IFS='|' read -r status name detail <<< "$r" + if [ "$status" = "FAIL" ]; then + printf " ✗ %-40s %s\n" "$name" "$detail" + fi + done + fi + fi +} + +############################################################################### +# Main +############################################################################### +if ! $JSON_MODE; then + echo "╔══════════════════════════════════════════════════════════════╗" + echo "║ CPC Remote Radio Validation ║" + echo "╠══════════════════════════════════════════════════════════════╣" + printf "║ Instance: %-49s║\n" "${CPC_INSTANCE}" + if [ -n "${CPC_REMOTE_HOST}" ]; then + printf "║ Remote: %-49s║\n" "${CPC_REMOTE_HOST}:${CPC_REMOTE_PORT}" + else + printf "║ Mode: %-49s║\n" "Local radio" + fi + printf "║ Date: %-49s║\n" "$(date -Iseconds)" + echo "╚══════════════════════════════════════════════════════════════╝" +fi + +check_container_env +check_dbus +check_cpc +check_ble_chain +check_hci +check_ble_scan +check_otbr +check_known_pitfalls +print_summary + +if [ "$FAIL_COUNT" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/scripts/remote-radio/hci_pty_proxy.py b/scripts/remote-radio/hci_pty_proxy.py index d758e848..0353663e 100644 --- a/scripts/remote-radio/hci_pty_proxy.py +++ b/scripts/remote-radio/hci_pty_proxy.py @@ -23,12 +23,28 @@ # ------------------------------ tabstop = 4 ---------------------------------- """ -HCI UART (H4) PTY proxy that strips the LE Extended Advertising feature -bit from the LE Read Local Supported Features response. - -This forces the Linux kernel to use legacy BLE scan and connection -commands (0x200b/0x200c/0x200d) instead of the extended versions -(0x2041/0x2042/0x2039). +HCI UART (H4) PTY proxy that works around Silicon Labs CPC BLE firmware +limitations by filtering and synthesizing HCI responses. + +Workaround 1 — Extended Advertising feature bit stripping +---------------------------------------------------------- +The CPC firmware reports Extended Advertising support in its LE +feature set, but responds to Extended commands with legacy opcodes. +We strip the bit so the kernel never uses Extended commands. + +Workaround 2 — Read Local Name interception +--------------------------------------------- +The CPC firmware does NOT support HCI Read Local Name (0x0C14). It +returns a 1-byte "Unknown HCI Command" error instead of the expected +249-byte response. The kernel logs "unexpected cc 0x0c14 length: 1 +< 249" and mishandles the command completion — this corrupts the HCI +command queue, causes background scan-stop timeouts (-ETIMEDOUT), +and leaves bluetoothd in a permanently stuck state where +StartDiscovery() returns "InProgress" even though no scan is active. + +We intercept outgoing Read Local Name commands and return a synthetic +success response with a placeholder name, so the unsupported command +never reaches the CPC firmware. Background ---------- @@ -76,8 +92,120 @@ _LE_FEAT_BYTE = 1 _LE_EXT_ADV_BIT = 0x10 +# --- Read Local Supported Commands response stripping --- +# +# On kernel 6.8+, use_ext_scan() / use_ext_adv() in hci_sync.c check +# hdev->commands[] (the Supported Commands bitmap from opcode 0x1002) +# instead of the LE features. We must also clear the relevant command +# bits so the kernel never issues Extended Advertising / Scan commands. +# +# We match the Command Complete opcode bytes (length-agnostic) rather +# than a fixed-length prefix, because the firmware may return a +# different parameter length than the spec's 68 bytes. +# +# Pattern inside the event (after H4 0x04 + event code 0x0E + plen): +# 01 — Num_HCI_Command_Packets +# 02 10 — opcode 0x1002 (Read Local Supported Commands) +# 00 — status Success +_CMDS_CC_OPCODE = bytes([0x01, 0x02, 0x10, 0x00]) # after plen byte + +# Byte offsets (0-based) within the 64-byte Supported_Commands bitmap +# to clear entirely. +# +# Byte 36 — LE Set Advertising Set Random Address, +# LE Set Extended Advertising Parameters / Data / Enable, +# LE Read Max Adv Data Length, etc. +# Byte 37 — LE Clear Advertising Sets, LE Set Periodic Adv Params / +# Data / Enable, LE Set Extended Scan Parameters / Enable, +# LE Extended Create Connection, LE Periodic Adv Create Sync +_EXT_ADV_CMD_BYTES = (36, 37) + # HCI Command Complete event for LE Read Local Supported Features # with success status. + +# --- Read Local Name interception --- +# +# HCI Read Local Name command (H4 format): +# 01 — H4 command packet indicator +# 14 0c — opcode 0x0C14 (Read Local Name) +# 00 — parameter length 0 +_READ_LOCAL_NAME_CMD = bytes([0x01, 0x14, 0x0C, 0x00]) + +# Synthetic name returned to the kernel (248 bytes, null-terminated). +_SYNTHETIC_NAME = b"SiLabs CPC BLE" +_SYNTHETIC_NAME_PADDED = _SYNTHETIC_NAME + b"\x00" * (248 - len(_SYNTHETIC_NAME)) + +# Pre-built Command Complete response (H4 format, 255 bytes total): +# 04 — H4 event +# 0e — HCI_EV_CMD_COMPLETE +# fc — parameter length (252) +# 01 — Num_HCI_Command_Packets +# 14 0c — opcode 0x0C14 +# 00 — status Success +# <248 bytes> — name +_READ_LOCAL_NAME_RESPONSE = ( + bytes([0x04, 0x0E, 0xFC, 0x01, 0x14, 0x0C, 0x00]) + _SYNTHETIC_NAME_PADDED +) + +# --- Read Class of Device interception --- +# +# HCI Read Class of Device command (H4 format): +# 01 — H4 command packet indicator +# 23 0c — opcode 0x0C23 (Read Class of Device) +# 00 — parameter length 0 +_READ_CLASS_OF_DEV_CMD = bytes([0x01, 0x23, 0x0C, 0x00]) + +# Pre-built Command Complete response (H4 format): +# 04 — H4 event +# 0e — HCI_EV_CMD_COMPLETE +# 07 — parameter length (7 = 1 + 2 + 1 + 3) +# 01 — Num_HCI_Command_Packets +# 23 0c — opcode 0x0C23 +# 00 — status Success +# 00 00 00 — Class of Device (uncategorized) +_READ_CLASS_OF_DEV_RESPONSE = bytes( + [0x04, 0x0E, 0x07, 0x01, 0x23, 0x0C, 0x00, 0x00, 0x00, 0x00] +) + +# --- Table of HCI commands that the CPC BLE firmware cannot handle --- +# +# For each (opcode_le_bytes, param_len_byte, description) we store the +# full H4 command bytes and a pre-built synthetic success response. +# The response must be exactly the length the kernel expects so the +# "unexpected cc" check passes. +# +# Adding new commands: look up the opcode and expected Command Complete +# response format in the Bluetooth Core Spec Vol 4 Part E, then add +# the bytes here. +_INTERCEPTED_COMMANDS: dict[bytes, tuple[bytes, str]] = { + _READ_LOCAL_NAME_CMD: (_READ_LOCAL_NAME_RESPONSE, "Read Local Name (0x0C14)"), + _READ_CLASS_OF_DEV_CMD: ( + _READ_CLASS_OF_DEV_RESPONSE, + "Read Class of Device (0x0C23)", + ), +} + +# --- Generic short-response fixer for Command Complete events --- +# +# The CPC firmware returns "Unknown HCI Command" (status 0x01) with a +# 1-byte payload for any unsupported command. The kernel expects at +# least N bytes (opcode-specific) and discards the event when it's too +# short, logging "unexpected cc length: 1 < N". This can +# corrupt the HCI command queue. +# +# For known opcodes we maintain a table of minimum response lengths. +# When we see a short Command Complete from the firmware, we pad it to +# the expected length so the kernel processes it normally (and sees the +# error status). +_OPCODE_MIN_RESPONSE_LEN: dict[int, int] = { + # opcode → minimum payload bytes after status (to pad with zeros) + 0x0C14: 248, # Read Local Name: name[248] + 0x0C23: 3, # Read Class of Device: class[3] + 0x0C33: 1, # Read Page Scan Activity: interval(2)+window(2) — but might be 4 + 0x0C45: 3, # Read Inquiry Mode: mode(1) + 0x0C55: 1, # Read Simple Pairing Mode: mode(1) + 0x1005: 8, # Read Buffer Size: acl_mtu(2)+sco_mtu(1)+acl_pkts(2)+sco_pkts(2) +} # # 04 — H4 event packet indicator # 0e — HCI_EV_CMD_COMPLETE @@ -229,6 +357,211 @@ def _strip_ext_adv(data: bytes) -> bytes: return bytes(buf) +def _strip_ext_adv_cmds(data: bytes) -> bytes: + """Clear Extended Advertising / Scan command bits from the + Read Local Supported Commands (0x1002) response. + + On kernel 6.8+, ``use_ext_scan()`` and ``use_ext_adv()`` in + ``hci_sync.c`` decide whether to issue Extended commands by + checking ``hdev->commands[36-37]`` — the Supported Commands + bitmap — instead of the LE features. We must zero these bytes + so the kernel sticks to legacy scan / advertising commands. + """ + # Parse HCI event structure (same approach as _log_hci_event) + # to robustly find the Command Complete for opcode 0x1002. + i = 0 + + while i < len(data) - 6: + if data[i] != 0x04: + i += 1 + continue + + evt_code = data[i + 1] + plen = data[i + 2] + pkt_end = i + 3 + plen + + if evt_code != 0x0E or i + 6 >= len(data): + i = max(pkt_end, i + 1) + continue + + opcode = data[i + 5] << 8 | data[i + 4] + + if opcode != 0x1002: + i = max(pkt_end, i + 1) + continue + + status = data[i + 6] + + if _DEBUG: + snippet = data[i : min(i + 15, len(data))].hex(" ") + print( + f"[hci-proxy] _strip_ext_adv_cmds: found 0x1002 at i={i} " + f"plen={plen} status=0x{status:02X} raw={snippet}", + flush=True, + ) + + if status != 0x00: + if _DEBUG: + print( + "[hci-proxy] _strip_ext_adv_cmds: status not success, " "skipping", + flush=True, + ) + return data + + # Supported commands bitmap starts at i+7 (after H4, event code, + # plen, num_hci_cmd_pkts, opcode_lo, opcode_hi, status). + cmds_start = i + 7 + + if cmds_start + 64 > len(data): + return data # Incomplete — leave unchanged. + + buf = bytearray(data) + cleared: list[str] = [] + + for byte_idx in _EXT_ADV_CMD_BYTES: + offset = cmds_start + byte_idx + + if buf[offset] != 0: + cleared.append(f"byte {byte_idx}: 0x{buf[offset]:02X} -> 0x00") + buf[offset] = 0x00 + + if cleared: + print( + "[hci-proxy] Cleared Extended Advertising/Scan command " + "support bits in Read Local Supported Commands: " + ", ".join(cleared), + flush=True, + ) + + return bytes(buf) + + return data + + +def _intercept_unsupported_commands(data: bytes, host_fd: int) -> bytes: + """Intercept HCI commands that the CPC BLE firmware does not support. + + For each command in _INTERCEPTED_COMMANDS, if the command bytes appear + in *data* (heading to the firmware), we: + 1. Remove the command from *data* (don't forward to firmware) + 2. Write a synthetic Command Complete response to *host_fd* (kernel) + + Returns *data* with intercepted commands removed. + """ + for cmd_bytes, (response, desc) in _INTERCEPTED_COMMANDS.items(): + idx = data.find(cmd_bytes) + + if idx < 0: + continue + + print( + f"[hci-proxy] Intercepted {desc} — " + f"returning synthetic response (CPC firmware does not support this command)", + flush=True, + ) + + # Send the pre-built fake response back to the kernel. + os.write(host_fd, response) + + # Remove the command from the data. + data = data[:idx] + data[idx + len(cmd_bytes) :] + + return data + + +def _fix_short_cc(data: bytes) -> bytes: + """Fix short Command Complete events from the CPC firmware. + + When the firmware returns "Unknown HCI Command" (status != 0x00) with + only the 1-byte status and no payload, the kernel expects more bytes + and discards the event ("unexpected cc ... length: 1 < N"), corrupting + the command queue. + + We pad such events to the minimum expected length so the kernel + processes them normally. + """ + # Quick reject — must contain an HCI Event indicator + Command Complete. + if b"\x04\x0e" not in data: + return data + + buf = bytearray(data) + out = bytearray() + i = 0 + + while i < len(buf): + # Look for H4 Event (0x04) + Command Complete (0x0E) + if buf[i] == 0x04 and i + 1 < len(buf) and buf[i + 1] == 0x0E: + if i + 2 >= len(buf): + out.extend(buf[i:]) + break + + plen = buf[i + 2] + pkt_end = i + 3 + plen + + if pkt_end > len(buf): + out.extend(buf[i:]) + break + + # Command Complete: plen includes num_cmd(1) + opcode(2) + params + # Minimum plen = 4 means just num_cmd(1) + opcode(2) + status(1) + if plen >= 4 and i + 6 < len(buf): + opcode = buf[i + 4] | (buf[i + 5] << 8) + status = buf[i + 6] + + # --- Strip ext adv/scan command bits from 0x1002 --- + # On kernel 6.8+, use_ext_scan()/use_ext_adv() check + # hdev->commands[36-37] instead of LE features. + if opcode == 0x1002 and status == 0x00: + cmds_start = i + 7 # supported commands bitmap + if cmds_start + 64 <= len(buf): + cleared = [] + for byte_idx in _EXT_ADV_CMD_BYTES: + offset = cmds_start + byte_idx + if buf[offset] != 0: + cleared.append( + f"byte {byte_idx}: " f"0x{buf[offset]:02X} -> 0x00" + ) + buf[offset] = 0x00 + if cleared: + print( + "[hci-proxy] Cleared ext adv/scan command " + "bits in Supported Commands: " + ", ".join(cleared), + flush=True, + ) + + # Only fix if status is non-zero (error) AND the response + # is short (just status, no payload). + payload_len = plen - 4 # bytes after status + if ( + status != 0 + and payload_len == 0 + and opcode in _OPCODE_MIN_RESPONSE_LEN + ): + needed = _OPCODE_MIN_RESPONSE_LEN[opcode] + new_plen = 4 + needed + print( + f"[hci-proxy] Padding short CC for opcode 0x{opcode:04X} " + f"(status=0x{status:02X}): {plen} → {new_plen} bytes", + flush=True, + ) + out.append(0x04) + out.append(0x0E) + out.append(new_plen) + out.extend(buf[i + 3 : pkt_end]) # original params + out.extend(b"\x00" * needed) # padding + i = pkt_end + continue + + # Not a short CC we need to fix — pass through. + out.extend(buf[i:pkt_end]) + i = pkt_end + continue + + out.append(buf[i]) + i += 1 + + return bytes(out) + + def _translate_conn_complete(data: bytes) -> bytes: """Translate LE Enhanced Connection Complete v1/v2 events into the legacy LE Connection Complete event so the kernel processes them. @@ -367,15 +700,20 @@ def main() -> None: return if fd == bridge_fd: - # Firmware → kernel: filter features and translate events. + # Firmware → kernel: filter features, translate events, + # and fix short error responses. _log_hci_event(data, "FW→HOST") data = _strip_ext_adv(data) + data = _strip_ext_adv_cmds(data) data = _translate_conn_complete(data) + data = _fix_short_cc(data) os.write(master_fd, data) else: - # Kernel → firmware: pass through unchanged. + # Kernel → firmware: intercept unsupported commands. _log_hci_event(data, "HOST→FW") - os.write(bridge_fd, data) + data = _intercept_unsupported_commands(data, master_fd) + if data: # Forward remaining bytes (if any). + os.write(bridge_fd, data) finally: os.close(bridge_fd) os.close(master_fd) From 117f3793df942956af8efe005f4dff617038b258 Mon Sep 17 00:00:00 2001 From: Thomas Lea Date: Thu, 28 May 2026 16:22:05 +0000 Subject: [PATCH 11/17] feat(docker): replace USB-IP with remote serial tunnel for radio access Replace the USB-IP and CPC socket proxy remote radio approach with a simpler remote serial tunnel architecture. The new design uses a single Python script on the workstation that relays serial bytes over an SSH reverse tunnel, eliminating the need for USB-IP kernel modules, multiple helper scripts, and complex CPC socket proxying. New files: - scripts/remote-radio/remote-serial.py: Workstation-side relay that opens the local Silicon Labs radio serial port, starts a TCP server, and establishes an SSH reverse tunnel to the dev server. Features auto-detection of radio ports, per-user port isolation (base 20000 + UID), persistent serial port across TCP reconnects, stale tunnel cleanup, and auto-reconnect for both serial and SSH. - scripts/remote-radio/validate.sh: Comprehensive 8-section validation script covering container environment, D-Bus, radio connection, CPC, BLE chain, HCI, otbr-agent, and known pitfalls. Auto-detects and re-execs into the otbr-radio container via Docker CLI or Docker Engine API (for environments without the docker command). - docker/setup-thread-routes.sh: Configures IPv6 routing in the barton container so it can reach Thread mesh devices via the otbr-radio container. Adds a fd00::/8 ULA catch-all route through otbr-radio's bridge address. Runs as postStartCommand in the devcontainer. Modified files: - docker/otbr-radio-entrypoint.sh: - Add remote serial tunnel mode: when RADIO_HOST/RADIO_PORT are set, use socat to bridge TCP to a PTY instead of a local USB device - Resolve host.docker.internal to IPv4 (SSH tunnel binds IPv4 only) - Remove wait-slave from socat (prevents TCP lifecycle coupling to PTY slave state during cpcd init retries) - Use no-data TCP probe (: >/dev/tcp/) instead of echo to avoid sending garbage bytes to the radio during connectivity checks - Always set uart_hardflow: true (radio firmware requires it; cpcd exits with FATAL on mismatch even though CRTSCTS is a no-op on PTY) - Add stdbuf -oL for cpcd output buffering (ensures grep detects the ready message promptly) - Set accept_ra=2 on backbone interface (kernel drops RAs when forwarding=1 without this) - docker/compose.otbr-radio.yaml: - Add sysctls for IPv6 forwarding and NDP proxy on otbr-radio - Add RADIO_PORT/RADIO_HOST environment variables - Add extra_hosts for host.docker.internal resolution - docker/setupDockerEnv.sh: - Replace CPC_REMOTE_HOST/PORT/INSTANCE with RADIO_PORT/RADIO_HOST - .devcontainer/devcontainer.json: - Add postStartCommand for Thread IPv6 route setup - docs/REMOTE_RADIO_FOR_DEVELOPMENT.md: - Complete rewrite covering both local USB and remote serial tunnel Deleted files (replaced by remote-serial.py + validate.sh): - scripts/remote-radio/cpc_remote_access.sh - scripts/remote-radio/cpc_socket_proxy.py - scripts/remote-radio/cpc_validate.sh - scripts/remote-radio/usbip-attach-local.sh - scripts/remote-radio/usbip-attach-remote.sh - scripts/remote-radio/usbip-detach-local.sh - scripts/remote-radio/usbip-detach-remote.sh - scripts/remote-radio/usbip-validate.sh --- .devcontainer/devcontainer.json | 7 +- docker/compose.otbr-radio.yaml | 35 +- docker/otbr-radio-entrypoint.sh | 286 ++++--- docker/setup-thread-routes.sh | 56 ++ docker/setupDockerEnv.sh | 33 +- docs/REMOTE_RADIO_FOR_DEVELOPMENT.md | 235 +++--- scripts/remote-radio/cpc_remote_access.sh | 257 ------- scripts/remote-radio/cpc_socket_proxy.py | 312 -------- scripts/remote-radio/remote-serial.py | 723 ++++++++++++++++++ scripts/remote-radio/usbip-attach-local.sh | 283 ------- scripts/remote-radio/usbip-attach-remote.sh | 312 -------- scripts/remote-radio/usbip-detach-local.sh | 136 ---- scripts/remote-radio/usbip-detach-remote.sh | 124 --- scripts/remote-radio/usbip-validate.sh | 342 --------- .../{cpc_validate.sh => validate.sh} | 333 ++++---- 15 files changed, 1305 insertions(+), 2169 deletions(-) create mode 100755 docker/setup-thread-routes.sh delete mode 100755 scripts/remote-radio/cpc_remote_access.sh delete mode 100644 scripts/remote-radio/cpc_socket_proxy.py create mode 100755 scripts/remote-radio/remote-serial.py delete mode 100755 scripts/remote-radio/usbip-attach-local.sh delete mode 100755 scripts/remote-radio/usbip-attach-remote.sh delete mode 100755 scripts/remote-radio/usbip-detach-local.sh delete mode 100755 scripts/remote-radio/usbip-detach-remote.sh delete mode 100755 scripts/remote-radio/usbip-validate.sh rename scripts/remote-radio/{cpc_validate.sh => validate.sh} (75%) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index aefc6227..2d73862a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -115,5 +115,10 @@ }, // script for further VSC customization after the container is created - "postCreateCommand": ".devcontainer/setup.sh" + "postCreateCommand": ".devcontainer/setup.sh", + + // Set up IPv6 routes to Thread devices via the otbr-radio container. + // Runs on every container start (not just creation) so routes survive + // restarts. Harmless no-op when otbr-radio is not running. + "postStartCommand": "docker/setup-thread-routes.sh" } diff --git a/docker/compose.otbr-radio.yaml b/docker/compose.otbr-radio.yaml index 17dcd63b..b70bc8f5 100644 --- a/docker/compose.otbr-radio.yaml +++ b/docker/compose.otbr-radio.yaml @@ -64,7 +64,7 @@ # ─── USB-IP (remote radio forwarding) ──────────────────────────────────────── # # If your dev server is remote and the radio is on your local workstation, -# see docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for USB-IP setup instructions. +# see docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for remote serial tunnel setup. # # ───────────────────────────────────────────────────────────────────────────── @@ -78,6 +78,16 @@ services: # - configuring iptables rules for Thread border routing # - accessing the USB serial device via /dev/ttyACM* privileged: true + # IPv6 forwarding must be enabled for otbr-agent to route packets + # between the Docker bridge (eth0) and the Thread mesh (wpan0). + # proxy_ndp allows otbr-agent to proxy Neighbor Discovery between + # interfaces so Thread devices are reachable from the bridge. + # NOTE: interface-specific sysctls (net.ipv6.conf.eth0.*) cannot be + # set here — Docker requires them via driver options. They are set + # in the entrypoint instead since the container is privileged. + sysctls: + - net.ipv6.conf.all.forwarding=1 + - net.ipv6.conf.all.proxy_ndp=1 # Connect to the same per-user IPv6 bridge network as the barton service so # that: # - each user on a shared server gets an isolated wpan0 interface @@ -96,8 +106,7 @@ services: # serial device RADIO_DEVICE points to (e.g. /dev/ttyACM0, # /dev/ttyACM8, /dev/ttyUSB0, etc.). This avoids a hard-coded # devices: mapping that would fail at 'docker compose up' time if - # the device does not yet exist or appears at a non-default path - # (common on shared build servers with multiple USB-IP users). + # the device does not yet exist or appears at a non-default path. # The privileged: true above grants the necessary device access. - /dev:/dev # Bind-mount the host's network namespace so that btattach and @@ -105,30 +114,32 @@ services: # AF_BLUETOOTH sockets are only available in the initial (host) # network namespace. - /proc/1/ns/net:/run/host-netns:ro - # Mount the CPC socket proxy script for remote CPC mode. - - ../scripts/remote-radio/cpc_socket_proxy.py:/opt/cpc-proxy/cpc_socket_proxy.py:ro # Mount the HCI PTY proxy that strips Extended Advertising from # the LE features response, working around a CPC firmware bug. - ../scripts/remote-radio/hci_pty_proxy.py:/opt/cpc-proxy/hci_pty_proxy.py:ro - # Validation/diagnostic script for the full CPC + BLE chain. - - ../scripts/remote-radio/cpc_validate.sh:/opt/cpc-proxy/cpc_validate.sh:ro # Bind-mount the entrypoint for development iteration without # rebuilding the Docker image. - ./otbr-radio-entrypoint.sh:/entrypoint.sh:ro + extra_hosts: + # Allow the container to reach the host machine for the remote + # serial tunnel. The SSH reverse tunnel binds on the host, so + # socat inside this container connects to host.docker.internal. + - "host.docker.internal:host-gateway" environment: # RADIO_DEVICE must be set explicitly before starting this overlay. # On a shared build server each user's radio may attach at a # different path (e.g. /dev/ttyACM8). The entrypoint validates the # path at runtime and exits with a clear error if it is unset or # the device does not exist. - # Not required when CPC_REMOTE_HOST is set. + # Not required when RADIO_PORT is set for remote serial mode. - RADIO_DEVICE=${RADIO_DEVICE:-} - BACKBONE_IF=${BACKBONE_IF:-} - DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/otbr-dbus/system_bus_socket - # Remote CPC mode: set CPC_REMOTE_HOST to connect to a remote cpcd - # via the CPC socket proxy instead of running cpcd locally. - - CPC_REMOTE_HOST=${CPC_REMOTE_HOST:-} - - CPC_REMOTE_PORT=${CPC_REMOTE_PORT:-15000} + # Remote serial tunnel mode: set RADIO_PORT to connect to a + # workstation's serial port forwarded via SSH tunnel. + # RADIO_HOST defaults to host.docker.internal in the entrypoint. + - RADIO_PORT=${RADIO_PORT:-} + - RADIO_HOST=${RADIO_HOST:-host.docker.internal} - CPC_INSTANCE=${CPC_INSTANCE:-cpcd_0} # Restart automatically if cpcd/otbr-agent crash, but limit retries so # a missing/invalid RADIO_DEVICE does not cause an infinite restart diff --git a/docker/otbr-radio-entrypoint.sh b/docker/otbr-radio-entrypoint.sh index ff69e5e8..d0d167b6 100755 --- a/docker/otbr-radio-entrypoint.sh +++ b/docker/otbr-radio-entrypoint.sh @@ -27,19 +27,17 @@ # 1. D-Bus system bus # 2. avahi-daemon (mDNS/DNS-SD — required by otbr-agent built with OTBR_MDNS=avahi) # 3. cpcd (CPC daemon — USB serial ↔ CPC socket) -# OR CPC socket proxy client (when CPC_REMOTE_HOST is set) # 4. otbr-agent (Thread Border Router — CPC socket ↔ D-Bus API) # # Environment variables: # RADIO_DEVICE - host path of the USB serial device. The compose # overlay requires this to be set explicitly. -# Not required when CPC_REMOTE_HOST is set. -# CPC_REMOTE_HOST - when set, connect to a remote cpcd via the CPC -# socket proxy instead of running cpcd locally. -# The remote host must be running -# cpc_remote_access.sh in server mode. -# CPC_REMOTE_PORT - base port for the CPC socket proxy -# (default: 15000) +# Not required when RADIO_HOST/RADIO_PORT are set. +# RADIO_HOST - when set (with RADIO_PORT), connect to a remote +# serial tunnel instead of using a local device. +# The workstation runs remote-serial.py to forward +# its serial port via SSH. +# RADIO_PORT - TCP port for the remote serial tunnel. # CPC_INSTANCE - CPC daemon instance name (default: cpcd_0) # BACKBONE_IF - network interface to use as the Thread backbone, # e.g. eth0 @@ -48,17 +46,17 @@ # DBUS_SOCKET_PATH - socket path for the private system bus # DBUS_SYSTEM_BUS_ADDRESS - D-Bus address used by otbr-agent and clients # -# See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for USB-IP setup and hardware info. +# See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for setup and hardware info. # set -e # RADIO_DEVICE is intentionally not defaulted here. # If it is unset or empty the validation section below will exit with a -# clear error message (unless CPC_REMOTE_HOST is set). -RADIO_DEVICE="${RADIO_DEVICE}" -CPC_REMOTE_HOST="${CPC_REMOTE_HOST:-}" -CPC_REMOTE_PORT="${CPC_REMOTE_PORT:-15000}" +# clear error message (unless RADIO_HOST/RADIO_PORT are set for remote serial). +RADIO_DEVICE="${RADIO_DEVICE:-}" +RADIO_HOST="${RADIO_HOST:-host.docker.internal}" +RADIO_PORT="${RADIO_PORT:-}" CPC_INSTANCE="${CPC_INSTANCE:-cpcd_0}" CPCD_CONF="${CPCD_CONF:-/usr/local/etc/cpcd.conf}" DBUS_DIR="${DBUS_DIR:-/var/run/otbr-dbus}" @@ -84,6 +82,13 @@ else echo "[otbr-radio] Auto-detected backbone interface: ${BACKBONE_IF}" fi +# Accept Router Advertisements even with forwarding=1. +# The kernel drops RAs when forwarding is enabled unless accept_ra=2. +# This is an interface-specific sysctl that cannot be set via Docker Compose +# sysctls (Docker requires the driver option syntax for those), so we set it +# here since the container is privileged. +sysctl -qw "net.ipv6.conf.${BACKBONE_IF}.accept_ra=2" 2>/dev/null || true + ############################################################################### # 1. Start a private D-Bus system bus # @@ -110,98 +115,116 @@ echo "[otbr-radio] Private D-Bus started." # Two modes are supported: # a) Local radio (default): RADIO_DEVICE must point to a USB serial device. # cpcd is started locally to manage the CPC link. -# b) Remote CPC: CPC_REMOTE_HOST is set. A CPC socket proxy client connects -# to a remote cpcd over TCP, creating local Unix sockets that libcpc -# applications (bt_host_cpc_hci_bridge, otbr-agent) use transparently. +# b) Remote serial tunnel: RADIO_HOST and RADIO_PORT are set. A socat +# process bridges the remote TCP serial tunnel to a local PTY that cpcd +# opens as if it were a directly-attached USB serial device. +# The workstation runs scripts/remote-radio/remote-serial.py to forward +# its local serial port over an SSH tunnel to this container. ############################################################################### -CPC_SOCKET_DIR="${CPC_SOCKET_DIR:-/dev/shm}" -if [ -n "${CPC_REMOTE_HOST}" ]; then +if [ -n "${RADIO_PORT}" ]; then #-------------------------------------------------------------------------- - # Remote CPC mode — connect to cpcd running on a remote host via the - # CPC socket proxy. + # Remote serial tunnel mode — create a virtual serial device via socat + # that connects to the SSH-tunnelled serial port on the workstation. + # RADIO_HOST defaults to host.docker.internal (Docker's built-in DNS + # for the host machine). #-------------------------------------------------------------------------- - echo "[otbr-radio] Remote CPC mode: connecting to cpcd on ${CPC_REMOTE_HOST}:${CPC_REMOTE_PORT}" - - CPC_PROXY_SCRIPT="/opt/cpc-proxy/cpc_socket_proxy.py" - CPC_SOCKET_BASE="${CPC_SOCKET_DIR}/cpcd/${CPC_INSTANCE}" - mkdir -p "${CPC_SOCKET_BASE}" - - # Start proxy for the control socket. - echo "[otbr-radio] Starting CPC proxy: ctrl.cpcd.sock (port ${CPC_REMOTE_PORT})" - python3 "${CPC_PROXY_SCRIPT}" client \ - -s "${CPC_SOCKET_BASE}/ctrl.cpcd.sock" \ - -H "${CPC_REMOTE_HOST}" -p "${CPC_REMOTE_PORT}" & - - # Start proxy for the reset socket. - CPC_RESET_PORT=$((CPC_REMOTE_PORT + 1)) - echo "[otbr-radio] Starting CPC proxy: reset.cpcd.sock (port ${CPC_RESET_PORT})" - python3 "${CPC_PROXY_SCRIPT}" client \ - -s "${CPC_SOCKET_BASE}/reset.cpcd.sock" \ - -H "${CPC_REMOTE_HOST}" -p "${CPC_RESET_PORT}" & - - # Start proxies for the Bluetooth RCP endpoint. - # bt_host_cpc_hci_bridge opens SL_CPC_ENDPOINT_BLUETOOTH_RCP which is 14 - # in cpcd v4.4.6 (host library sl_cpc.h). - CPC_BT_EP=14 - CPC_BT_DATA_PORT=$((CPC_REMOTE_PORT + 100 + CPC_BT_EP)) - CPC_BT_EVENT_PORT=$((CPC_REMOTE_PORT + 400 + CPC_BT_EP)) - echo "[otbr-radio] Starting CPC proxy: ep${CPC_BT_EP}.cpcd.sock (port ${CPC_BT_DATA_PORT})" - python3 "${CPC_PROXY_SCRIPT}" client \ - -s "${CPC_SOCKET_BASE}/ep${CPC_BT_EP}.cpcd.sock" \ - -H "${CPC_REMOTE_HOST}" -p "${CPC_BT_DATA_PORT}" & - echo "[otbr-radio] Starting CPC proxy: ep${CPC_BT_EP}.event.cpcd.sock (port ${CPC_BT_EVENT_PORT})" - python3 "${CPC_PROXY_SCRIPT}" client \ - -s "${CPC_SOCKET_BASE}/ep${CPC_BT_EP}.event.cpcd.sock" \ - -H "${CPC_REMOTE_HOST}" -p "${CPC_BT_EVENT_PORT}" & - - # Start proxies for the OpenThread/Spinel endpoint. - # otbr-agent uses spinel+cpc://cpcd_0 which maps to - # SL_CPC_ENDPOINT_15_4 (12) in cpcd v4.4.6 for multi-PAN RCP firmware. - CPC_SPINEL_EP=12 - CPC_SPINEL_DATA_PORT=$((CPC_REMOTE_PORT + 100 + CPC_SPINEL_EP)) - CPC_SPINEL_EVENT_PORT=$((CPC_REMOTE_PORT + 400 + CPC_SPINEL_EP)) - echo "[otbr-radio] Starting CPC proxy: ep${CPC_SPINEL_EP}.cpcd.sock (port ${CPC_SPINEL_DATA_PORT})" - python3 "${CPC_PROXY_SCRIPT}" client \ - -s "${CPC_SOCKET_BASE}/ep${CPC_SPINEL_EP}.cpcd.sock" \ - -H "${CPC_REMOTE_HOST}" -p "${CPC_SPINEL_DATA_PORT}" & - echo "[otbr-radio] Starting CPC proxy: ep${CPC_SPINEL_EP}.event.cpcd.sock (port ${CPC_SPINEL_EVENT_PORT})" - python3 "${CPC_PROXY_SCRIPT}" client \ - -s "${CPC_SOCKET_BASE}/ep${CPC_SPINEL_EP}.event.cpcd.sock" \ - -H "${CPC_REMOTE_HOST}" -p "${CPC_SPINEL_EVENT_PORT}" & - - # Give the proxies a moment to establish connections. - sleep 2 - - # Verify the control socket was created. - if [ ! -S "${CPC_SOCKET_BASE}/ctrl.cpcd.sock" ]; then - echo "[otbr-radio] ERROR: CPC proxy failed to create ctrl.cpcd.sock." >&2 - echo "[otbr-radio] Check that ${CPC_REMOTE_HOST}:${CPC_REMOTE_PORT} is reachable" >&2 - echo "[otbr-radio] and cpc_remote_access.sh server is running." >&2 - exit 1 + echo "[otbr-radio] Remote serial mode: connecting to ${RADIO_HOST}:${RADIO_PORT}" + + VIRTUAL_TTY="/dev/ttyRadio" + + # Resolve RADIO_HOST to an IPv4 address. The SSH reverse tunnel binds on + # 0.0.0.0 (IPv4 only), so we must connect via IPv4 even when the Docker + # host-gateway resolves to an IPv6 address. + RADIO_HOST_V4=$(getent ahostsv4 "${RADIO_HOST}" 2>/dev/null | awk '{print $1; exit}') + if [ -z "${RADIO_HOST_V4}" ]; then + echo "[otbr-radio] WARNING: Could not resolve ${RADIO_HOST} to IPv4; using as-is" + RADIO_HOST_V4="${RADIO_HOST}" + else + echo "[otbr-radio] Resolved ${RADIO_HOST} → ${RADIO_HOST_V4} (IPv4)" fi - echo "[otbr-radio] CPC proxy client connected to ${CPC_REMOTE_HOST}." -else + # socat creates a PTY linked to the remote TCP stream. Retry with + # backoff until the SSH tunnel is reachable. The 'forever' and + # 'intervall' options make socat reconnect automatically if the TCP + # connection drops (self-healing). + SOCAT_WAIT_MAX=60 + socatWaitCount=0 + echo "[otbr-radio] Waiting for remote serial tunnel at ${RADIO_HOST_V4}:${RADIO_PORT}..." + + while ! timeout 2 bash -c ": >/dev/tcp/${RADIO_HOST_V4}/${RADIO_PORT}" 2>/dev/null; do + + if [ ${socatWaitCount} -ge ${SOCAT_WAIT_MAX} ]; then + echo "[otbr-radio] ERROR: Remote serial tunnel at ${RADIO_HOST_V4}:${RADIO_PORT} not reachable after ${SOCAT_WAIT_MAX}s." >&2 + echo "[otbr-radio] Ensure remote-serial.py is running on your workstation." >&2 + echo "[otbr-radio] See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for setup instructions." >&2 + exit 1 + fi + + sleep 2 + socatWaitCount=$((socatWaitCount + 2)) + done + + echo "[otbr-radio] Remote serial tunnel is reachable. Starting socat bridge..." + rm -f "${VIRTUAL_TTY}" + + # Start socat: TCP connection → PTY device + # - TCP4: force IPv4 (the SSH reverse tunnel binds on 0.0.0.0, not [::]) + # - b115200: match the radio baud rate + # - raw,echo=0: pass bytes through unmodified + # - link=: create a named symlink to the PTY + # - forever,intervall=3: reconnect on TCP drops every 3s (self-healing) + # NOTE: do NOT use wait-slave — it ties socat's TCP lifecycle to the + # PTY slave state. If cpcd briefly closes/reopens the device during + # CPC init retries, wait-slave causes socat to disconnect TCP and + # destroy the relay connection. + socat \ + "TCP4:${RADIO_HOST_V4}:${RADIO_PORT},forever,intervall=3,keepalive,keepidle=10,keepintvl=5,keepcnt=3" \ + "PTY,link=${VIRTUAL_TTY},raw,echo=0,b115200" & + SOCAT_PID=$! + + # Wait for the PTY symlink to appear. + socatPtyWait=0 + + while [ ! -L "${VIRTUAL_TTY}" ]; do + + if ! kill -0 ${SOCAT_PID} 2>/dev/null; then + echo "[otbr-radio] ERROR: socat exited before creating ${VIRTUAL_TTY}." >&2 + exit 1 + fi + + if [ ${socatPtyWait} -ge 10 ]; then + echo "[otbr-radio] ERROR: socat did not create ${VIRTUAL_TTY} after 10s." >&2 + exit 1 + fi + + sleep 1 + socatPtyWait=$((socatPtyWait + 1)) + done + + RADIO_DEVICE="${VIRTUAL_TTY}" + echo "[otbr-radio] Virtual serial device ready: ${RADIO_DEVICE} (via ${RADIO_HOST_V4}:${RADIO_PORT})" + +elif [ -n "${RADIO_DEVICE}" ]; then #-------------------------------------------------------------------------- - # Local radio mode — validate RADIO_DEVICE and start cpcd. + # Local radio mode — validate RADIO_DEVICE directly. #-------------------------------------------------------------------------- - if [ -z "${RADIO_DEVICE}" ]; then - echo "[otbr-radio] ERROR: Neither RADIO_DEVICE nor CPC_REMOTE_HOST is set." >&2 - echo "[otbr-radio] Set RADIO_DEVICE for a local radio:" >&2 - echo "[otbr-radio] export RADIO_DEVICE=/dev/ttyACM0" >&2 - echo "[otbr-radio] Or set CPC_REMOTE_HOST for a remote cpcd:" >&2 - echo "[otbr-radio] export CPC_REMOTE_HOST=10.0.0.1" >&2 - exit 1 - fi - if [ ! -e "${RADIO_DEVICE}" ]; then echo "[otbr-radio] ERROR: USB radio device '${RADIO_DEVICE}' not found." >&2 - echo "[otbr-radio] Check that the thread radio is connected and USB-IP is attached." >&2 + echo "[otbr-radio] Check that the radio is connected." >&2 echo "[otbr-radio] See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for setup instructions." >&2 exit 1 fi + echo "[otbr-radio] Using USB radio device: ${RADIO_DEVICE}" + +else + echo "[otbr-radio] ERROR: No radio configured." >&2 + echo "[otbr-radio] Set RADIO_DEVICE for a locally attached radio:" >&2 + echo "[otbr-radio] export RADIO_DEVICE=/dev/ttyACM0" >&2 + echo "[otbr-radio] Or set RADIO_PORT for a remote serial tunnel:" >&2 + echo "[otbr-radio] export RADIO_PORT=21000" >&2 + exit 1 fi ############################################################################### @@ -213,18 +236,27 @@ avahi-daemon --daemonize --no-chroot echo "[otbr-radio] avahi-daemon started." ############################################################################### -# 4. Configure and start cpcd (local radio mode only) +# 4. Configure and start cpcd # # Always write a fresh config so we fully control all keys. # The cpcd installed config uses 'uart_device_file' (not 'uart_device'), so # patching the existing file would silently fail leaving the wrong device path. # -# Skipped when CPC_REMOTE_HOST is set — the CPC proxy handles connectivity. +# In remote serial tunnel mode, RADIO_DEVICE has been set to the socat-created +# PTY (/dev/ttyRadio) by step 2 above. cpcd treats it identically to a +# directly-attached USB serial device. +# +# Note: even for remote serial tunnels, uart_hardflow must be 'true' because +# the radio's firmware has hardware flow control enabled. cpcd checks the +# radio's capability flags during init and exits with FATAL if there is a +# mismatch. On the PTY, CRTSCTS is accepted by the kernel but is a no-op +# (no physical RTS/CTS lines). Actual flow control happens on the physical +# serial port at the workstation relay side. ############################################################################### -if [ -z "${CPC_REMOTE_HOST}" ]; then - echo "[otbr-radio] Writing cpcd config to ${CPCD_CONF}..." - mkdir -p "$(dirname "${CPCD_CONF}")" - cat > "${CPCD_CONF}" < "${CPCD_CONF}" < "${CPCD_LOG}" - cpcd --conf "${CPCD_CONF}" > >(tee "${CPCD_LOG}") 2>&1 & - CPCD_PID=$! - - # Wait until cpcd logs its ready message, meaning it has fully connected to the - # secondary and is accepting client connections. - CPCD_WAIT_MAX=30 - cpcdWaitCount=0 - echo "[otbr-radio] Waiting for cpcd to be ready..." - while ! grep -q "Daemon startup was successful" "${CPCD_LOG}" 2>/dev/null; do - if ! kill -0 ${CPCD_PID} 2>/dev/null; then - echo "[otbr-radio] ERROR: cpcd exited before becoming ready. Check device and firmware." >&2 - cat "${CPCD_LOG}" >&2 - exit 1 - fi - if [ ${cpcdWaitCount} -ge ${CPCD_WAIT_MAX} ]; then - echo "[otbr-radio] ERROR: cpcd did not become ready after ${CPCD_WAIT_MAX}s." >&2 - cat "${CPCD_LOG}" >&2 - exit 1 - fi - sleep 1 - cpcdWaitCount=$((cpcdWaitCount + 1)) - done - echo "[otbr-radio] cpcd ready (PID ${CPCD_PID}, waited ${cpcdWaitCount}s)." -fi +echo "[otbr-radio] Starting cpcd (instance: ${CPC_INSTANCE}, device: ${RADIO_DEVICE})..." +CPCD_LOG="/tmp/cpcd.log" +: > "${CPCD_LOG}" +# stdbuf -oL forces line-buffered stdout so 'grep' can detect the ready +# message promptly even though cpcd's output is going through a pipe. +stdbuf -oL cpcd --conf "${CPCD_CONF}" > >(tee "${CPCD_LOG}") 2>&1 & +CPCD_PID=$! + +# Wait until cpcd logs its ready message, meaning it has fully connected to the +# secondary and is accepting client connections. +CPCD_WAIT_MAX=30 +cpcdWaitCount=0 +echo "[otbr-radio] Waiting for cpcd to be ready..." + +while ! grep -q "Daemon startup was successful" "${CPCD_LOG}" 2>/dev/null; do + + if ! kill -0 ${CPCD_PID} 2>/dev/null; then + echo "[otbr-radio] ERROR: cpcd exited before becoming ready. Check device and firmware." >&2 + cat "${CPCD_LOG}" >&2 + exit 1 + fi + + if [ ${cpcdWaitCount} -ge ${CPCD_WAIT_MAX} ]; then + echo "[otbr-radio] ERROR: cpcd did not become ready after ${CPCD_WAIT_MAX}s." >&2 + cat "${CPCD_LOG}" >&2 + exit 1 + fi + + sleep 1 + cpcdWaitCount=$((cpcdWaitCount + 1)) +done + +echo "[otbr-radio] cpcd ready (PID ${CPCD_PID}, waited ${cpcdWaitCount}s)." ############################################################################### # 5. Start bt_host_cpc_hci_bridge (CPC → virtual HCI serial device) diff --git a/docker/setup-thread-routes.sh b/docker/setup-thread-routes.sh new file mode 100755 index 00000000..10771f8f --- /dev/null +++ b/docker/setup-thread-routes.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# ------------------------------ tabstop = 4 ---------------------------------- +# +# If not stated otherwise in this file or this component's LICENSE file the +# following copyright and licenses apply: +# +# Copyright 2026 Comcast Cable Communications Management, LLC +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# +# ------------------------------ tabstop = 4 ---------------------------------- + +# +# Set up IPv6 routes so the barton container can reach Thread devices via +# the otbr-radio container. +# +# Thread mesh-local and on-mesh prefix addresses are all in the ULA range +# (fd00::/8). The Docker bridge subnet (e.g. fd00:xxxx:yyyy::/64) is also +# in this range but has a more-specific /64 route, so the /8 catch-all only +# matches Thread traffic. +# +# This script is idempotent — safe to run multiple times. +# It runs as part of the devcontainer postStartCommand so routes are +# restored after container restarts. +# + +set -e + +# Resolve the otbr-radio container's IPv6 address on the shared bridge. +# Docker's embedded DNS makes the service name "otbr-radio" resolvable +# from other containers on the same Compose network. +OTBR_IPV6=$(getent ahostsv6 otbr-radio 2>/dev/null | grep STREAM | head -1 | awk '{print $1}') + +if [ -z "${OTBR_IPV6}" ]; then + echo "[setup-thread-routes] otbr-radio not resolvable — Thread routes not configured." + echo "[setup-thread-routes] This is normal when the otbr-radio container is not running." + exit 0 +fi + +# Add a catch-all ULA route via the otbr-radio container. +# 'replace' is idempotent — updates the route if it already exists. +sudo ip -6 route replace fd00::/8 via "${OTBR_IPV6}" dev eth0 2>/dev/null || true + +echo "[setup-thread-routes] Thread route installed: fd00::/8 via ${OTBR_IPV6}" diff --git a/docker/setupDockerEnv.sh b/docker/setupDockerEnv.sh index b801031c..22e6bce1 100755 --- a/docker/setupDockerEnv.sh +++ b/docker/setupDockerEnv.sh @@ -80,16 +80,14 @@ IMAGE_TAG=$HIGHEST_BUILDER_TAG BUILDER_TAG_CHANGED=false existingRadioDevice="" existingBackboneIf="" -existingCpcRemoteHost="" -existingCpcRemotePort="" -existingCpcInstance="" +existingRadioPort="" +existingRadioHost="" if [ -f "$OUTFILE" ]; then existingRadioDevice=$(grep '^RADIO_DEVICE=' "$OUTFILE" | sed 's/^RADIO_DEVICE=//' || true) existingBackboneIf=$(grep '^BACKBONE_IF=' "$OUTFILE" | sed 's/^BACKBONE_IF=//' || true) - existingCpcRemoteHost=$(grep '^CPC_REMOTE_HOST=' "$OUTFILE" | sed 's/^CPC_REMOTE_HOST=//' || true) - existingCpcRemotePort=$(grep '^CPC_REMOTE_PORT=' "$OUTFILE" | sed 's/^CPC_REMOTE_PORT=//' || true) - existingCpcInstance=$(grep '^CPC_INSTANCE=' "$OUTFILE" | sed 's/^CPC_INSTANCE=//' || true) + existingRadioPort=$(grep '^RADIO_PORT=' "$OUTFILE" | sed 's/^RADIO_PORT=//' || true) + existingRadioHost=$(grep '^RADIO_HOST=' "$OUTFILE" | sed 's/^RADIO_HOST=//' || true) CURRENT_BUILDER_TAG=$(grep "CURRENT_BUILDER_TAG=" "$OUTFILE" | sed 's/CURRENT_BUILDER_TAG=//') @@ -196,7 +194,7 @@ echo "LIB_BARTON_SHARED_PATH=/usr/local/lib" >> $OUTFILE # Optional Thread real-radio variables (used by docker/compose.otbr-radio.yaml). # # RADIO_DEVICE: host path of the USB radio serial device. -# - Must be set explicitly when using the real-radio overlay. +# - Must be set explicitly when using a locally-attached radio. # - On shared build servers the forwarded radio may appear at a non-default # path (e.g. /dev/ttyACM8), so silently defaulting to /dev/ttyACM0 is not # safe. @@ -211,14 +209,13 @@ echo "LIB_BARTON_SHARED_PATH=/usr/local/lib" >> $OUTFILE # by exporting BACKBONE_IF before running setupDockerEnv.sh or dockerw, # e.g.: export BACKBONE_IF=enp6s0 # -# CPC_REMOTE_HOST: when set, connect to a remote cpcd via the CPC socket proxy. -# - If already present in docker/.env, preserve that value unless overridden -# by exporting CPC_REMOTE_HOST before running setupDockerEnv.sh or dockerw. -# -# CPC_REMOTE_PORT: base port for the CPC socket proxy (default: 15000). +# RADIO_PORT: TCP port for the remote serial tunnel (set by remote-serial.py). +# - When set, the otbr-radio container uses socat to bridge the TCP tunnel +# to a virtual serial device instead of using a local USB radio. # - If already present in docker/.env, preserve that value unless overridden. # -# CPC_INSTANCE: CPC daemon instance name (default: cpcd_0). +# RADIO_HOST: hostname/IP for the remote serial tunnel. +# - Defaults to host.docker.internal (Docker host gateway). # - If already present in docker/.env, preserve that value unless overridden. # # These variables are only consumed when compose.otbr-radio.yaml is included @@ -233,15 +230,13 @@ fi radioDeviceValue="${RADIO_DEVICE:-$existingRadioDevice}" backboneIfValue="${BACKBONE_IF:-${existingBackboneIf:-$detectedBackboneIf}}" -cpcRemoteHostValue="${CPC_REMOTE_HOST:-$existingCpcRemoteHost}" -cpcRemotePortValue="${CPC_REMOTE_PORT:-${existingCpcRemotePort:-15000}}" -cpcInstanceValue="${CPC_INSTANCE:-${existingCpcInstance:-cpcd_0}}" +radioPortValue="${RADIO_PORT:-$existingRadioPort}" +radioHostValue="${RADIO_HOST:-${existingRadioHost:-host.docker.internal}}" echo "RADIO_DEVICE=$radioDeviceValue" >> $OUTFILE echo "BACKBONE_IF=$backboneIfValue" >> $OUTFILE -echo "CPC_REMOTE_HOST=$cpcRemoteHostValue" >> $OUTFILE -echo "CPC_REMOTE_PORT=$cpcRemotePortValue" >> $OUTFILE -echo "CPC_INSTANCE=$cpcInstanceValue" >> $OUTFILE +echo "RADIO_PORT=$radioPortValue" >> $OUTFILE +echo "RADIO_HOST=$radioHostValue" >> $OUTFILE ############################################################################## # Ensure the container network exists diff --git a/docs/REMOTE_RADIO_FOR_DEVELOPMENT.md b/docs/REMOTE_RADIO_FOR_DEVELOPMENT.md index 42124fa9..6d532bd5 100644 --- a/docs/REMOTE_RADIO_FOR_DEVELOPMENT.md +++ b/docs/REMOTE_RADIO_FOR_DEVELOPMENT.md @@ -66,8 +66,8 @@ reads this file at startup to configure the Matter SDK's BLE adapter via The host machine may have its own built-in Bluetooth adapter (e.g. `hci0`). The radio's BLE adapter appears as a separate HCI device with a distinct index. A background monitor in the entrypoint watches `btattach` and automatically -restarts it if the USB connection drops (e.g. USB-IP reconnect), updating the -`ble_adapter_id` file accordingly. +restarts it if the connection drops, updating the `ble_adapter_id` file +accordingly. --- @@ -100,126 +100,135 @@ Download the firmware image appropriate for the BRD2703 (EFR32MG24) in either --- -## USB-IP: Forwarding the Radio to a Remote Dev Server +## Radio Connection Methods -If your devcontainer runs on a **remote server** (common with VS Code -Remote-SSH) but the BRD2703 is plugged into your **local laptop**, use USB-IP -to forward the USB device over the network. +Two methods are supported for connecting the radio to the otbr-radio container: -Automation scripts are provided in `scripts/remote-radio/`. They handle -package installation, kernel module loading, device detection, per-user port -isolation on shared servers, and cleanup. +| Method | When to use | Environment variable | +|--------|-------------|---------------------| +| **Local USB** | Radio is plugged directly into the Docker host | `RADIO_DEVICE=/dev/ttyACM0` | +| **Remote serial tunnel** | Radio is on your laptop, Docker runs on a remote server | `RADIO_PORT=21000` | -### Quick Start (Scripted) +### Local USB Radio -**Step 1 — On your laptop** (where the radio is plugged in): +If the radio is plugged directly into the machine running Docker: ```bash -scripts/remote-radio/usbip-attach-local.sh user@devserver.example.com +RADIO_DEVICE=/dev/ttyACM0 ./dockerw -T bash ``` -This auto-detects the radio, binds it, starts the USB-IP daemon, and opens an -SSH reverse tunnel. Leave the terminal open. +### Remote Serial Tunnel -**Step 2 — On the remote server** (where Docker runs): +If your devcontainer runs on a **remote server** (common with VS Code +Remote-SSH) but the BRD2703 is plugged into your **local workstation**, use +`remote-serial.py` to forward the serial port over an SSH tunnel. -```bash -scripts/remote-radio/usbip-attach-remote.sh ``` - -This auto-detects the radio from the remote USB listing, attaches it, waits -for `/dev/ttyACM*` to appear, and prints the `RADIO_DEVICE` path to use. - -**Step 3 — Validate** (inside the Barton container, after containers are up): - -```bash -scripts/remote-radio/usbip-validate.sh + Workstation Dev Server (Docker host) + ┌──────────────────┐ ┌───────────────────────────────┐ + │ BRD2703 radio │ │ otbr-radio container │ + │ /dev/ttyACM0 │ │ ┌─────────────────────────┐ │ + │ │ │ │ │ socat (TCP→PTY bridge) │ │ + │ ▼ │ │ │ /dev/ttyRadio │ │ + │ remote-serial.py │ SSH tunnel │ │ │ │ │ + │ (serial→TCP │───────────────▶│ │ ▼ │ │ + │ relay + SSH -R) │ port 21000 │ │ cpcd │ │ + │ │ │ │ (same as local USB) │ │ + └──────────────────┘ │ └─────────────────────────┘ │ + └───────────────────────────────┘ ``` -Checks the entire chain: D-Bus socket, container processes (cpcd, otbr-agent, -bt_host_cpc_hci_bridge, btattach, bluetoothd), Thread network state, BLE HCI -device, and `DBUS_SYSTEM_BUS_ADDRESS`. +The tunnel is self-healing — if the SSH connection drops, `remote-serial.py` +automatically reconnects. The socat bridge inside the container also reconnects +automatically. -**Teardown** — when done: +#### Prerequisites -```bash -# On the remote server: -scripts/remote-radio/usbip-detach-remote.sh +1. **Python 3.7+** and **pyserial** on the workstation: -# On your laptop: -scripts/remote-radio/usbip-detach-local.sh -``` + ```bash + pip install pyserial + ``` -### Port Isolation on Shared Servers +2. **SSH key authentication** from your workstation to the dev server (no + password prompts). -The scripts use per-user loopback addresses to avoid conflicts when multiple -developers share the same remote server. Each user's SSH tunnel binds to a -unique address in the `127.0.0.0/8` range derived from their UID: +3. **`GatewayPorts clientspecified`** in the dev server's sshd config: -``` -loopback_addr = 127.0.. -``` + ``` + # /etc/ssh/sshd_config on the dev server + GatewayPorts clientspecified + ``` + + Then restart sshd: `sudo systemctl restart sshd` -For example, UID 1000 → `127.0.3.232`. All tunnels use the standard USB-IP -port 3240, so both `usbip list` and `usbip attach` work without any -non-standard flags. +#### Quick Start -> **Prerequisite**: The remote server's sshd must have `GatewayPorts clientspecified` -> enabled so that SSH can bind the reverse tunnel to the per-user loopback address -> instead of defaulting to `127.0.0.1`. Add this to `/etc/ssh/sshd_config` and -> restart sshd: -> ``` -> GatewayPorts clientspecified -> ``` +**Step 1 — On your workstation** (where the radio is plugged in): -Each user's attach and detach scripts only touch their own state files and -vhci ports. There is no cross-user interference. +```bash +python3 scripts/remote-radio/remote-serial.py @ +``` -### Manual USB-IP Setup +The script auto-detects the radio, starts a TCP relay, and opens an SSH reverse +tunnel. Leave this terminal open. -If you prefer to run the commands manually: +Example output: -
-Click to expand manual instructions +``` +[remote-serial] OK: Auto-detected radio: /dev/ttyACM0 (Silicon Labs CP210x) +[remote-serial] OK: Remote UID: 1000 +[remote-serial] OK: Tunnel port: 21000 +[remote-serial] OK: TCP server listening on 127.0.0.1:21000 + +======================================= + REMOTE SERIAL TUNNEL ACTIVE +======================================= + Radio: /dev/ttyACM0 + Local TCP: 127.0.0.1:21000 + Tunnel port: 21000 on remote (all interfaces) +======================================= + + RADIO_PORT=21000 +``` -#### On the laptop (USB-IP server) +**Step 2 — On the dev server** (or in your devcontainer): ```bash -sudo apt install linux-tools-generic linux-cloud-tools-generic -sudo modprobe usbip_core usbip_host vhci_hcd +RADIO_PORT=21000 ./dockerw -T bash +``` -# Find and bind the radio -# Look for Silicon Labs CP210x (10c4:ea60) or SEGGER J-Link (1366:0105) -usbip list -l -sudo usbip bind -b +Or add `RADIO_PORT=21000` to `docker/.env`. -# Start the daemon -sudo usbipd -D +**Step 3 — Validate** (inside the Barton or otbr-radio container): -# Compute your per-user loopback: 127.0.. -# Example for remote UID 1000: 127.0.3.232 -ssh -R 127.0.3.232:3240:localhost:3240 @ +```bash +scripts/remote-radio/validate.sh ``` -#### On the remote server (USB-IP client) +#### Port Isolation on Shared Servers -```bash -sudo apt install linux-tools-generic hwdata -sudo modprobe vhci_hcd +The tunnel port is computed as `20000 + UID` on the remote server. Each +developer on a shared server gets a unique port automatically: + +| UID | Port | +|-----|------| +| 1000 | 21000 | +| 1001 | 21001 | +| 1002 | 21002 | -# List and attach using your per-user loopback address -usbip list -r 127.0.3.232 -sudo usbip attach -r 127.0.3.232 -b +#### Command-Line Options -# Confirm -ls -la /dev/ttyACM* ``` +usage: remote-serial.py [-h] [--port PORT] [--baud BAUD] ssh_target -
+positional arguments: + ssh_target SSH target (e.g. user@hostname) -> **Tip**: Always set `RADIO_DEVICE` explicitly, even if the device is at -> `/dev/ttyACM0`. On shared servers, multiple radios may be attached at -> different paths. +options: + --port PORT Serial port path (auto-detected if omitted) + --baud BAUD Baud rate (default: 115200) +``` --- @@ -227,11 +236,14 @@ ls -la /dev/ttyACM* ### CLI (`dockerw`) -Use the `-T` flag to add `docker/compose.otbr-radio.yaml` to the compose stack. -Set `RADIO_DEVICE` explicitly: +Use the `-T` flag to add `docker/compose.otbr-radio.yaml` to the compose stack: ```bash +# Local USB radio: RADIO_DEVICE=/dev/ttyACM0 ./dockerw -T bash + +# Remote serial tunnel: +RADIO_PORT=21000 ./dockerw -T bash ``` To override the backbone interface: @@ -250,7 +262,7 @@ docker compose -f docker/compose.yaml -f docker/compose.otbr-radio.yaml logs -f Expected log progression: -1. `Using USB radio device: /dev/ttyACM0` +1. `Using USB radio device: /dev/ttyACM0` (or `Remote serial mode: connecting to ...`) 2. `Connected to Secondary` / `Secondary CPC v4.4.6` 3. `Daemon startup was successful` 4. `bt_host_cpc_hci_bridge ready` @@ -264,15 +276,20 @@ Expected log progression: manual edit to `.devcontainer/devcontainer.json` is required. The `otbr-radio` container starts automatically when the devcontainer launches. -If `RADIO_DEVICE` is not set it exits with a clear error in its own logs, but -**the `barton` devcontainer is unaffected and starts normally**. +If neither `RADIO_DEVICE` nor `RADIO_PORT` is set, it exits with a clear error +in its own logs, but **the `barton` devcontainer is unaffected and starts +normally**. -To enable real radio support, set `RADIO_DEVICE` in `docker/.env` after running -`docker/setupDockerEnv.sh`, or export it in your host shell before opening -VS Code: +To enable real radio support, set the appropriate variable in `docker/.env` +after running `docker/setupDockerEnv.sh`, or export it in your host shell +before opening VS Code: ```bash +# Local radio: export RADIO_DEVICE=/dev/ttyACM0 + +# Remote tunnel: +export RADIO_PORT=21000 ``` Then rebuild the devcontainer (**Dev Containers: Rebuild Container**). @@ -281,21 +298,25 @@ Then rebuild the devcontainer (**Dev Containers: Rebuild Container**). ## Verifying the Full Stack -The fastest way to verify everything is working is to run the validation script -from inside the Barton container: +Run the validation script from inside the Barton container: ```bash -scripts/remote-radio/usbip-validate.sh +scripts/remote-radio/validate.sh ``` This checks: -- D-Bus socket exists and is reachable -- otbr-radio container processes (cpcd, otbr-agent, bt_host_cpc_hci_bridge, - btattach, bluetoothd) -- Thread network state via D-Bus -- BLE adapter ID file and HCI device presence -- `DBUS_SYSTEM_BUS_ADDRESS` is set correctly +- Container environment (privileged mode, required binaries) +- D-Bus socket and daemon +- Radio connection (local USB or remote serial tunnel) +- CPC daemon and sockets +- BLE chain (bridge → btattach → bluetoothd) +- HCI adapter and transport health +- BLE scanning capability +- otbr-agent and Thread interface +- Known pitfalls + +Options: `--json` for machine-readable output, `--fix` for auto-remediation. ### Manual D-Bus Verification @@ -360,15 +381,9 @@ the index of the radio's adapter. docker compose -f docker/compose.yaml -f docker/compose.otbr-radio.yaml rm -sf otbr-radio ``` -### Detaching the USB-IP device +### Stopping the remote serial tunnel -```bash -# On the remote server: -scripts/remote-radio/usbip-detach-remote.sh - -# On your laptop (also close the SSH tunnel terminal): -scripts/remote-radio/usbip-detach-local.sh -``` +Press `Ctrl-C` in the terminal running `remote-serial.py` on your workstation. --- @@ -377,7 +392,9 @@ scripts/remote-radio/usbip-detach-local.sh | Symptom | Cause | Fix | |---------|-------|-----| | `InitCommissioner` fails with `CHIP_ERROR_INCORRECT_STATE` | Default `CertifierOperationalCredentialsIssuer` requires an auth token | Build with dev platform: `cmake -C config/cmake/platforms/dev/linux.cmake ..` to use `SelfSignedCertifierOperationalCredentialsIssuer` | -| Only `hci0` visible, no `hci1` | `btattach` failed or USB-IP disconnected | Run `scripts/remote-radio/usbip-validate.sh` to diagnose; the entrypoint's BLE monitor should auto-restart `btattach` | +| Only `hci0` visible, no `hci1` | `btattach` failed or radio disconnected | Run `scripts/remote-radio/validate.sh` to diagnose; the entrypoint's BLE monitor should auto-restart `btattach` | | `ble_adapter_id` contains `0` | Radio BLE bridge didn't create a new HCI device | Check otbr-radio logs for `bt_host_cpc_hci_bridge` errors | | OpenSSL link errors during build | Stale OpenSSL 1.1.1 in `/usr/local/openssl/` | Remove it: `sudo rm -rf /usr/local/openssl /usr/local/lib/libcurl*` and reconfigure | | `DBUS_SYSTEM_BUS_ADDRESS` not set | Barton connects to system D-Bus instead of otbr-radio's private bus | Export it or use `dockerw -T` which injects it automatically | +| Remote tunnel not reachable | SSH tunnel not binding or IPv6/IPv4 mismatch | Check `remote-serial.py` output; ensure `GatewayPorts clientspecified` in sshd_config | +| socat/ttyRadio not created | Remote serial tunnel not yet running | Start `remote-serial.py` on workstation before starting containers | diff --git a/scripts/remote-radio/cpc_remote_access.sh b/scripts/remote-radio/cpc_remote_access.sh deleted file mode 100755 index d46da702..00000000 --- a/scripts/remote-radio/cpc_remote_access.sh +++ /dev/null @@ -1,257 +0,0 @@ -#!/bin/bash -# -# CPC Remote Access - Start all socket proxies for remote libcpc access -# -# This script starts proxy processes to tunnel all cpcd Unix sockets over TCP, -# allowing libcpc clients to run on remote machines. -# -# Usage: -# On cpcd host: ./cpc_remote_access.sh server [instance_name] [base_port] -# On remote client: ./cpc_remote_access.sh client [instance_name] [base_port] -# -# Port assignments (from base_port): -# base_port + 0: ctrl.cpcd.sock -# base_port + 1: reset.cpcd.sock -# base_port + 100-355: ep0-ep255.cpcd.sock -# base_port + 400-655: ep0-ep255.event.cpcd.sock -# - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROXY_SCRIPT="$SCRIPT_DIR/cpc_socket_proxy.py" - -MODE="${1:-}" -INSTANCE="${3:-cpcd_0}" -BASE_PORT="${4:-15000}" -SOCKET_DIR="${CPC_SOCKET_DIR:-/dev/shm}" - -usage() { - echo "Usage:" - echo " On cpcd host: $0 server [instance_name] [base_port]" - echo " On remote client: $0 client [instance_name] [base_port]" - echo "" - echo "Options:" - echo " instance_name CPC daemon instance name (default: cpcd_0)" - echo " base_port Starting port number (default: 15000)" - echo "" - echo "Environment:" - echo " CPC_SOCKET_DIR Socket directory (default: /dev/shm)" - exit 1 -} - -cleanup() { - echo "Stopping all proxy processes..." - jobs -p | xargs -r kill 2>/dev/null || true - wait 2>/dev/null || true - echo "Cleanup complete" -} - -trap cleanup EXIT INT TERM - -start_server() { - local socket_base="$SOCKET_DIR/cpcd/$INSTANCE" - - if [[ ! -d "$socket_base" ]]; then - echo "Error: Socket directory $socket_base does not exist" - echo "Is cpcd running with instance name '$INSTANCE'?" - exit 1 - fi - - echo "Starting CPC remote access server for instance '$INSTANCE'" - echo "Socket directory: $socket_base" - echo "Base port: $BASE_PORT" - echo "" - - # Track socket_name -> PID so we can detect and restart dead proxies. - declare -A PROXIED_PIDS - - start_proxy() { - local sock="$1" port="$2" - local filename - filename=$(basename "$sock") - python3 "$PROXY_SCRIPT" server -s "$sock" -p "$port" & - PROXIED_PIDS["$filename"]=$! - echo " $filename -> port $port (PID $!)" - } - - # Control socket - echo "Starting core socket proxies:" - start_proxy "$socket_base/ctrl.cpcd.sock" "$BASE_PORT" - - # Reset socket - start_proxy "$socket_base/reset.cpcd.sock" "$((BASE_PORT + 1))" - - # proxy_endpoint_sockets scans the socket directory and starts a proxy for - # any endpoint socket that doesn't have one yet. It also restarts proxies - # that have died. cpcd creates endpoint sockets on-demand when clients - # open endpoints, so we must poll for new ones. - proxy_endpoint_sockets() { - for sock in "$socket_base"/ep*.cpcd.sock; do - [[ -S "$sock" ]] || continue - local filename - filename=$(basename "$sock") - - # If we already started a proxy, check if it's still alive. - if [[ -n "${PROXIED_PIDS[$filename]+x}" ]]; then - local pid="${PROXIED_PIDS[$filename]}" - - if kill -0 "$pid" 2>/dev/null; then - continue - fi - - echo "Proxy for $filename (PID $pid) died — restarting" - unset 'PROXIED_PIDS[$filename]' - fi - - if [[ "$filename" =~ ^ep([0-9]+)\.event\.cpcd\.sock$ ]]; then - local ep_num="${BASH_REMATCH[1]}" - local event_port=$((BASE_PORT + 400 + ep_num)) - start_proxy "$sock" "$event_port" - elif [[ "$filename" =~ ^ep([0-9]+)\.cpcd\.sock$ ]]; then - local ep_num="${BASH_REMATCH[1]}" - local ep_port=$((BASE_PORT + 100 + ep_num)) - start_proxy "$sock" "$ep_port" - fi - done - } - - # Also monitor the core proxies (ctrl, reset) and restart them if dead. - check_core_proxies() { - for filename in ctrl.cpcd.sock reset.cpcd.sock; do - [[ -n "${PROXIED_PIDS[$filename]+x}" ]] || continue - local pid="${PROXIED_PIDS[$filename]}" - - if ! kill -0 "$pid" 2>/dev/null; then - echo "Core proxy for $filename (PID $pid) died — restarting" - unset 'PROXIED_PIDS[$filename]' - local port - case "$filename" in - ctrl.cpcd.sock) port="$BASE_PORT" ;; - reset.cpcd.sock) port="$((BASE_PORT + 1))" ;; - esac - start_proxy "$socket_base/$filename" "$port" - fi - done - } - - # Pre-start TCP listeners for well-known CPC endpoints that remote - # clients need. cpcd creates endpoint sockets on-demand when a client - # opens them, but the proxy TCP listener must be ready BEFORE the - # remote client tries to connect. The proxy server retries the local - # unix socket connection, giving cpcd time to create it after the - # remote client opens the endpoint via the control socket. - # - # Silicon Labs CPC v4.4.x well-known endpoints: - # 12 SL_CPC_ENDPOINT_15_4 (802.15.4 / Spinel / OpenThread) - # 14 SL_CPC_ENDPOINT_BLUETOOTH (Bluetooth RCP / HCI bridge) - WELL_KNOWN_ENDPOINTS="12 14" - echo "" - echo "Starting well-known endpoint proxies:" - - for ep in ${WELL_KNOWN_ENDPOINTS}; do - start_proxy "$socket_base/ep${ep}.cpcd.sock" "$((BASE_PORT + 100 + ep))" - start_proxy "$socket_base/ep${ep}.event.cpcd.sock" "$((BASE_PORT + 400 + ep))" - done - - # Proxy any other endpoint sockets that already exist. - proxy_endpoint_sockets - - echo "" - echo "Server started. Watching for new/dead endpoint sockets..." - echo "" - echo "On the remote client, run:" - echo " $0 client $INSTANCE $BASE_PORT" - - # check_wellknown_proxies — restart pre-started endpoint proxies if dead. - # Unlike proxy_endpoint_sockets (which only checks existing socket files), - # this always checks the well-known endpoints regardless of whether the - # socket file exists on disk. - check_wellknown_proxies() { - for ep in ${WELL_KNOWN_ENDPOINTS}; do - - for suffix in ".cpcd.sock" ".event.cpcd.sock"; do - local filename="ep${ep}${suffix}" - [[ -n "${PROXIED_PIDS[$filename]+x}" ]] || continue - local pid="${PROXIED_PIDS[$filename]}" - - if ! kill -0 "$pid" 2>/dev/null; then - echo "Well-known proxy for $filename (PID $pid) died — restarting" - unset 'PROXIED_PIDS[$filename]' - local port - - if [[ "$suffix" == ".event.cpcd.sock" ]]; then - port=$((BASE_PORT + 400 + ep)) - else - port=$((BASE_PORT + 100 + ep)) - fi - start_proxy "$socket_base/$filename" "$port" - fi - done - done - } - - # Poll for new endpoint sockets and restart dead proxies. - while true; do - sleep 2 - proxy_endpoint_sockets - check_core_proxies - check_wellknown_proxies - done -} - -start_client() { - local remote_host="$2" - - if [[ -z "$remote_host" ]]; then - echo "Error: Remote host not specified" - usage - fi - - local socket_base="$SOCKET_DIR/cpcd/$INSTANCE" - - echo "Starting CPC remote access client for instance '$INSTANCE'" - echo "Remote host: $remote_host" - echo "Socket directory: $socket_base" - echo "Base port: $BASE_PORT" - echo "" - - # Create socket directory - mkdir -p "$socket_base" - - # Control socket - echo "Starting proxy for ctrl.cpcd.sock from port $BASE_PORT" - python3 "$PROXY_SCRIPT" client -s "$socket_base/ctrl.cpcd.sock" -H "$remote_host" -p "$BASE_PORT" & - - # Reset socket - local reset_port=$((BASE_PORT + 1)) - echo "Starting proxy for reset.cpcd.sock from port $reset_port" - python3 "$PROXY_SCRIPT" client -s "$socket_base/reset.cpcd.sock" -H "$remote_host" -p "$reset_port" & - - echo "" - echo "Client started with core sockets (ctrl, reset)." - echo "Endpoint sockets will be created on-demand." - echo "" - echo "To add endpoint proxies manually:" - echo " # For endpoint N data socket:" - echo " python3 $PROXY_SCRIPT client -s $socket_base/epN.cpcd.sock -H $remote_host -p \$((BASE_PORT + 100 + N))" - echo "" - echo " # For endpoint N event socket:" - echo " python3 $PROXY_SCRIPT client -s $socket_base/epN.event.cpcd.sock -H $remote_host -p \$((BASE_PORT + 400 + N))" - echo "" - echo "Press Ctrl+C to stop." - - wait -} - -case "$MODE" in - server) - start_server "$@" - ;; - client) - start_client "$@" - ;; - *) - usage - ;; -esac diff --git a/scripts/remote-radio/cpc_socket_proxy.py b/scripts/remote-radio/cpc_socket_proxy.py deleted file mode 100644 index 092d7427..00000000 --- a/scripts/remote-radio/cpc_socket_proxy.py +++ /dev/null @@ -1,312 +0,0 @@ -#!/usr/bin/env python3 -""" -CPC Socket Proxy - Tunnels SEQPACKET Unix sockets over TCP with message framing. - -Self-healing proxy that automatically recovers from: - - TCP connection drops (client or server container restart) - - Unix socket disconnections (cpcd or application restart) - - Stale sessions from previous connections - -Each message is framed with a 4-byte network-order length prefix to preserve -SEQPACKET message boundaries over TCP. - -Server mode (on cpcd host): - - Listens on a TCP port and relays to/from a local CPC Unix socket - - Only one active session per endpoint (CPC limitation); a new TCP client - automatically terminates the previous session - - TCP keepalive detects dead clients within ~25 seconds - -Client mode (on remote host): - - Creates a local Unix SEQPACKET socket mimicking cpcd - - Each local connection gets a fresh TCP session to the server - - Retries TCP connections (30 attempts) to handle server-side startup races - -Usage: - Server: ./cpc_socket_proxy.py server -s /dev/shm/cpcd/cpcd_0/ctrl.cpcd.sock -p 5000 - Client: ./cpc_socket_proxy.py client -s /dev/shm/cpcd/cpcd_0/ctrl.cpcd.sock -H -p 5000 -""" - -import argparse -import os -import socket -import struct -import sys -import threading -import time -from pathlib import Path - - -def enable_tcp_keepalive(sock: socket.socket): - """Enable aggressive TCP keepalive for fast stale-connection detection.""" - sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10) - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 5) - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) - - -def close_sockets(*sockets): - """Shut down and close sockets, ignoring errors.""" - for s in sockets: - try: - s.shutdown(socket.SHUT_RDWR) - except OSError: - pass - - try: - s.close() - except OSError: - pass - - -def recv_framed(tcp_sock: socket.socket) -> bytes | None: - """Receive a length-prefixed message from TCP socket.""" - header = b'' - - while len(header) < 4: - chunk = tcp_sock.recv(4 - len(header)) - - if not chunk: - return None - - header += chunk - - length = struct.unpack('!I', header)[0] - - if length == 0: - return b'' - - data = b'' - - while len(data) < length: - chunk = tcp_sock.recv(min(length - len(data), 65536)) - - if not chunk: - return None - - data += chunk - - return data - - -def send_framed(tcp_sock: socket.socket, data: bytes) -> bool: - """Send a length-prefixed message to TCP socket.""" - try: - header = struct.pack('!I', len(data)) - tcp_sock.sendall(header + data) - return True - except (BrokenPipeError, ConnectionResetError, OSError): - return False - - -def relay_unix_to_tcp(unix_sock, tcp_sock, name, done): - """Forward Unix -> TCP. Closes both sockets on exit so the peer exits.""" - try: - while True: - data = unix_sock.recv(65536) - - if not data: - break - - if not send_framed(tcp_sock, data): - break - except (ConnectionResetError, BrokenPipeError, OSError): - pass - finally: - close_sockets(unix_sock, tcp_sock) - done.set() - - print(f"[{name}] Unix->TCP stopped", flush=True) - - -def relay_tcp_to_unix(tcp_sock, unix_sock, name, done): - """Forward TCP -> Unix. Closes both sockets on exit so the peer exits.""" - try: - while True: - data = recv_framed(tcp_sock) - - if data is None: - break - - unix_sock.send(data) - except (ConnectionResetError, BrokenPipeError, OSError): - pass - finally: - close_sockets(unix_sock, tcp_sock) - done.set() - - print(f"[{name}] TCP->Unix stopped", flush=True) - - -def run_relay_pair(unix_sock, tcp_sock, name): - """Start bidirectional relay threads and block until the session ends.""" - done = threading.Event() - - t1 = threading.Thread( - target=relay_unix_to_tcp, - args=(unix_sock, tcp_sock, name, done), - daemon=True, - ) - t2 = threading.Thread( - target=relay_tcp_to_unix, - args=(tcp_sock, unix_sock, name, done), - daemon=True, - ) - t1.start() - t2.start() - - # Block until either direction fails. - done.wait() - - # Ensure both sockets are closed so the surviving thread also exits. - close_sockets(unix_sock, tcp_sock) - t1.join(timeout=5) - t2.join(timeout=5) - - print(f"[{name}] Session ended", flush=True) - - -def run_server(socket_path: str, port: int, bind_addr: str = '0.0.0.0'): - """ - Server mode: accept TCP clients and relay to the local CPC Unix socket. - - Multiple clients may connect concurrently (e.g. both the HCI bridge and - otbr-agent need their own ctrl session). Each TCP client gets its own - independent Unix socket connection to cpcd and a dedicated relay pair. - """ - - name = os.path.basename(socket_path) - print(f"[{name}] Server: TCP :{port} <-> {socket_path}", flush=True) - - tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - tcp_server.bind((bind_addr, port)) - tcp_server.listen(5) - - while True: - tcp_client, addr = tcp_server.accept() - enable_tcp_keepalive(tcp_client) - print(f"[{name}] TCP connection from {addr}", flush=True) - - # cpcd creates endpoint sockets on-demand when a client opens - # them via the control socket. In the remote-access scenario the - # open-endpoint command arrives over the proxied ctrl socket, so - # the endpoint socket may not exist yet when the TCP client for - # that endpoint connects moments later. Retry with backoff to - # give cpcd time to create the socket (the firmware can take - # several seconds to make an endpoint available after a reset). - unix_sock = None - max_attempts = 20 - retry_delay = 0.5 - - for attempt in range(max_attempts): - - try: - s = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) - s.connect(socket_path) - unix_sock = s - break - except Exception as e: - s.close() - - if attempt < max_attempts - 1: - time.sleep(retry_delay) - else: - print( - f"[{name}] Unix connect to {socket_path} " - f"failed after {attempt + 1} attempts: {e}", - flush=True, - ) - - if unix_sock is None: - tcp_client.close() - continue - - def session(us=unix_sock, ts=tcp_client, n=name): - run_relay_pair(us, ts, n) - - threading.Thread(target=session, daemon=True).start() - - -def run_client(socket_path: str, host: str, port: int): - """ - Client mode: create a local CPC-compatible Unix socket and relay to TCP. - - When a relay session dies (TCP drop, Unix disconnect, container restart), - the proxy goes back to accept() and waits for the next local client. - The CPC library inside the application will reconnect automatically. - """ - name = os.path.basename(socket_path) - print(f"[{name}] Client: {socket_path} <-> TCP {host}:{port}", flush=True) - - socket_dir = os.path.dirname(socket_path) - Path(socket_dir).mkdir(parents=True, exist_ok=True) - - try: - os.unlink(socket_path) - except FileNotFoundError: - pass - - unix_server = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) - unix_server.bind(socket_path) - unix_server.listen(5) - os.chmod(socket_path, 0o777) - - while True: - unix_client, _ = unix_server.accept() - print(f"[{name}] Local client connected", flush=True) - - tcp_sock = None - - for attempt in range(30): - - try: - tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - tcp_sock.connect((host, port)) - enable_tcp_keepalive(tcp_sock) - break - except Exception as e: - tcp_sock.close() - tcp_sock = None - - if attempt < 29: - time.sleep(1) - else: - print(f"[{name}] TCP connect to {host}:{port} " - f"failed after 30 attempts: {e}", flush=True) - - if tcp_sock is None: - unix_client.close() - continue - - print(f"[{name}] Connected to {host}:{port}", flush=True) - - def session(us=unix_client, ts=tcp_sock, n=name): - run_relay_pair(us, ts, n) - - threading.Thread(target=session, daemon=True).start() - - -def main(): - parser = argparse.ArgumentParser(description='CPC Socket Proxy') - subparsers = parser.add_subparsers(dest='mode', required=True) - - server_parser = subparsers.add_parser('server', help='Run as server (on cpcd host)') - server_parser.add_argument('--socket', '-s', required=True, help='Unix socket path') - server_parser.add_argument('--port', '-p', type=int, required=True, help='TCP port') - server_parser.add_argument('--bind', '-b', default='0.0.0.0', help='Bind address') - - client_parser = subparsers.add_parser('client', help='Run as client (on remote host)') - client_parser.add_argument('--socket', '-s', required=True, help='Unix socket path to create') - client_parser.add_argument('--host', '-H', required=True, help='Remote cpcd host') - client_parser.add_argument('--port', '-p', type=int, required=True, help='Remote TCP port') - - args = parser.parse_args() - - if args.mode == 'server': - run_server(args.socket, args.port, args.bind) - else: - run_client(args.socket, args.host, args.port) - - -if __name__ == '__main__': - main() diff --git a/scripts/remote-radio/remote-serial.py b/scripts/remote-radio/remote-serial.py new file mode 100755 index 00000000..65ccd753 --- /dev/null +++ b/scripts/remote-radio/remote-serial.py @@ -0,0 +1,723 @@ +#!/usr/bin/env python3 +# ------------------------------ tabstop = 4 ---------------------------------- +# +# If not stated otherwise in this file or this component's LICENSE file the +# following copyright and licenses apply: +# +# Copyright 2026 Comcast Cable Communications Management, LLC +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# +# ------------------------------ tabstop = 4 ---------------------------------- + +""" +Remote Serial Tunnel — forward a local Silicon Labs radio's serial port to a +remote dev server via SSH port forwarding. + +Run this on the WORKSTATION (Windows or Linux) where the BRD2703 xG24 radio is +physically connected via USB. It: + + 1. Auto-detects the Silicon Labs radio serial port. + 2. Computes a per-user TCP port from the remote UID (base 20000 + UID) and + a per-user loopback address (127.0..) to avoid conflicts on + shared servers. + 3. Starts a local TCP server that relays bytes between the serial port and + TCP clients. + 4. Opens an SSH reverse tunnel so the remote dev server can reach the + local TCP server. The otbr-radio container's entrypoint connects + socat to this port, creating a virtual serial device for cpcd. + 5. Self-heals: reconnects serial and SSH on failure with backoff. + +Requirements (workstation): + - Python 3.8+ + - pyserial (pip install pyserial) + - ssh client on PATH + +Usage: + python remote-serial.py user@devserver.example.com + python remote-serial.py user@devserver.example.com --port /dev/ttyACM0 + python remote-serial.py user@devserver.example.com --port COM3 + +The script keeps running until Ctrl-C. The SSH tunnel and serial relay +are restarted automatically if either side disconnects. + +See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for full setup instructions. +""" + +import argparse +import datetime +import os +import platform +import re +import signal +import socket +import subprocess +import sys +import threading +import time + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +SILABS_VID = "10C4" +SILABS_PID = "EA60" +SEGGER_VID = "1366" +SEGGER_PID = "0105" +SERIAL_BAUD = 115200 +BASE_PORT = 20000 +RELAY_BUF_SIZE = 4096 + +# --------------------------------------------------------------------------- +# Logging helpers +# --------------------------------------------------------------------------- +BOLD = "\033[1m" +GREEN = "\033[0;32m" +RED = "\033[0;31m" +YELLOW = "\033[1;33m" +NC = "\033[0m" + +# Disable ANSI on Windows unless the terminal supports it. +if platform.system() == "Windows": + try: + os.system("") # enable VT100 on Windows 10+ + except Exception: + BOLD = GREEN = RED = YELLOW = NC = "" + + +def _ts() -> str: + """Return a compact timestamp for log lines.""" + return datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3] + + +def info(msg: str) -> None: + print(f"{_ts()} {BOLD}[remote-serial]{NC} {msg}", flush=True) + + +def ok(msg: str) -> None: + print(f"{_ts()} {GREEN}[remote-serial] OK:{NC} {msg}", flush=True) + + +def warn(msg: str) -> None: + print(f"{_ts()} {YELLOW}[remote-serial] WARNING:{NC} {msg}", file=sys.stderr, flush=True) + + +def fail(msg: str) -> None: + print(f"{_ts()} {RED}[remote-serial] ERROR:{NC} {msg}", file=sys.stderr, flush=True) + + +def die(msg: str) -> None: + fail(msg) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Serial port detection +# --------------------------------------------------------------------------- +def find_radio_port() -> str | None: + """Auto-detect the Silicon Labs radio serial port. + + If multiple matching devices are found, returns None so the caller + can report the ambiguity and require --port. + """ + try: + from serial.tools.list_ports import comports + except ImportError: + die( + "pyserial is not installed. Install it with:\n" + " pip install pyserial" + ) + + target_ids = [ + (SILABS_VID.lower(), SILABS_PID.lower()), + (SEGGER_VID.lower(), SEGGER_PID.lower()), + ] + + matches = [] + + for port_info in comports(): + vid = f"{port_info.vid:04x}" if port_info.vid else "" + pid = f"{port_info.pid:04x}" if port_info.pid else "" + + for t_vid, t_pid in target_ids: + + if vid == t_vid and pid == t_pid: + matches.append(port_info) + break + + if len(matches) == 1: + return matches[0].device + + if len(matches) > 1: + fail("Multiple Silicon Labs radios found:") + + for m in matches: + info(f" {m.device} ({m.description})") + + die("Specify which radio to use with --port /dev/ttyACM0") + + return None + + +# --------------------------------------------------------------------------- +# SSH helpers +# --------------------------------------------------------------------------- +def parse_ssh_target(target: str) -> tuple[str, str]: + """Split user@host into (user, host). If no user@, use current user.""" + if "@" in target: + user, host = target.split("@", 1) + else: + user = os.environ.get("USER") or os.environ.get("USERNAME") or "unknown" + host = target + + return user, host + + +def get_remote_uid(ssh_target: str) -> int: + """Fetch the remote user's UID via SSH.""" + info(f"Resolving remote UID for {ssh_target}...") + + try: + result = subprocess.run( + ["ssh", "-o", "ConnectTimeout=10", ssh_target, "id -u"], + capture_output=True, + text=True, + timeout=30, + ) + except FileNotFoundError: + die("ssh is not available on PATH.") + except subprocess.TimeoutExpired: + die(f"SSH to {ssh_target} timed out.") + + if result.returncode != 0: + die( + f"Cannot SSH to {ssh_target}.\n" + f" stderr: {result.stderr.strip()}" + ) + + uid_str = result.stdout.strip() + + if not uid_str.isdigit(): + die(f"Got invalid UID '{uid_str}' from remote host.") + + return int(uid_str) + + +def compute_tunnel_addr(uid: int) -> tuple[str, int]: + """Derive per-user tunnel port from UID. + + Port: 20000 + UID (unique per user on shared servers) + + The SSH reverse tunnel binds to 0.0.0.0 on the remote so it is + reachable from Docker containers (which cannot access the host's + loopback directly). Per-user port isolation prevents conflicts. + """ + port = BASE_PORT + uid + + return "0.0.0.0", port + + +# --------------------------------------------------------------------------- +# Serial ↔ TCP relay +# --------------------------------------------------------------------------- +class SerialRelay: + """Relay bytes between a serial port and TCP clients. + + Architecture: + - The serial port is opened once and kept open for the lifetime of + the relay. This prevents resetting the radio's CPC state machine. + - A serial-reader thread runs continuously, forwarding data to the + current TCP client or discarding it when no client is connected. + This prevents stale data from accumulating in the kernel serial + buffer during TCP reconnection gaps. + - A TCP-writer thread reads from the current TCP client and writes + to the serial port. + - Only one TCP client at a time (cpcd is the sole consumer via socat). + + Self-healing: USB unplug → serial reopen; socat reconnect → new TCP + client accepted seamlessly without disturbing the serial port. + """ + + def __init__(self, serial_port: str, listen_port: int, baud: int = SERIAL_BAUD): + self.serial_port = serial_port + self.listen_port = listen_port + self.baud = baud + self._stop = threading.Event() + self._server_sock: socket.socket | None = None + # Guarded by _client_lock. Set to the active TCP socket or None. + self._client: socket.socket | None = None + self._client_lock = threading.Lock() + + def start(self) -> None: + """Bind the TCP server socket, then start the relay threads. + + Binding synchronously ensures the port is ready before the SSH + tunnel is opened — otherwise the remote socat may connect before + the listen socket exists and get ECONNREFUSED. + """ + self._server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._server_sock.settimeout(2.0) + self._server_sock.bind(("127.0.0.1", self.listen_port)) + self._server_sock.listen(1) + ok(f"TCP server listening on 127.0.0.1:{self.listen_port}") + + t = threading.Thread(target=self._run, daemon=True, name="serial-relay") + t.start() + + def stop(self) -> None: + """Signal the relay to stop.""" + self._stop.set() + + if self._server_sock: + try: + self._server_sock.close() + except OSError: + pass + + def _set_client(self, client: socket.socket | None) -> None: + """Swap the active TCP client, closing the old one.""" + with self._client_lock: + old = self._client + self._client = client + + if old is not None: + try: + old.close() + except OSError: + pass + + def _open_serial(self): + """Open the serial port, retrying on failure.""" + import serial + + backoff = 1 + + while not self._stop.is_set(): + + try: + ser = serial.Serial( + self.serial_port, + self.baud, + rtscts=True, + timeout=0.1, + ) + ok(f"Serial port {self.serial_port} opened ({self.baud} baud, hw flow control)") + return ser + except serial.SerialException as e: + warn(f"Cannot open {self.serial_port}: {e} — retrying in {backoff}s") + time.sleep(backoff) + backoff = min(backoff * 2, 30) + + return None + + def _run(self) -> None: + try: + import serial as serial_mod + except ImportError: + die("pyserial is not installed. Install it with:\n pip install pyserial") + + # Open the serial port once and keep it open. + ser = self._open_serial() + + if ser is None: + return + + # Start the serial reader thread — it runs for the entire lifetime + # of the relay, forwarding data to the current TCP client or + # discarding it when none is connected. + serial_reader = threading.Thread( + target=self._serial_reader_loop, + args=(ser, serial_mod), + daemon=True, + name="serial-reader", + ) + serial_reader.start() + + # Main loop: accept TCP clients and read from them. + while not self._stop.is_set(): + # Accept a TCP client. + try: + client, addr = self._server_sock.accept() + except socket.timeout: + continue + except OSError: + break + + info(f"TCP client connected from {addr}") + client.settimeout(0.5) + self._set_client(client) + + # Read from TCP client → serial port, until client disconnects. + self._tcp_to_serial_loop(client, ser, serial_mod) + + info("TCP client disconnected — keeping serial port open.") + self._set_client(None) + + # Check if serial port is still healthy after a USB unplug. + if not ser.is_open: + warn("Serial port closed — attempting to reopen...") + ser = self._open_serial() + + if ser is None: + break + + # Restart the serial reader for the new port instance. + serial_reader = threading.Thread( + target=self._serial_reader_loop, + args=(ser, serial_mod), + daemon=True, + name="serial-reader", + ) + serial_reader.start() + + if not self._stop.is_set(): + info("Waiting for next TCP connection...") + + def _serial_reader_loop(self, ser, serial_mod) -> None: + """Continuously read from serial port. + + Sends data to the current TCP client if one is connected. + Discards data otherwise (prevents stale bytes from accumulating + in the kernel serial buffer during TCP reconnection gaps). + """ + + while not self._stop.is_set(): + + try: + data = ser.read(RELAY_BUF_SIZE) + except serial_mod.SerialException: + info("serial reader: port error — stopping reader") + break + except OSError: + break + + if not data: + continue + + with self._client_lock: + client = self._client + + if client is not None: + + try: + client.sendall(data) + except OSError: + # TCP client went away; the main loop will handle it. + pass + + def _tcp_to_serial_loop(self, client: socket.socket, ser, serial_mod) -> None: + """Read from TCP client and write to serial port until disconnect.""" + + while not self._stop.is_set(): + + try: + data = client.recv(RELAY_BUF_SIZE) + except socket.timeout: + continue + except OSError as e: + info(f"tcp→serial: recv error: {e}") + break + + if not data: + break + + try: + ser.write(data) + except serial_mod.SerialException as e: + info(f"tcp→serial: serial write error: {e}") + break + + +# --------------------------------------------------------------------------- +# SSH tunnel +# --------------------------------------------------------------------------- +class SSHTunnel: + """Manage an SSH reverse tunnel with auto-reconnect. + + Before opening the tunnel, kills any stale SSH processes on the remote + that are still holding the port. After opening, verifies the -R forward + actually bound on the remote side. + """ + + def __init__(self, ssh_target: str, remote_addr: str, remote_port: int, local_port: int): + self.ssh_target = ssh_target + self.remote_addr = remote_addr + self.remote_port = remote_port + self.local_port = local_port + self._stop = threading.Event() + self._process: subprocess.Popen | None = None + + def start(self) -> None: + """Start the tunnel in a background thread with auto-reconnect.""" + t = threading.Thread(target=self._run, daemon=True, name="ssh-tunnel") + t.start() + + def stop(self) -> None: + """Signal the tunnel to stop.""" + self._stop.set() + + if self._process: + try: + self._process.terminate() + except OSError: + pass + + def _kill_stale_listeners(self) -> None: + """Kill any process on the remote that is holding our tunnel port.""" + info(f"Checking for stale listeners on remote port {self.remote_port}...") + + try: + result = subprocess.run( + [ + "ssh", "-o", "ConnectTimeout=10", + "-o", "ClearAllForwardings=yes", + self.ssh_target, + f"fuser -k {self.remote_port}/tcp 2>/dev/null; echo done", + ], + capture_output=True, + text=True, + timeout=15, + ) + + if result.returncode == 0: + info("Cleared stale listeners (if any).") + except (subprocess.TimeoutExpired, FileNotFoundError): + warn("Could not check for stale listeners — continuing anyway.") + + def _verify_tunnel(self) -> bool: + """Check that the -R forward is actually listening on the remote.""" + try: + result = subprocess.run( + [ + "ssh", "-o", "ConnectTimeout=5", + "-o", "ClearAllForwardings=yes", + self.ssh_target, + f"ss -tlnp 2>/dev/null | grep -q ':{self.remote_port} ' && echo BOUND || echo NOTBOUND", + ], + capture_output=True, + text=True, + timeout=10, + ) + + output = result.stdout.strip() + + if "BOUND" in output and "NOTBOUND" not in output: + ok(f"Verified: remote port {self.remote_port} is bound.") + return True + else: + warn(f"Remote port {self.remote_port} is NOT bound — tunnel -R forward may have failed.") + return False + except (subprocess.TimeoutExpired, FileNotFoundError): + warn("Could not verify tunnel — continuing anyway.") + return True # assume OK if we can't check + + def _run(self) -> None: + backoff = 2 + + while not self._stop.is_set(): + # Kill any stale SSH tunnels holding our port on the remote. + self._kill_stale_listeners() + + # Brief pause to let the port be released. + time.sleep(1) + + tunnel_spec = f"{self.remote_addr}:{self.remote_port}:127.0.0.1:{self.local_port}" + cmd = [ + "ssh", + "-N", # no remote command + "-o", "ServerAliveInterval=15", + "-o", "ServerAliveCountMax=3", + "-o", "ConnectTimeout=10", + # Do NOT use ClearAllForwardings — it also clears command-line + # -R forwards. Instead, tolerate local forward failures from + # ~/.ssh/config with ExitOnForwardFailure=no. Our reverse + # forward is verified separately after startup. + "-o", "ExitOnForwardFailure=no", + "-R", tunnel_spec, + self.ssh_target, + ] + + info(f"Opening SSH tunnel: {self.remote_addr}:{self.remote_port} → localhost:{self.local_port}") + + try: + self._process = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except FileNotFoundError: + die("ssh is not available on PATH.") + + # Give SSH a moment to establish the connection and set up forwards. + time.sleep(3) + + if self._process.poll() is not None: + # SSH already exited. + rc = self._process.returncode + stderr_out = "" + + try: + stderr_out = self._process.stderr.read().decode(errors="replace").strip() + except Exception: + pass + + warn(f"SSH exited immediately (code {rc}){': ' + stderr_out if stderr_out else ''}") + warn(f"Reconnecting in {backoff}s...") + time.sleep(backoff) + backoff = min(backoff * 2, 60) + continue + + ok("SSH connection established.") + + # Verify the -R forward actually bound on the remote. + if not self._verify_tunnel(): + warn("Killing SSH and retrying with stale listener cleanup...") + self._process.terminate() + self._process.wait() + time.sleep(2) + continue + + backoff = 2 + + # Wait for the SSH process to exit. + self._process.wait() + rc = self._process.returncode + + if self._stop.is_set(): + break + + stderr_out = "" + + try: + stderr_out = self._process.stderr.read().decode(errors="replace").strip() + except Exception: + pass + + warn(f"SSH tunnel exited (code {rc}){': ' + stderr_out if stderr_out else ''}") + warn(f"Reconnecting in {backoff}s...") + time.sleep(backoff) + backoff = min(backoff * 2, 60) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def main() -> None: + parser = argparse.ArgumentParser( + description="Forward a local Silicon Labs radio serial port to a remote dev server.", + epilog="See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for full setup instructions.", + ) + parser.add_argument( + "ssh_target", + help="SSH destination, e.g. user@devserver.example.com", + ) + parser.add_argument( + "--port", "-p", + dest="serial_port", + default=None, + help="Serial port of the radio (e.g. /dev/ttyACM0, COM3). Auto-detected if omitted.", + ) + parser.add_argument( + "--baud", "-b", + type=int, + default=SERIAL_BAUD, + help=f"Serial baud rate (default: {SERIAL_BAUD}).", + ) + + args = parser.parse_args() + + # ── Detect radio ────────────────────────────────────────────────────── + serial_port = args.serial_port + + if serial_port is None: + info("Searching for Silicon Labs radio...") + serial_port = find_radio_port() + + if serial_port is None: + die( + "No Silicon Labs radio found.\n" + " Looked for USB VID:PID 10C4:EA60 (CP210x) or 1366:0105 (SEGGER J-Link)\n" + " Specify the port manually with --port /dev/ttyACM0 or --port COM3" + ) + + ok(f"Radio serial port: {serial_port}") + + # ── Resolve remote UID and compute tunnel parameters ────────────────── + remote_uid = get_remote_uid(args.ssh_target) + tunnel_addr, tunnel_port = compute_tunnel_addr(remote_uid) + ok(f"Remote UID: {remote_uid}") + ok(f"Tunnel port: {tunnel_port}") + + # ── Start the serial relay ──────────────────────────────────────────── + # Use an ephemeral local port for the TCP server. The SSH tunnel + # forwards from the remote side to this local port. + local_port = tunnel_port # keep it simple — same port locally + relay = SerialRelay(serial_port, local_port, args.baud) + relay.start() + + # ── Start the SSH tunnel ────────────────────────────────────────────── + tunnel = SSHTunnel(args.ssh_target, tunnel_addr, tunnel_port, local_port) + tunnel.start() + + # ── Print summary ───────────────────────────────────────────────────── + print() + print(f"{GREEN}{BOLD}======================================={NC}") + print(f"{GREEN}{BOLD} REMOTE SERIAL TUNNEL ACTIVE{NC}") + print(f"{GREEN}{BOLD}======================================={NC}") + print(f"{GREEN} Radio: {serial_port}{NC}") + print(f"{GREEN} Local TCP: 127.0.0.1:{local_port}{NC}") + print(f"{GREEN} Tunnel port: {tunnel_port} on remote (all interfaces){NC}") + print(f"{GREEN} Remote UID: {remote_uid}{NC}") + print(f"{GREEN}{BOLD}======================================={NC}") + print() + info("Keep this terminal open while you work.") + info("Press Ctrl-C to stop the tunnel.") + print() + info("On the remote dev server, set in docker/.env or export:") + print() + print(f" {BOLD}RADIO_PORT={tunnel_port}{NC}") + print() + info("Then rebuild the devcontainer or run:") + print() + print(f" {BOLD}RADIO_PORT={tunnel_port} ./dockerw -T bash{NC}") + print() + + # ── Wait for Ctrl-C ─────────────────────────────────────────────────── + stop_event = threading.Event() + + def handle_signal(sig, frame): + info("Shutting down...") + stop_event.set() + + signal.signal(signal.SIGINT, handle_signal) + + if platform.system() != "Windows": + signal.signal(signal.SIGTERM, handle_signal) + + stop_event.wait() + + # ── Cleanup ─────────────────────────────────────────────────────────── + tunnel.stop() + relay.stop() + + print() + print(f"{GREEN}{BOLD}======================================={NC}") + print(f"{GREEN}{BOLD} TUNNEL STOPPED{NC}") + print(f"{GREEN}{BOLD}======================================={NC}") + + +if __name__ == "__main__": + main() diff --git a/scripts/remote-radio/usbip-attach-local.sh b/scripts/remote-radio/usbip-attach-local.sh deleted file mode 100755 index 51f33248..00000000 --- a/scripts/remote-radio/usbip-attach-local.sh +++ /dev/null @@ -1,283 +0,0 @@ -#!/usr/bin/env bash -# ------------------------------ tabstop = 4 ---------------------------------- -# -# If not stated otherwise in this file or this component's LICENSE file the -# following copyright and licenses apply: -# -# Copyright 2026 Comcast Cable Communications Management, LLC -# -# 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. -# -# SPDX-License-Identifier: Apache-2.0 -# -# ------------------------------ tabstop = 4 ---------------------------------- - -############################################################################### -# usbip-attach-local.sh -# -# Run this on the LOCAL WORKSTATION (laptop) where the BRD2703 xG24 radio is -# physically plugged in via USB. It: -# -# 1. Ensures required packages and kernel modules are loaded. -# 2. Auto-detects the Silicon Labs radio by USB vendor/product ID. -# 3. Binds the device to USB-IP. -# 4. Starts the USB-IP daemon if not already running. -# 5. Opens an SSH reverse tunnel to the remote server using a per-user -# loopback address (127.0.. from remote UID) so multiple -# developers can share a server without port conflicts. -# Requires GatewayPorts clientspecified in sshd_config on the server. -# -# Usage: -# scripts/usbip-attach-local.sh user@remote-server -# scripts/usbip-attach-local.sh remote-server # uses current username -# -# Leave the terminal open while you work. Press Ctrl-C to close the tunnel. -# Run scripts/usbip-detach-local.sh to clean up. -# -# See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for full setup instructions. -############################################################################### - -set -euo pipefail - -# ─── Constants ──────────────────────────────────────────────────────────────── -SILABS_VID="10c4" -SILABS_PID="ea60" -SEGGER_VID="1366" -SEGGER_PID="0105" -RADIO_IDS=("${SILABS_VID}:${SILABS_PID}" "${SEGGER_VID}:${SEGGER_PID}") -USBIP_PORT=3240 -STATE_FILE="/tmp/.usbip-radio-local-$(id -u)" - -# ─── Helpers ────────────────────────────────────────────────────────────────── -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BOLD='\033[1m' -NC='\033[0m' - -info() { echo -e "${BOLD}[usbip-local]${NC} $*"; } -ok() { echo -e "${GREEN}[usbip-local] OK:${NC} $*"; } -warn() { echo -e "${YELLOW}[usbip-local] WARNING:${NC} $*" >&2; } -fail() { echo -e "${RED}[usbip-local] ERROR:${NC} $*" >&2; } - -die() { - fail "$@" - echo "" - echo -e "${RED}[usbip-local] FAILED — see errors above.${NC}" - exit 1 -} - -# ─── Parse arguments ───────────────────────────────────────────────────────── -if [[ $# -lt 1 ]]; then - echo "Usage: $0 <[user@]remote-host>" - echo "" - echo " remote-host The SSH host where Docker and the devcontainer run." - echo " Prefix with user@ if your remote username differs." - echo "" - echo "Examples:" - echo " $0 devserver.example.com" - echo " $0 jdoe@devserver.example.com" - exit 1 -fi - -SSH_TARGET="$1" - -# Extract user and host for display. If no user@ prefix, use current user. -if [[ "${SSH_TARGET}" == *@* ]]; then - REMOTE_USER="${SSH_TARGET%%@*}" - REMOTE_HOST="${SSH_TARGET#*@}" -else - REMOTE_USER="$(whoami)" - REMOTE_HOST="${SSH_TARGET}" - SSH_TARGET="${REMOTE_USER}@${REMOTE_HOST}" -fi - -# ─── Pre-flight checks ─────────────────────────────────────────────────────── -info "Checking prerequisites..." - -if [[ "$(uname -s)" != "Linux" ]]; then - die "This script only works on Linux." -fi - -if ! command -v usbip &>/dev/null; then - info "usbip not found — installing..." - sudo apt-get update -qq - sudo apt-get install -y -qq linux-tools-generic linux-cloud-tools-generic -fi - -if ! command -v usbip &>/dev/null; then - die "usbip still not found after install. Check your kernel version and packages." -fi - -ok "usbip is available." - -# ─── Resolve the remote UID for per-user loopback address ───────────────────── -info "Resolving remote UID for ${SSH_TARGET}..." -REMOTE_UID=$(ssh -o ConnectTimeout=10 "${SSH_TARGET}" "id -u" 2>/dev/null) || \ - die "Cannot SSH to ${SSH_TARGET}. Check connectivity and credentials." - -if ! [[ "${REMOTE_UID}" =~ ^[0-9]+$ ]]; then - die "Got invalid UID '${REMOTE_UID}' from remote host." -fi - -# Each user gets a unique loopback address so multiple developers can share -# a server. Both usbip list and usbip attach connect to this address on the -# standard port 3240. Requires 'GatewayPorts clientspecified' in sshd_config. -# UID 1000 → 127.0.3.232, UID 1001 → 127.0.3.233, etc. -USBIP_LOOPBACK="127.0.$(( (REMOTE_UID >> 8) & 255 )).$(( REMOTE_UID & 255 ))" -ok "Remote UID: ${REMOTE_UID}, tunnel: ${USBIP_LOOPBACK}:${USBIP_PORT}" - -# ─── Verify kernel modules ──────────────────────────────────────────────────── -info "Checking required kernel modules..." - -MISSING_MODULES=() - -for mod in usbip_core usbip_host vhci_hcd; do - - if ! grep -qw "${mod}" /proc/modules 2>/dev/null; then - # Try to load it (may fail without sudo privileges) - sudo modprobe "${mod}" 2>/dev/null || true - - if ! grep -qw "${mod}" /proc/modules 2>/dev/null; then - MISSING_MODULES+=("${mod}") - fi - fi -done - -if [[ ${#MISSING_MODULES[@]} -gt 0 ]]; then - fail "The following required kernel modules are not loaded:" - echo "" - - for mod in "${MISSING_MODULES[@]}"; do - echo -e " ${RED}•${NC} ${BOLD}${mod}${NC}" - done - - echo "" - echo -e " Please load them with:" - echo "" - - for mod in "${MISSING_MODULES[@]}"; do - echo -e " ${BOLD}sudo modprobe ${mod}${NC}" - done - - echo "" - die "Required kernel modules are not loaded." -fi - -ok "Kernel modules loaded." - -# ─── Find the BRD2703 radio ─────────────────────────────────────────────────── -info "Searching for BRD2703 radio (${RADIO_IDS[*]})..." - -RADIO_BUSID="" -MATCHED_ID="" - -while IFS= read -r line; do - - for rid in "${RADIO_IDS[@]}"; do - - if echo "${line}" | grep -qi "${rid}"; then - RADIO_BUSID=$(echo "${line}" | grep -oP 'busid\s+\K[0-9.-]+' || echo "${line}" | awk '{print $3}') - MATCHED_ID="${rid}" - break 2 - fi - done -done < <(usbip list -l 2>/dev/null) - -# Fallback: scan sysfs directly -if [[ -z "${RADIO_BUSID}" ]]; then - - for dev in /sys/bus/usb/devices/*/idVendor; do - devDir=$(dirname "${dev}") - vid=$(cat "${devDir}/idVendor" 2>/dev/null || true) - pid=$(cat "${devDir}/idProduct" 2>/dev/null || true) - - for rid in "${RADIO_IDS[@]}"; do - IFS=: read -r rVid rPid <<< "${rid}" - - if [[ "${vid}" == "${rVid}" && "${pid}" == "${rPid}" ]]; then - RADIO_BUSID=$(basename "${devDir}") - MATCHED_ID="${rid}" - break 2 - fi - done - done -fi - -if [[ -z "${RADIO_BUSID}" ]]; then - die "No BRD2703 radio found. Is it plugged in via USB?" \ - "Looked for USB IDs: ${RADIO_IDS[*]}" -fi - -ok "Found radio at bus ID: ${RADIO_BUSID} (${MATCHED_ID})" - -# ─── Bind the device ───────────────────────────────────────────────────────── -info "Binding device ${RADIO_BUSID} to USB-IP..." - -# Unbind first in case it's already bound (idempotent). -sudo usbip unbind -b "${RADIO_BUSID}" 2>/dev/null || true -sudo usbip bind -b "${RADIO_BUSID}" - -if ! usbip list -l 2>/dev/null | grep -q "${RADIO_BUSID}"; then - die "Device ${RADIO_BUSID} does not appear in 'usbip list -l' after binding." -fi - -ok "Device bound." - -# ─── Start the USB-IP daemon ───────────────────────────────────────────────── -if ss -tlnp 2>/dev/null | grep -q ":${USBIP_PORT}\b" || \ - netstat -tlnp 2>/dev/null | grep -q ":${USBIP_PORT}\b"; then - ok "usbipd already listening on port ${USBIP_PORT}." -else - info "Starting usbipd on port ${USBIP_PORT}..." - sudo usbipd -D - sleep 1 - - if ss -tlnp 2>/dev/null | grep -q ":${USBIP_PORT}\b" || \ - netstat -tlnp 2>/dev/null | grep -q ":${USBIP_PORT}\b"; then - ok "usbipd started." - else - die "usbipd failed to start on port ${USBIP_PORT}." - fi -fi - -# ─── Save state for teardown ───────────────────────────────────────────────── -cat > "${STATE_FILE}" <. from UID). -# 3. Verifies the USB-IP tunnel is reachable. -# 4. Lists available devices and auto-detects the BRD2703 radio. -# 5. Detaches any stale attachment belonging to this user only. -# 6. Attaches the remote USB device. -# 7. Waits for the /dev/ttyACM* device to appear. -# 8. Saves state so usbip-detach-remote.sh can clean up safely. -# -# No arguments required. -# -# Requires 'GatewayPorts clientspecified' in sshd_config on this server. -# -# See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for full setup instructions. -############################################################################### - -set -euo pipefail - -# ─── Constants ──────────────────────────────────────────────────────────────── -SILABS_VID="10c4" -SILABS_PID="ea60" -SEGGER_VID="1366" -SEGGER_PID="0105" -RADIO_IDS=("${SILABS_VID}:${SILABS_PID}" "${SEGGER_VID}:${SEGGER_PID}") -USBIP_PORT=3240 -# Each user gets a unique loopback address so multiple developers can share -# a server. Matches the address used by usbip-attach-local.sh. -# UID 1000 → 127.0.3.232, UID 1001 → 127.0.3.233, etc. -USBIP_LOOPBACK="127.0.$(( ($(id -u) >> 8) & 255 )).$(( $(id -u) & 255 ))" -STATE_FILE="/tmp/.usbip-radio-remote-$(id -u)" - -# ─── Helpers ────────────────────────────────────────────────────────────────── -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BOLD='\033[1m' -NC='\033[0m' - -info() { echo -e "${BOLD}[usbip-remote]${NC} $*"; } -ok() { echo -e "${GREEN}[usbip-remote] OK:${NC} $*"; } -warn() { echo -e "${YELLOW}[usbip-remote] WARNING:${NC} $*" >&2; } -fail() { echo -e "${RED}[usbip-remote] ERROR:${NC} $*" >&2; } - -die() { - fail "$@" - echo "" - echo -e "${RED}[usbip-remote] FAILED — see errors above.${NC}" - exit 1 -} - -# ─── Pre-flight checks ─────────────────────────────────────────────────────── -info "Checking prerequisites..." -info "Per-user loopback: ${USBIP_LOOPBACK}:${USBIP_PORT} (UID $(id -u))" - -if [[ "$(uname -s)" != "Linux" ]]; then - die "This script only works on Linux." -fi - -if ! command -v usbip &>/dev/null; then - info "usbip not found — installing..." - sudo apt-get update -qq - sudo apt-get install -y -qq linux-tools-generic hwdata -fi - -if ! command -v usbip &>/dev/null; then - die "usbip still not found after install." -fi - -ok "usbip is available." - -# ─── Verify kernel modules ──────────────────────────────────────────────────── -info "Checking required kernel modules..." - -MISSING_MODULES=() - -for mod in vhci_hcd usbip_core; do - - if ! grep -qw "${mod}" /proc/modules 2>/dev/null; then - MISSING_MODULES+=("${mod}") - fi -done - -if [[ ${#MISSING_MODULES[@]} -gt 0 ]]; then - fail "The following required kernel modules are not loaded:" - echo "" - - for mod in "${MISSING_MODULES[@]}"; do - echo -e " ${RED}•${NC} ${BOLD}${mod}${NC}" - done - - echo "" - echo -e " These modules must be loaded by a system administrator." - echo -e " Please ask an administrator to run:" - echo "" - - for mod in "${MISSING_MODULES[@]}"; do - echo -e " ${BOLD}sudo modprobe ${mod}${NC}" - done - - echo "" - echo -e " To make this persistent across reboots, ask them to add the" - echo -e " module names to ${BOLD}/etc/modules-load.d/usbip.conf${NC}." - die "Required kernel modules are not loaded." -fi - -ok "Kernel modules loaded (vhci_hcd, usbip_core)." - -# ─── Verify tunnel is up ───────────────────────────────────────────────────── -info "Checking USB-IP tunnel on ${USBIP_LOOPBACK}:${USBIP_PORT}..." - -if ! ss -tlnp 2>/dev/null | grep -q "${USBIP_LOOPBACK}:${USBIP_PORT}" && \ - ! netstat -tlnp 2>/dev/null | grep -q "${USBIP_LOOPBACK}:${USBIP_PORT}"; then - die "Nothing is listening on ${USBIP_LOOPBACK}:${USBIP_PORT}." \ - "Make sure usbip-attach-local.sh is running on your workstation" \ - "with an SSH reverse tunnel open." \ - "The remote sshd must have 'GatewayPorts clientspecified' enabled." -fi - -ok "Tunnel is reachable at ${USBIP_LOOPBACK}:${USBIP_PORT}." - -# ─── List available devices ────────────────────────────────────────────────── -info "Listing remote USB devices..." - -DEVICE_LIST="" - -if ! DEVICE_LIST=$(usbip list -r "${USBIP_LOOPBACK}" 2>&1); then - fail "usbip list failed with output:" - echo "${DEVICE_LIST}" | head -20 - die "Failed to list remote devices. Is the tunnel open?" -fi - -echo "${DEVICE_LIST}" | head -20 -echo "" - -# ─── Find the BRD2703 radio ────────────────────────────────────────────────── -info "Looking for BRD2703 radio (${RADIO_IDS[*]})..." - -RADIO_BUSID="" -MATCHED_ID="" - -while IFS= read -r line; do - - for rid in "${RADIO_IDS[@]}"; do - - if echo "${line}" | grep -qi "${rid}"; then - RADIO_BUSID=$(echo "${line}" | grep -oP '^\s*\K[0-9.-]+' || true) - - if [[ -z "${RADIO_BUSID}" ]]; then - RADIO_BUSID=$(echo "${line}" | awk -F: '{print $1}' | tr -d ' ') - fi - - MATCHED_ID="${rid}" - break 2 - fi - done -done <<< "${DEVICE_LIST}" - -if [[ -z "${RADIO_BUSID}" ]]; then - die "No BRD2703 radio found in the remote device list." \ - "Looked for USB IDs: ${RADIO_IDS[*]}" \ - "Check that the radio is plugged in and bound on the local workstation." -fi - -ok "Found radio at bus ID: ${RADIO_BUSID} (${MATCHED_ID})" - -# ─── Detach any stale attachment for THIS user ──────────────────────────────── -# Only touch vhci ports recorded in our own state file. -if [[ -f "${STATE_FILE}" ]]; then - PREV_PORT=$(grep '^VHCI_PORT=' "${STATE_FILE}" 2>/dev/null | cut -d= -f2 || true) - - if [[ -n "${PREV_PORT}" ]]; then - info "Detaching stale USB-IP vhci port ${PREV_PORT} from previous session..." - sudo usbip detach -p "${PREV_PORT}" 2>/dev/null || true - sleep 1 - fi - - rm -f "${STATE_FILE}" -fi - -# ─── Snapshot existing ttyACM devices ───────────────────────────────────────── -TTY_BEFORE=$(ls /dev/ttyACM* 2>/dev/null | sort || true) - -# ─── Snapshot existing vhci ports ───────────────────────────────────────────── -VHCI_BEFORE=$(usbip port 2>/dev/null | grep -oP 'Port\s+\K[0-9]+' | sort || true) - -# ─── Attach the device ─────────────────────────────────────────────────────── -info "Attaching device ${RADIO_BUSID} via ${USBIP_LOOPBACK}..." - -sudo usbip attach -r "${USBIP_LOOPBACK}" -b "${RADIO_BUSID}" - -# ─── Identify the new vhci port (for clean detach later) ───────────────────── -sleep 1 -VHCI_AFTER=$(usbip port 2>/dev/null | grep -oP 'Port\s+\K[0-9]+' | sort || true) -NEW_VHCI_PORT="" - -for port in ${VHCI_AFTER}; do - - if ! echo "${VHCI_BEFORE}" | grep -qw "${port}"; then - NEW_VHCI_PORT="${port}" - break - fi -done - -# ─── Wait for the ttyACM device to appear ──────────────────────────────────── -info "Waiting for /dev/ttyACM* device to appear..." - -RADIO_DEVICE="" -WAIT_MAX=10 -waited=0 - -while [[ ${waited} -lt ${WAIT_MAX} ]]; do - sleep 1 - waited=$((waited + 1)) - - TTY_AFTER=$(ls /dev/ttyACM* 2>/dev/null | sort || true) - - for tty in ${TTY_AFTER}; do - - if ! echo "${TTY_BEFORE}" | grep -qw "${tty}"; then - RADIO_DEVICE="${tty}" - break - fi - done - - if [[ -n "${RADIO_DEVICE}" ]]; then - break - fi -done - -if [[ -z "${RADIO_DEVICE}" ]]; then - # Fall back to the last ttyACM device if we can't diff - RADIO_DEVICE=$(ls /dev/ttyACM* 2>/dev/null | tail -1 || true) -fi - -if [[ -z "${RADIO_DEVICE}" ]]; then - die "No /dev/ttyACM* device appeared after attaching." -fi - -ok "Radio device appeared: ${RADIO_DEVICE}" - -# ─── Verify the device is accessible ───────────────────────────────────────── -if [[ ! -c "${RADIO_DEVICE}" ]]; then - die "${RADIO_DEVICE} exists but is not a character device." -fi - -ok "Device is accessible." - -# ─── Save state for teardown ───────────────────────────────────────────────── -cat > "${STATE_FILE}" <&2; } -fail() { echo -e "${RED}[usbip-local] ERROR:${NC} $*" >&2; } - -# ─── Load state ────────────────────────────────────────────────────────────── -RADIO_BUSID="" - -if [[ -f "${STATE_FILE}" ]]; then - RADIO_BUSID=$(grep '^RADIO_BUSID=' "${STATE_FILE}" 2>/dev/null | cut -d= -f2 || true) - info "Loaded state from ${STATE_FILE}" -else - warn "No state file found at ${STATE_FILE}." - warn "Will attempt auto-detection." -fi - -# ─── Auto-detect if no state ───────────────────────────────────────────────── -SILABS_VID="10c4" -SILABS_PID="ea60" -SEGGER_VID="1366" -SEGGER_PID="0105" -RADIO_IDS=("${SILABS_VID}:${SILABS_PID}" "${SEGGER_VID}:${SEGGER_PID}") - -if [[ -z "${RADIO_BUSID}" ]]; then - info "Scanning for BRD2703 radio (${RADIO_IDS[*]})..." - - for dev in /sys/bus/usb/devices/*/idVendor; do - devDir=$(dirname "${dev}") - vid=$(cat "${devDir}/idVendor" 2>/dev/null || true) - pid=$(cat "${devDir}/idProduct" 2>/dev/null || true) - - for rid in "${RADIO_IDS[@]}"; do - IFS=: read -r rVid rPid <<< "${rid}" - - if [[ "${vid}" == "${rVid}" && "${pid}" == "${rPid}" ]]; then - RADIO_BUSID=$(basename "${devDir}") - break 2 - fi - done - done -fi - -# ─── Unbind the device ─────────────────────────────────────────────────────── -if [[ -n "${RADIO_BUSID}" ]]; then - info "Unbinding device ${RADIO_BUSID} from USB-IP..." - sudo usbip unbind -b "${RADIO_BUSID}" 2>/dev/null || true - ok "Device unbound." -else - warn "No radio bus ID found — skipping unbind." -fi - -# ─── Stop usbipd ───────────────────────────────────────────────────────────── -info "Stopping usbipd..." - -if pgrep -x usbipd &>/dev/null; then - sudo pkill -x usbipd 2>/dev/null || true - sleep 1 - - if pgrep -x usbipd &>/dev/null; then - warn "usbipd is still running." - else - ok "usbipd stopped." - fi -else - ok "usbipd was not running." -fi - -# ─── Clean up state file ───────────────────────────────────────────────────── -if [[ -f "${STATE_FILE}" ]]; then - rm -f "${STATE_FILE}" - ok "State file removed." -fi - -# ─── Done ───────────────────────────────────────────────────────────────────── -echo "" -echo -e "${GREEN}${BOLD}=======================================${NC}" -echo -e "${GREEN}${BOLD} LOCAL TEARDOWN COMPLETE${NC}" -echo -e "${GREEN}${BOLD}=======================================${NC}" -echo "" -info "The SSH tunnel (if still open) must be closed separately" -info "by pressing Ctrl-C in the terminal running usbip-attach-local.sh." -echo "" diff --git a/scripts/remote-radio/usbip-detach-remote.sh b/scripts/remote-radio/usbip-detach-remote.sh deleted file mode 100755 index e8cac60f..00000000 --- a/scripts/remote-radio/usbip-detach-remote.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env bash -# ------------------------------ tabstop = 4 ---------------------------------- -# -# If not stated otherwise in this file or this component's LICENSE file the -# following copyright and licenses apply: -# -# Copyright 2026 Comcast Cable Communications Management, LLC -# -# 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. -# -# SPDX-License-Identifier: Apache-2.0 -# -# ------------------------------ tabstop = 4 ---------------------------------- - -############################################################################### -# usbip-detach-remote.sh -# -# Run this on the REMOTE DEV SERVER to tear down the USB-IP attachment -# started by usbip-attach-remote.sh. It: -# -# 1. Reads the saved state file to identify this user's vhci port. -# 2. Detaches ONLY this user's device (does not affect other users). -# 3. Removes the state file. -# -# No arguments required. -# -# This script is safe on shared servers — it only touches the vhci port -# recorded in the current user's state file. -############################################################################### - -set -euo pipefail - -# ─── Constants ──────────────────────────────────────────────────────────────── -STATE_FILE="/tmp/.usbip-radio-remote-$(id -u)" - -# ─── Helpers ────────────────────────────────────────────────────────────────── -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BOLD='\033[1m' -NC='\033[0m' - -info() { echo -e "${BOLD}[usbip-remote]${NC} $*"; } -ok() { echo -e "${GREEN}[usbip-remote] OK:${NC} $*"; } -warn() { echo -e "${YELLOW}[usbip-remote] WARNING:${NC} $*" >&2; } -fail() { echo -e "${RED}[usbip-remote] ERROR:${NC} $*" >&2; } - -die() { - fail "$@" - echo "" - echo -e "${RED}[usbip-remote] FAILED — see errors above.${NC}" - exit 1 -} - -# ─── Load state ────────────────────────────────────────────────────────────── -if [[ ! -f "${STATE_FILE}" ]]; then - die "No state file found at ${STATE_FILE}." \ - "Either usbip-attach-remote.sh was not run, or it was already cleaned up." -fi - -VHCI_PORT=$(grep '^VHCI_PORT=' "${STATE_FILE}" 2>/dev/null | cut -d= -f2 || true) -RADIO_DEVICE=$(grep '^RADIO_DEVICE=' "${STATE_FILE}" 2>/dev/null | cut -d= -f2 || true) -RADIO_BUSID=$(grep '^RADIO_BUSID=' "${STATE_FILE}" 2>/dev/null | cut -d= -f2 || true) - -info "State loaded from ${STATE_FILE}" -info " VHCI port: ${VHCI_PORT:-unknown}" -info " Radio device: ${RADIO_DEVICE:-unknown}" -info " Bus ID: ${RADIO_BUSID:-unknown}" - -# ─── Detach the device ─────────────────────────────────────────────────────── -if [[ -n "${VHCI_PORT}" && "${VHCI_PORT}" != "unknown" ]]; then - info "Detaching vhci port ${VHCI_PORT}..." - - if sudo usbip detach -p "${VHCI_PORT}" 2>/dev/null; then - ok "Device detached from vhci port ${VHCI_PORT}." - else - warn "Detach returned an error — device may already be detached." - fi - - # Wait briefly for the ttyACM device to disappear - if [[ -n "${RADIO_DEVICE}" && -e "${RADIO_DEVICE}" ]]; then - info "Waiting for ${RADIO_DEVICE} to disappear..." - waited=0 - - while [[ -e "${RADIO_DEVICE}" && ${waited} -lt 5 ]]; do - sleep 1 - waited=$((waited + 1)) - done - - if [[ -e "${RADIO_DEVICE}" ]]; then - warn "${RADIO_DEVICE} still exists after detach." - else - ok "${RADIO_DEVICE} removed." - fi - fi -else - warn "No vhci port recorded — cannot safely detach." - warn "Use 'usbip port' to list attached devices and 'sudo usbip detach -p ' manually." -fi - -# ─── Clean up state file ───────────────────────────────────────────────────── -rm -f "${STATE_FILE}" -ok "State file removed." - -# ─── Done ───────────────────────────────────────────────────────────────────── -echo "" -echo -e "${GREEN}${BOLD}=======================================${NC}" -echo -e "${GREEN}${BOLD} REMOTE TEARDOWN COMPLETE${NC}" -echo -e "${GREEN}${BOLD}=======================================${NC}" -echo "" -info "The USB device has been detached from this server." -info "Close the SSH tunnel on your workstation (Ctrl-C) or run" -info "${BOLD}scripts/remote-radio/usbip-detach-local.sh${NC} to unbind and stop usbipd." -echo "" diff --git a/scripts/remote-radio/usbip-validate.sh b/scripts/remote-radio/usbip-validate.sh deleted file mode 100755 index a7f3d478..00000000 --- a/scripts/remote-radio/usbip-validate.sh +++ /dev/null @@ -1,342 +0,0 @@ -#!/usr/bin/env bash -# ------------------------------ tabstop = 4 ---------------------------------- -# -# If not stated otherwise in this file or this component's LICENSE file the -# following copyright and licenses apply: -# -# Copyright 2026 Comcast Cable Communications Management, LLC -# -# 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. -# -# SPDX-License-Identifier: Apache-2.0 -# -# ------------------------------ tabstop = 4 ---------------------------------- - -############################################################################### -# usbip-validate.sh -# -# Run this from INSIDE the Barton container (devcontainer or dockerw session) -# after the otbr-radio container is up. It validates the entire chain: -# -# 1. USB device is present on the host -# 2. D-Bus socket is shared and reachable -# 3. otbr-radio container is running (cpcd, bt_host_cpc_hci_bridge, -# btattach, bluetoothd, otbr-agent) -# 4. Thread network is responsive (ot-ctl state via D-Bus) -# 5. BLE adapter is configured and visible -# 6. ble_adapter_id file is present and points to a valid HCI device -# -# No arguments required. -# -# See docs/REMOTE_RADIO_FOR_DEVELOPMENT.md for full setup instructions. -############################################################################### - -set -uo pipefail - -# ─── Helpers ────────────────────────────────────────────────────────────────── -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BOLD='\033[1m' -NC='\033[0m' - -PASS_COUNT=0 -FAIL_COUNT=0 -WARN_COUNT=0 - -info() { echo -e "${BOLD}[validate]${NC} $*"; } -pass() { echo -e " ${GREEN}PASS${NC} $*"; PASS_COUNT=$((PASS_COUNT + 1)); } -fail() { echo -e " ${RED}FAIL${NC} $*"; FAIL_COUNT=$((FAIL_COUNT + 1)); } -warn() { echo -e " ${YELLOW}WARN${NC} $*"; WARN_COUNT=$((WARN_COUNT + 1)); } -skip() { echo -e " ${YELLOW}SKIP${NC} $*"; } - -# ─── Shared paths ──────────────────────────────────────────────────────────── -DBUS_DIR="/var/run/otbr-dbus" -DBUS_SOCKET="${DBUS_DIR}/system_bus_socket" -BLE_ADAPTER_FILE="${DBUS_DIR}/ble_adapter_id" -HOST_NETNS="/run/host-netns" - -# Docker API access (from inside the container) -DOCKER_SOCK="/var/run/docker.sock" - -echo "" -echo -e "${BOLD}═══════════════════════════════════════${NC}" -echo -e "${BOLD} Thread Radio & BLE Validation${NC}" -echo -e "${BOLD}═══════════════════════════════════════${NC}" -echo "" - -############################################################################### -# 1. D-Bus socket -############################################################################### -info "Checking D-Bus socket..." - -if [[ -S "${DBUS_SOCKET}" ]]; then - pass "D-Bus socket exists: ${DBUS_SOCKET}" -else - fail "D-Bus socket not found: ${DBUS_SOCKET}" - echo " The otbr-radio container may not be running, or the" - echo " dbus-socket volume is not mounted." -fi - -############################################################################### -# 2. otbr-radio container processes (via Docker API) -############################################################################### -info "Checking otbr-radio container..." - -OTBR_CONTAINER_ID="" -EXPECTED_PROCS=("cpcd" "otbr-agent" "bt_host_cpc_hci_bridge" "btattach" "bluetoothd") -OPTIONAL_PROCS=() - -if [[ -S "${DOCKER_SOCK}" ]]; then - # Find the otbr-radio container - OTBR_CONTAINER_ID=$(sudo curl -s --unix-socket "${DOCKER_SOCK}" \ - "http://localhost/containers/json" 2>/dev/null | \ - python3 -c " -import sys, json -try: - containers = json.load(sys.stdin) - for c in containers: - for name in c.get('Names', []): - if 'otbr-radio' in name: - print(c['Id'][:12]) - sys.exit(0) -except Exception: - pass -" 2>/dev/null || true) - - if [[ -n "${OTBR_CONTAINER_ID}" ]]; then - pass "otbr-radio container is running (${OTBR_CONTAINER_ID})" - - # Execute ps inside the container - EXEC_ID=$(sudo curl -s --unix-socket "${DOCKER_SOCK}" \ - -X POST -H "Content-Type: application/json" \ - -d '{"Cmd":["ps","aux"],"AttachStdout":true,"AttachStderr":true}' \ - "http://localhost/containers/${OTBR_CONTAINER_ID}/exec" 2>/dev/null | \ - python3 -c "import sys,json; print(json.load(sys.stdin).get('Id',''))" 2>/dev/null || true) - - PS_OUTPUT="" - - if [[ -n "${EXEC_ID}" ]]; then - PS_OUTPUT=$(sudo curl -s --unix-socket "${DOCKER_SOCK}" \ - -X POST -H "Content-Type: application/json" \ - -d '{"Detach":false,"Tty":false}' \ - "http://localhost/exec/${EXEC_ID}/start" 2>/dev/null | strings || true) - fi - - if [[ -n "${PS_OUTPUT}" ]]; then - - for proc in "${EXPECTED_PROCS[@]}"; do - - if echo "${PS_OUTPUT}" | grep -qw "${proc}"; then - pass "${proc} is running" - else - fail "${proc} is NOT running in otbr-radio container" - fi - done - - for proc in "${OPTIONAL_PROCS[@]}"; do - - if echo "${PS_OUTPUT}" | grep -qw "${proc}"; then - pass "${proc} is running" - else - warn "${proc} is not running (BLE may not be available)" - fi - done - else - warn "Could not inspect processes inside otbr-radio container" - fi - else - fail "otbr-radio container is NOT running" - echo " Start it with: RADIO_DEVICE=/dev/ttyACMx ./dockerw -T" - echo " Or rebuild the devcontainer with RADIO_DEVICE set." - fi -else - skip "Docker socket not available — cannot inspect otbr-radio container" -fi - -############################################################################### -# 3. D-Bus connectivity to otbr-agent -############################################################################### -info "Checking D-Bus connectivity to otbr-agent..." - -if [[ -S "${DBUS_SOCKET}" ]] && command -v gdbus &>/dev/null; then - DBUS_INTROSPECT=$(timeout 10 gdbus introspect \ - --address "unix:path=${DBUS_SOCKET}" \ - --dest io.openthread.BorderRouter.wpan0 \ - --object-path /io/openthread/BorderRouter/wpan0 2>&1 || true) - - if echo "${DBUS_INTROSPECT}" | grep -q "interface"; then - pass "otbr-agent D-Bus interface is reachable" - - # Try to read Thread device role - DEVICE_ROLE=$(timeout 10 gdbus call \ - --address "unix:path=${DBUS_SOCKET}" \ - --dest io.openthread.BorderRouter.wpan0 \ - --object-path /io/openthread/BorderRouter/wpan0 \ - --method org.freedesktop.DBus.Properties.Get \ - io.openthread.BorderRouter DeviceRole 2>/dev/null || true) - - if [[ -n "${DEVICE_ROLE}" ]]; then - ROLE_VALUE=$(echo "${DEVICE_ROLE}" | grep -oP "'[^']+'" | head -1 | tr -d "'") - pass "Thread device role: ${ROLE_VALUE:-unknown}" - - if [[ "${ROLE_VALUE}" == "leader" || "${ROLE_VALUE}" == "router" || "${ROLE_VALUE}" == "child" ]]; then - pass "Thread network is active" - elif [[ "${ROLE_VALUE}" == "disabled" ]]; then - pass "Thread state is 'disabled' — Barton will form the network" - elif [[ "${ROLE_VALUE}" == "detached" ]]; then - warn "Thread state is 'detached' — trying to join a network" - fi - else - warn "Could not read Thread DeviceRole property" - fi - else - fail "Cannot reach otbr-agent on D-Bus" - echo " ${DBUS_INTROSPECT}" - fi -elif ! command -v gdbus &>/dev/null; then - skip "gdbus not available — cannot test D-Bus connectivity" -else - skip "D-Bus socket missing — skipping connectivity test" -fi - -############################################################################### -# 4. BLE adapter configuration -############################################################################### -info "Checking BLE configuration..." - -if [[ -f "${BLE_ADAPTER_FILE}" ]]; then - BLE_ADAPTER_ID=$(cat "${BLE_ADAPTER_FILE}" | tr -d '[:space:]') - pass "ble_adapter_id file exists: ${BLE_ADAPTER_FILE}" - info " BLE adapter index: ${BLE_ADAPTER_ID}" - - # Check if the HCI device exists in the host network namespace - if [[ -e "${HOST_NETNS}" ]]; then - HCI_DEVICES=$(sudo nsenter --net="${HOST_NETNS}" hciconfig 2>/dev/null || true) - - if echo "${HCI_DEVICES}" | grep -q "hci${BLE_ADAPTER_ID}"; then - pass "hci${BLE_ADAPTER_ID} is present in host network namespace" - - HCI_STATUS=$(echo "${HCI_DEVICES}" | grep -A2 "hci${BLE_ADAPTER_ID}:" || true) - - if echo "${HCI_STATUS}" | grep -q "UP RUNNING"; then - pass "hci${BLE_ADAPTER_ID} is UP and RUNNING" - else - fail "hci${BLE_ADAPTER_ID} is not UP RUNNING" - echo " ${HCI_STATUS}" - fi - - # Show the BD address for reference - BD_ADDR=$(echo "${HCI_STATUS}" | grep -oP 'BD Address: \K[0-9A-F:]+' || true) - - if [[ -n "${BD_ADDR}" ]]; then - info " BD Address: ${BD_ADDR}" - fi - else - fail "hci${BLE_ADAPTER_ID} is NOT present in host network namespace" - echo " btattach may have failed or the BLE bridge is down." - echo " Available HCI devices:" - echo "${HCI_DEVICES}" | grep "^hci" | sed 's/^/ /' - fi - - # Count total HCI devices - HCI_COUNT=$(echo "${HCI_DEVICES}" | grep -c "^hci" || true) - - if [[ ${HCI_COUNT} -ge 2 ]]; then - pass "Multiple HCI devices present (${HCI_COUNT} total)" - elif [[ ${HCI_COUNT} -eq 1 ]]; then - warn "Only 1 HCI device present — the radio's BLE adapter may not be attached" - else - fail "No HCI devices found" - fi - else - skip "Host network namespace not mounted — cannot verify HCI devices" - fi -else - fail "ble_adapter_id file not found: ${BLE_ADAPTER_FILE}" - echo " The otbr-radio entrypoint may not have completed BLE setup." -fi - -############################################################################### -# 5. BLE adapter reachable via bluetoothctl (host netns) -############################################################################### -info "Checking BLE controller via bluetoothctl..." - -if [[ -e "${HOST_NETNS}" ]] && command -v bluetoothctl &>/dev/null; then - # bluetoothctl uses D-Bus; bluetoothd registers on our private D-Bus so - # we use the same DBUS_SYSTEM_BUS_ADDRESS that the container inherits. - # Use timeout because bluetoothctl blocks if bluetoothd is not running. - BT_LIST=$(timeout 5 sudo nsenter --net="${HOST_NETNS}" bluetoothctl list 2>/dev/null || true) - - if [[ -n "${BT_LIST}" ]]; then - CONTROLLER_COUNT=$(echo "${BT_LIST}" | wc -l) - pass "bluetoothctl sees ${CONTROLLER_COUNT} controller(s)" - echo "${BT_LIST}" | sed 's/^/ /' - else - fail "bluetoothctl returned no controllers" - fi -else - skip "Cannot run bluetoothctl in host network namespace" -fi - -############################################################################### -# 6. DBUS_SYSTEM_BUS_ADDRESS environment variable -############################################################################### -info "Checking DBUS_SYSTEM_BUS_ADDRESS..." - -if [[ -n "${DBUS_SYSTEM_BUS_ADDRESS:-}" ]]; then - pass "DBUS_SYSTEM_BUS_ADDRESS is set: ${DBUS_SYSTEM_BUS_ADDRESS}" - - if echo "${DBUS_SYSTEM_BUS_ADDRESS}" | grep -q "otbr-dbus"; then - pass "Points to the private otbr-dbus socket" - else - warn "Does not point to the otbr-dbus socket — Barton may use the wrong D-Bus" - fi -else - fail "DBUS_SYSTEM_BUS_ADDRESS is not set" - echo " Barton will use the system D-Bus instead of the otbr-radio private bus." - echo " Export it: export DBUS_SYSTEM_BUS_ADDRESS=unix:path=${DBUS_SOCKET}" -fi - -############################################################################### -# Summary -############################################################################### -echo "" -echo -e "${BOLD}═══════════════════════════════════════${NC}" -echo -e "${BOLD} Results${NC}" -echo -e "${BOLD}═══════════════════════════════════════${NC}" -echo -e " ${GREEN}Passed: ${PASS_COUNT}${NC}" -echo -e " ${YELLOW}Warnings: ${WARN_COUNT}${NC}" -echo -e " ${RED}Failed: ${FAIL_COUNT}${NC}" -echo "" - -if [[ ${FAIL_COUNT} -eq 0 ]]; then - echo -e "${GREEN}${BOLD}=======================================${NC}" - echo -e "${GREEN}${BOLD} ALL CHECKS PASSED${NC}" - echo -e "${GREEN}${BOLD}=======================================${NC}" - echo "" - - if [[ ${WARN_COUNT} -gt 0 ]]; then - echo -e "${YELLOW}Some warnings were reported — review above for details.${NC}" - fi - - exit 0 -else - echo -e "${RED}${BOLD}=======================================${NC}" - echo -e "${RED}${BOLD} VALIDATION FAILED (${FAIL_COUNT} issue(s))${NC}" - echo -e "${RED}${BOLD}=======================================${NC}" - echo "" - echo "Review the failures above and check docs/REMOTE_RADIO_FOR_DEVELOPMENT.md" - echo "for troubleshooting guidance." - exit 1 -fi diff --git a/scripts/remote-radio/cpc_validate.sh b/scripts/remote-radio/validate.sh similarity index 75% rename from scripts/remote-radio/cpc_validate.sh rename to scripts/remote-radio/validate.sh index 21505ade..970b6178 100755 --- a/scripts/remote-radio/cpc_validate.sh +++ b/scripts/remote-radio/validate.sh @@ -1,16 +1,38 @@ #!/bin/bash # ------------------------------ tabstop = 4 ---------------------------------- # -# CPC Remote Radio Validation Script +# If not stated otherwise in this file or this component's LICENSE file the +# following copyright and licenses apply: # -# Performs a detailed check of all requirements for the CPC remote radio -# chain to function correctly. Run this inside the Barton or otbr-radio +# Copyright 2026 Comcast Cable Communications Management, LLC +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# +# ------------------------------ tabstop = 4 ---------------------------------- + +# +# Radio Validation Script +# +# Performs a detailed check of all requirements for the real-radio chain +# to function correctly. Run this inside the Barton or otbr-radio # container to diagnose connectivity, BLE chain, and runtime issues. # # Usage: -# ./cpc_validate.sh # Run all checks -# ./cpc_validate.sh --fix # Attempt to fix common issues -# ./cpc_validate.sh --json # Output results as JSON (for automation) +# ./validate.sh # Run all checks +# ./validate.sh --fix # Attempt to fix common issues +# ./validate.sh --json # Output results as JSON (for automation) # # Exit codes: # 0 All checks passed @@ -38,14 +60,79 @@ if [ ! -f /entrypoint.sh ] || ! grep -q "otbr-radio" /entrypoint.sh 2>/dev/null; OTBR_CONTAINER="" # Determine docker command (with or without sudo). + # The barton devcontainer does not have the Docker CLI installed but does + # bind-mount the Docker socket. Fall back to the curl+API approach when + # the docker command is unavailable. DOCKER_CMD="" + USE_CURL_API=false if command -v docker >/dev/null 2>&1 && docker ps >/dev/null 2>&1; then DOCKER_CMD="docker" - elif command -v sudo >/dev/null 2>&1 && sudo docker ps >/dev/null 2>&1; then + elif command -v docker >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1 && sudo docker ps >/dev/null 2>&1; then DOCKER_CMD="sudo docker" + elif [ -S /var/run/docker.sock ]; then + USE_CURL_API=true fi - if [ -n "$DOCKER_CMD" ]; then + if [ "$USE_CURL_API" = true ]; then + # Use the Docker Engine API via curl to find the otbr-radio container. + _DOCKER_API="http://localhost/v1.45" + _CURL="curl -s --unix-socket /var/run/docker.sock" + _SUDO="" + # Socket may require sudo for access. + if ! $_CURL "$_DOCKER_API/_ping" >/dev/null 2>&1; then + _CURL="sudo curl -s --unix-socket /var/run/docker.sock" + fi + + # Get our own compose project from our hostname (container ID). + COMPOSE_PROJECT=$($_CURL "$_DOCKER_API/containers/$(hostname)/json" 2>/dev/null \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['Config']['Labels'].get('com.docker.compose.project',''))" 2>/dev/null) || true + + if [ -n "$COMPOSE_PROJECT" ]; then + OTBR_CONTAINER=$($_CURL "$_DOCKER_API/containers/json?filters=%7B%22label%22%3A%5B%22com.docker.compose.project%3D${COMPOSE_PROJECT}%22%5D%2C%22name%22%3A%5B%22otbr-radio%22%5D%7D" 2>/dev/null \ + | python3 -c "import json,sys; cs=json.load(sys.stdin); print(cs[0]['Names'][0].lstrip('/') if cs else '')" 2>/dev/null) || true + fi + + if [ -z "$OTBR_CONTAINER" ]; then + OTBR_CONTAINER=$($_CURL "$_DOCKER_API/containers/json?filters=%7B%22name%22%3A%5B%22otbr-radio%22%5D%7D" 2>/dev/null \ + | python3 -c "import json,sys; cs=json.load(sys.stdin); print(cs[0]['Names'][0].lstrip('/') if cs else '')" 2>/dev/null) || true + fi + + if [ -n "$OTBR_CONTAINER" ]; then + echo "Not in otbr-radio container — re-executing inside ${OTBR_CONTAINER}..." + echo "" + # Use the Docker Engine API to exec into the container. + # Read the script content, base64-encode it, and pass as a + # command argument to avoid Docker exec stdin piping issues. + _ESCAPED_ARGS="" + for arg in "$@"; do + _ESCAPED_ARGS="${_ESCAPED_ARGS}, \"${arg}\"" + done + _SCRIPT_B64=$(base64 -w0 "$0") + _EXEC_ID=$($_CURL -X POST "$_DOCKER_API/containers/${OTBR_CONTAINER}/exec" \ + -H "Content-Type: application/json" \ + -d "{\"Cmd\":[\"bash\", \"-c\", \"echo ${_SCRIPT_B64} | base64 -d | bash -s -- ${*}\"],\"AttachStdout\":true,\"AttachStderr\":true}" \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['Id'])") + # Start the exec and demux the Docker multiplexed stream. + $_CURL -X POST "$_DOCKER_API/exec/${_EXEC_ID}/start" \ + -H "Content-Type: application/json" \ + -d '{"Detach":false}' \ + | python3 -c " +import sys +buf = sys.stdin.buffer.read() +i = 0 +while i + 8 <= len(buf): + size = int.from_bytes(buf[i+4:i+8], 'big') + if i + 8 + size <= len(buf): + sys.stdout.buffer.write(buf[i+8:i+8+size]) + i += 8 + size +sys.stdout.buffer.flush() +" + # Get the exit code from the exec instance. + _EXIT=$($_CURL "$_DOCKER_API/exec/${_EXEC_ID}/json" \ + | python3 -c "import json,sys; print(json.load(sys.stdin).get('ExitCode',1))") + exit "${_EXIT}" + fi + elif [ -n "$DOCKER_CMD" ]; then # If we're inside a Compose-managed container, read the project label # from our own container to scope the search. COMPOSE_PROJECT=$($DOCKER_CMD inspect "$(hostname)" \ @@ -84,8 +171,9 @@ fi # Configuration ############################################################################### CPC_INSTANCE="${CPC_INSTANCE:-cpcd_0}" -CPC_REMOTE_HOST="${CPC_REMOTE_HOST:-}" -CPC_REMOTE_PORT="${CPC_REMOTE_PORT:-15000}" +RADIO_PORT="${RADIO_PORT:-}" +RADIO_HOST="${RADIO_HOST:-host.docker.internal}" +RADIO_DEVICE="${RADIO_DEVICE:-}" CPC_SOCKET_DIR="${CPC_SOCKET_DIR:-/dev/shm}" CPC_SOCKET_BASE="${CPC_SOCKET_DIR}/cpcd/${CPC_INSTANCE}" DBUS_DIR="${DBUS_DIR:-/var/run/otbr-dbus}" @@ -93,7 +181,6 @@ DBUS_SOCKET_PATH="${DBUS_SOCKET_PATH:-${DBUS_DIR}/system_bus_socket}" HOST_NETNS="/run/host-netns" BT_BRIDGE_DIR="/var/run/bt-hci-bridge" HCI_PROXY_SCRIPT="/opt/cpc-proxy/hci_pty_proxy.py" -CPC_PROXY_SCRIPT="/opt/cpc-proxy/cpc_socket_proxy.py" FIX_MODE=false JSON_MODE=false @@ -188,13 +275,7 @@ check_container_env() { fail "Host netns mount" "${HOST_NETNS} not found — BLE will not work" fi - # Required bind-mounts - if [ -f "${CPC_PROXY_SCRIPT}" ]; then - pass "CPC proxy script" "${CPC_PROXY_SCRIPT} present" - else - fail "CPC proxy script" "${CPC_PROXY_SCRIPT} not found" - fi - + # HCI PTY proxy script if [ -f "${HCI_PROXY_SCRIPT}" ]; then pass "HCI PTY proxy script" "${HCI_PROXY_SCRIPT} present" else @@ -202,7 +283,7 @@ check_container_env() { fi # Required commands - for cmd in btattach bluetoothd hcitool hciconfig nsenter bt_host_cpc_hci_bridge python3; do + for cmd in btattach bluetoothd hcitool hciconfig nsenter bt_host_cpc_hci_bridge python3 socat cpcd; do if command -v "$cmd" >/dev/null 2>&1; then pass "Command: $cmd" "$(command -v "$cmd")" else @@ -256,52 +337,90 @@ check_dbus() { } ############################################################################### -# Section 3: CPC Connectivity +# Section 3: Radio Connection ############################################################################### -check_cpc() { - section "CPC Connectivity" - - if [ -z "${CPC_REMOTE_HOST}" ]; then - skip "CPC remote mode" "CPC_REMOTE_HOST not set — local mode" - # Check for local cpcd - local cpcd_pid - cpcd_pid=$(pgrep -x cpcd 2>/dev/null | head -1) || true - if [ -n "$cpcd_pid" ]; then - pass "cpcd process" "PID $cpcd_pid" +check_radio() { + section "Radio Connection" + + if [ -n "${RADIO_PORT}" ]; then + #---------------------------------------------------------------------- + # Remote serial tunnel mode + #---------------------------------------------------------------------- + pass "Radio mode" "Remote serial tunnel (port ${RADIO_PORT})" + + # Resolve to IPv4 (the SSH tunnel binds on 0.0.0.0) + local radio_host_v4 + radio_host_v4=$(getent ahostsv4 "${RADIO_HOST}" 2>/dev/null | awk '{print $1; exit}') || true + if [ -z "${radio_host_v4}" ]; then + radio_host_v4="${RADIO_HOST}" + fi + + # TCP connectivity to the tunnel endpoint + if timeout 3 bash -c "echo >/dev/tcp/${radio_host_v4}/${RADIO_PORT}" 2>/dev/null; then + pass "Tunnel TCP connectivity" "${radio_host_v4}:${RADIO_PORT} reachable" else - fail "cpcd process" "cpcd not running" + fail "Tunnel TCP connectivity" "Cannot connect to ${radio_host_v4}:${RADIO_PORT} — is remote-serial.py running on your workstation?" fi - else - pass "CPC remote mode" "Remote host: ${CPC_REMOTE_HOST}:${CPC_REMOTE_PORT}" - # TCP connectivity to remote host - if timeout 3 bash -c "echo >/dev/tcp/${CPC_REMOTE_HOST}/${CPC_REMOTE_PORT}" 2>/dev/null; then - pass "Remote ctrl port" "TCP ${CPC_REMOTE_HOST}:${CPC_REMOTE_PORT} reachable" + # socat process + local socat_pid + socat_pid=$(pgrep -x socat 2>/dev/null | head -1) || true + if [ -n "$socat_pid" ]; then + if is_process_alive "$socat_pid"; then + pass "socat bridge" "PID $socat_pid (alive)" + else + fail "socat bridge" "PID $socat_pid (ZOMBIE)" + fi else - fail "Remote ctrl port" "Cannot connect to ${CPC_REMOTE_HOST}:${CPC_REMOTE_PORT}" + fail "socat bridge" "socat not running — virtual serial device will not exist" fi - local reset_port=$((CPC_REMOTE_PORT + 1)) - if timeout 3 bash -c "echo >/dev/tcp/${CPC_REMOTE_HOST}/${reset_port}" 2>/dev/null; then - pass "Remote reset port" "TCP ${CPC_REMOTE_HOST}:${reset_port} reachable" + # Virtual PTY device + if [ -L "/dev/ttyRadio" ]; then + local pty_target + pty_target=$(readlink -f "/dev/ttyRadio" 2>/dev/null) || pty_target="" + if [ -c "$pty_target" ]; then + pass "Virtual serial device" "/dev/ttyRadio → ${pty_target}" + else + fail "Virtual serial device" "/dev/ttyRadio exists but target ${pty_target} is not a character device" + fi else - fail "Remote reset port" "Cannot connect to ${CPC_REMOTE_HOST}:${reset_port}" + fail "Virtual serial device" "/dev/ttyRadio not found — socat may not have started" fi - # Check well-known endpoint ports - local ep14_port=$((CPC_REMOTE_PORT + 100 + 14)) - if timeout 3 bash -c "echo >/dev/tcp/${CPC_REMOTE_HOST}/${ep14_port}" 2>/dev/null; then - pass "Remote ep14 port (BLE)" "TCP ${CPC_REMOTE_HOST}:${ep14_port} reachable" + elif [ -n "${RADIO_DEVICE}" ]; then + #---------------------------------------------------------------------- + # Local USB radio mode + #---------------------------------------------------------------------- + pass "Radio mode" "Local USB (${RADIO_DEVICE})" + + if [ -e "${RADIO_DEVICE}" ]; then + pass "Radio device" "${RADIO_DEVICE} exists" + if [ -c "${RADIO_DEVICE}" ]; then + pass "Radio device type" "Character device" + else + warn "Radio device type" "Not a character device" + fi else - fail "Remote ep14 port (BLE)" "Cannot connect to ${CPC_REMOTE_HOST}:${ep14_port} — BLE bridge will fail" + fail "Radio device" "${RADIO_DEVICE} not found — is the radio connected?" fi - local ep12_port=$((CPC_REMOTE_PORT + 100 + 12)) - if timeout 3 bash -c "echo >/dev/tcp/${CPC_REMOTE_HOST}/${ep12_port}" 2>/dev/null; then - pass "Remote ep12 port (Thread)" "TCP ${CPC_REMOTE_HOST}:${ep12_port} reachable" + else + fail "Radio mode" "Neither RADIO_PORT nor RADIO_DEVICE is set" + return + fi + + # cpcd process (runs in both modes) + local cpcd_pid + cpcd_pid=$(pgrep -x cpcd 2>/dev/null | head -1) || true + if [ -n "$cpcd_pid" ]; then + if is_process_alive "$cpcd_pid"; then + pass "cpcd process" "PID $cpcd_pid (alive)" else - fail "Remote ep12 port (Thread)" "Cannot connect to ${CPC_REMOTE_HOST}:${ep12_port} — otbr-agent will fail" + fail "cpcd process" "PID $cpcd_pid (ZOMBIE)" fi + else + fail "cpcd process" "cpcd not running" fi # CPC socket directory @@ -338,34 +457,6 @@ check_cpc() { warn "CPC socket: ep${ep} event" "Not found (events may not be available)" fi done - - # CPC proxy processes (remote mode) - if [ -n "${CPC_REMOTE_HOST}" ]; then - local proxy_count - proxy_count=$(pgrep -cf "cpc_socket_proxy.py client" 2>/dev/null) || proxy_count=0 - if [ "$proxy_count" -ge 6 ]; then - pass "CPC proxy processes" "${proxy_count} running (expected 6: ctrl, reset, ep12/ep14 data+event)" - elif [ "$proxy_count" -gt 0 ]; then - warn "CPC proxy processes" "Only ${proxy_count} running (expected 6)" - else - fail "CPC proxy processes" "No CPC proxy client processes running" - fi - - # Check for zombie proxies - local zombie_count=0 - for pid in $(pgrep -f "cpc_socket_proxy.py client" 2>/dev/null); do - local state - state=$(awk '/^State:/ {print $2}' /proc/"$pid"/status 2>/dev/null) || continue - if [[ "$state" == "Z" ]]; then - zombie_count=$((zombie_count + 1)) - fi - done - if [ "$zombie_count" -gt 0 ]; then - fail "CPC proxy zombies" "${zombie_count} zombie proxy process(es)" - else - pass "CPC proxy zombies" "None" - fi - fi } ############################################################################### @@ -375,8 +466,6 @@ check_ble_chain() { section "BLE Chain (bridge → pty_proxy → btattach → bluetoothd)" # bt_host_cpc_hci_bridge - # NOTE: pgrep -x can't match this — Linux /proc/PID/comm truncates to - # 15 chars ("bt_host_cpc_hci"). Use pgrep -f for the full command line. local bridge_pid bridge_pid=$(pgrep -f "bt_host_cpc_hci_bridge" 2>/dev/null | head -1) || true if [ -n "$bridge_pid" ]; then @@ -445,10 +534,7 @@ check_ble_chain() { fail "bluetoothd" "Not running — BLE operations will fail" fi - # BLE monitor — runs as a backgrounded bash function inside the - # entrypoint script, so it appears as a child /bin/bash process of PID 1. - # We detect it by looking for child bash processes whose /proc/PID/cmdline - # contains "/entrypoint.sh" (i.e. subshells forked from the entrypoint). + # BLE monitor local monitor_found=false for child_pid in $(pgrep -P 1 2>/dev/null); do local child_cmd @@ -499,9 +585,6 @@ check_hci() { for hci in $hci_list; do local idx="${hci#hci}" - local bus - bus=$(cat "/sys/class/bluetooth/${hci}/device/subsystem" 2>/dev/null | xargs basename 2>/dev/null) || bus="unknown" - # Try to get the vendor from hciconfig or sysfs local info="" local is_cpc=false @@ -532,7 +615,6 @@ check_hci() { if $is_cpc; then warn "HCI adapter: ${hci}" "${info} — state=${up_state} (CPC adapter not selected?)" else - # Non-CPC adapter that's not selected — just informational if [ "$up_state" = "UP" ]; then warn "HCI adapter: ${hci}" "${info} — state=${up_state} (host adapter — may confuse bluetoothd)" else @@ -546,8 +628,6 @@ check_hci() { # IMPORTANT: Do NOT use hciconfig name / Read Local Name (0x03|0x0014). # The Silicon Labs CPC BLE firmware does NOT support it and returns # "Unknown HCI Command", which hciconfig misreports as "I/O error". - # This was the root cause of false health-check failures that cascaded - # into bridge restarts and ep14 EAGAIN lockups. if [ -n "$expected_idx" ]; then local target_hci="hci${expected_idx}" if nsenter --net="${HOST_NETNS}" hciconfig "${target_hci}" 2>/dev/null | grep -q "UP RUNNING"; then @@ -555,13 +635,10 @@ check_hci() { hcitool -i "${target_hci}" cmd 0x04 0x0009 >/dev/null 2>&1; then pass "HCI transport (Read BD ADDR)" "Responsive on ${target_hci}" - # Extract the BD address from the response for display local bd_addr bd_addr=$(timeout 5 nsenter --net="${HOST_NETNS}" \ hcitool -i "${target_hci}" cmd 0x04 0x0009 2>/dev/null \ | grep "HCI Event" -A1 | tail -1 | awk '{ - # Response: status(1) + addr(6 bytes LE) - # Skip first 4 bytes (evt fields), addr starts at byte 5 printf "%s:%s:%s:%s:%s:%s", $10, $9, $8, $7, $6, $5 }') || bd_addr="" if [ -n "$bd_addr" ]; then @@ -571,7 +648,6 @@ check_hci() { fail "HCI transport (Read BD ADDR)" "No response from ${target_hci} within 5s — transport dead" fi - # Verify Read Local Version works (basic command, widely supported) if timeout 5 nsenter --net="${HOST_NETNS}" \ hcitool -i "${target_hci}" cmd 0x04 0x0001 >/dev/null 2>&1; then pass "HCI transport (Read Version)" "Responsive" @@ -579,7 +655,6 @@ check_hci() { warn "HCI transport (Read Version)" "No response (unusual)" fi - # Explicitly check Read Local Name is unsupported (known CPC firmware limitation) local name_result name_result=$(timeout 3 nsenter --net="${HOST_NETNS}" \ hciconfig "${target_hci}" name 2>&1) || true @@ -616,16 +691,8 @@ check_ble_scan() { local target_hci="hci${idx}" # Use bluetoothctl for scanning (via D-Bus → bluetoothd). - # - # IMPORTANT: Do NOT use raw hcitool lescan here. Raw HCI scanning - # bypasses bluetoothd and corrupts its internal state machine. When the - # Matter SDK later tries to start BLE discovery via D-Bus, bluetoothd - # rejects it with "org.bluez.Error.InProgress" because it doesn't know - # the raw scan stopped. This blocks commissioning entirely. - # - # bluetoothctl goes through D-Bus → bluetoothd, keeping state consistent. - # We must explicitly select the CPC adapter because bluetoothd may - # default to a host USB adapter (e.g. hci0). + # IMPORTANT: Do NOT use raw hcitool lescan — it bypasses bluetoothd + # and corrupts its internal state machine. local bd_addr bd_addr=$(nsenter --net="${HOST_NETNS}" hciconfig "${target_hci}" 2>/dev/null \ | awk '/BD Address:/{print $3}') || bd_addr="" @@ -635,16 +702,6 @@ check_ble_scan() { return fi - # Start scan, collect output for a few seconds, then stop cleanly. - # - # CRITICAL: Use a SINGLE bluetoothctl session for both scan on and - # scan off. Using two separate instances creates a D-Bus session - # mismatch — the second instance's "scan off" is ignored because - # bluetoothd tracks discovery per-client and the second client never - # started one. The first client's scan persists until bluetoothd's - # auto-cleanup fires (which may be delayed or incomplete), leaving - # the adapter in Discovering=yes and causing "InProgress" errors - # for the Matter SDK. local btctl_output btctl_output=$( { @@ -686,7 +743,6 @@ check_otbr() { if is_process_alive "$otbr_pid"; then pass "otbr-agent process" "PID $otbr_pid (alive)" - # Check how long it's been running (stability indicator) local etime etime=$(ps -o etime= -p "$otbr_pid" 2>/dev/null | tr -d ' ') || etime="" if [ -n "$etime" ]; then @@ -705,7 +761,6 @@ check_otbr() { wpan_state=$(ip -br link show wpan0 2>/dev/null | awk '{print $2}') || wpan_state="unknown" pass "wpan0 interface" "State: ${wpan_state}" - # Check for IPv6 addresses on wpan0 (Thread mesh connectivity) local v6_addrs v6_addrs=$(ip -6 addr show dev wpan0 scope global 2>/dev/null | grep -c "inet6") || v6_addrs=0 if [ "$v6_addrs" -gt 0 ]; then @@ -717,7 +772,7 @@ check_otbr() { fail "wpan0 interface" "Not found — otbr-agent may not be running or CPC link is down" fi - # Check avahi-daemon (required by otbr-agent with OTBR_MDNS=avahi) + # Check avahi-daemon local avahi_pid avahi_pid=$(pgrep -x avahi-daemon 2>/dev/null | head -1) || true if [ -n "$avahi_pid" ]; then @@ -733,9 +788,8 @@ check_otbr() { check_known_pitfalls() { section "Known Pitfalls & Lessons Learned" - # Check for stale PID files or sockets from previous runs + # Check for stale CPC sockets if [ -S "${CPC_SOCKET_BASE}/ctrl.cpcd.sock" ]; then - # Verify the socket is actually connected (not stale) local ctrl_established ctrl_established=$(ss -x 2>/dev/null | grep -c "ctrl.cpcd.sock" 2>/dev/null) || ctrl_established=0 if [ "$ctrl_established" -gt 0 ]; then @@ -745,17 +799,16 @@ check_known_pitfalls() { fi fi - # Check for ep14 EAGAIN state: if the bridge is repeatedly failing to open - # the endpoint, cpcd needs to be restarted on the server side. + # ep14 EAGAIN state check local bridge_pid bridge_pid=$(pgrep -f "bt_host_cpc_hci_bridge" 2>/dev/null | head -1) || true if [ -z "$bridge_pid" ]; then - warn "ep14 EAGAIN check" "Bridge not running — if it keeps failing, restart cpcd on the server" + warn "ep14 EAGAIN check" "Bridge not running — if it keeps failing, restart cpcd" else pass "ep14 EAGAIN check" "Bridge alive (PID $bridge_pid)" fi - # Check for multiple HCI adapters (can confuse bluetoothd default adapter selection) + # Multiple HCI adapters local hci_count hci_count=$(ls /sys/class/bluetooth/ 2>/dev/null | wc -w) || hci_count=0 if [ "$hci_count" -gt 1 ]; then @@ -769,31 +822,28 @@ check_known_pitfalls() { pass "Single HCI adapter" "Only one adapter — no confusion risk" fi - # Check if there's a health check using the wrong HCI command - # (hciconfig name / Read Local Name is unsupported by CPC firmware) + # Dangerous health check command if grep -rq "hciconfig.*name" /entrypoint.sh 2>/dev/null; then fail "Health check command" "entrypoint.sh uses 'hciconfig name' — this ALWAYS fails on CPC firmware (use hcitool cmd 0x04 0x0009)" else pass "Health check command" "Not using unsupported 'hciconfig name'" fi - # Check if entrypoint has the dangerous HCI Reset sequence + # Dangerous HCI Reset if grep -q "hcitool.*cmd.*0x03.*0x0003" /entrypoint.sh 2>/dev/null; then warn "HCI Reset in entrypoint" "Found HCI Reset command — this can kill the CPC transport" else pass "No HCI Reset in entrypoint" "Dangerous reset sequence not present" fi - # Check for the kill-0 bug (BTATTACH_PID=0 initialization) + # kill-0 bug if grep -q 'BTATTACH_PID=0' /entrypoint.sh 2>/dev/null; then fail "BTATTACH_PID init" "Initialized to 0 — 'kill 0' will SIGTERM the entire process group!" else pass "BTATTACH_PID init" "Not initialized to 0 (safe)" fi - # Check for raw HCI scan commands (hcitool lescan) in scripts. - # These bypass bluetoothd and corrupt its state machine, causing - # "org.bluez.Error.InProgress" when the Matter SDK tries to scan. + # Raw HCI scan if grep -rq "hcitool.*lescan" /entrypoint.sh 2>/dev/null; then fail "Raw HCI scan in entrypoint" "hcitool lescan corrupts bluetoothd state — use bluetoothctl instead" else @@ -855,23 +905,30 @@ print_summary() { ############################################################################### # Main ############################################################################### + +# Detect radio mode for header +RADIO_MODE_DISPLAY="Unknown" +if [ -n "${RADIO_PORT}" ]; then + RADIO_MODE_DISPLAY="Remote serial tunnel (${RADIO_HOST}:${RADIO_PORT})" +elif [ -n "${RADIO_DEVICE}" ]; then + RADIO_MODE_DISPLAY="Local USB (${RADIO_DEVICE})" +else + RADIO_MODE_DISPLAY="Not configured" +fi + if ! $JSON_MODE; then echo "╔══════════════════════════════════════════════════════════════╗" - echo "║ CPC Remote Radio Validation ║" + echo "║ Radio Stack Validation ║" echo "╠══════════════════════════════════════════════════════════════╣" printf "║ Instance: %-49s║\n" "${CPC_INSTANCE}" - if [ -n "${CPC_REMOTE_HOST}" ]; then - printf "║ Remote: %-49s║\n" "${CPC_REMOTE_HOST}:${CPC_REMOTE_PORT}" - else - printf "║ Mode: %-49s║\n" "Local radio" - fi + printf "║ Radio: %-49s║\n" "${RADIO_MODE_DISPLAY}" printf "║ Date: %-49s║\n" "$(date -Iseconds)" echo "╚══════════════════════════════════════════════════════════════╝" fi check_container_env check_dbus -check_cpc +check_radio check_ble_chain check_hci check_ble_scan From e62307a283fbbb5f98b46c75705a4d01aec53f14 Mon Sep 17 00:00:00 2001 From: Thomas Lea Date: Mon, 1 Jun 2026 17:54:04 +0000 Subject: [PATCH 12/17] fix(docker): unify service monitoring, add HCI auto-healing, update example ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit otbr-radio-entrypoint.sh: - Replace separate ble_monitor and infra_monitor loops with a single unified service_monitor that checks all layers bottom-to-top (socat → cpcd → bridge → btattach → bluetoothd → otbr-agent). - Restart scope is determined by the lowest failed layer: a socat failure tears down and restarts the full stack, a cpcd failure restarts cpcd + the BLE chain, and a bridge failure restarts only the BLE chain. - Add two-stage HCI health check (hci_transport_healthy): stage 1 verifies basic transport with hciconfig, stage 2 sends LE Read Local Supported Features (OGF 0x08 | OCF 0x0003) and verifies a Command Complete (0x0e) response. This catches stuck LE controllers that the old single-stage Read BD ADDR check missed. - Add teardown_ble_chain with skip-if-already-torn-down guard to avoid redundant cleanup on retry loops. - Add start_ble_chain with per-component retries and verification. - Update example RADIO_PORT from 21000 to 21234 (UID 1234 example). REMOTE_RADIO_FOR_DEVELOPMENT.md: - Change example UID from 1000 to 1234 and RADIO_PORT from 21000 to 21234 throughout, so examples do not suggest using the host user's own UID. remote-serial.py: - Promote serial write error log from info to warn. - Force-close the serial port on write error so the reconnect logic triggers immediately, even when pyserial leaves is_open True after a dead handle (observed on Windows with PermissionError). --- docker/otbr-radio-entrypoint.sh | 520 ++++++++++++++++++++------ docs/REMOTE_RADIO_FOR_DEVELOPMENT.md | 30 +- scripts/remote-radio/remote-serial.py | 9 +- 3 files changed, 424 insertions(+), 135 deletions(-) diff --git a/docker/otbr-radio-entrypoint.sh b/docker/otbr-radio-entrypoint.sh index d0d167b6..64829d84 100755 --- a/docker/otbr-radio-entrypoint.sh +++ b/docker/otbr-radio-entrypoint.sh @@ -223,7 +223,7 @@ else echo "[otbr-radio] Set RADIO_DEVICE for a locally attached radio:" >&2 echo "[otbr-radio] export RADIO_DEVICE=/dev/ttyACM0" >&2 echo "[otbr-radio] Or set RADIO_PORT for a remote serial tunnel:" >&2 - echo "[otbr-radio] export RADIO_PORT=21000" >&2 + echo "[otbr-radio] export RADIO_PORT=21234" >&2 exit 1 fi @@ -299,6 +299,110 @@ done echo "[otbr-radio] cpcd ready (PID ${CPCD_PID}, waited ${cpcdWaitCount}s)." +############################################################################### +# 4b. Restart helpers for socat and cpcd +# +# These functions are called by service_monitor (background loop) to recover +# from socat or cpcd crashes. When socat dies, /dev/ttyRadio disappears +# and cpcd loses its serial link — everything downstream fails. When cpcd +# dies, both otbr-agent and bt_host_cpc_hci_bridge lose their CPC sockets. +# The unified service_monitor handles the full dependency chain: it tears +# down all layers above the failure point and restarts bottom-to-top. +############################################################################### + +# restart_socat — restart the socat TCP→PTY bridge for remote serial mode. +# Waits for the tunnel to be reachable, then starts a fresh socat and waits +# for the /dev/ttyRadio symlink. Returns non-zero on failure. +restart_socat() { + echo "[otbr-radio] Restarting socat bridge..." + [ -n "${SOCAT_PID:-}" ] && kill ${SOCAT_PID} 2>/dev/null || true + [ -n "${SOCAT_PID:-}" ] && wait ${SOCAT_PID} 2>/dev/null || true + rm -f "${VIRTUAL_TTY}" + + # Wait for the remote tunnel to be reachable before starting socat. + local waitCount=0 + + while ! timeout 2 bash -c ": >/dev/tcp/${RADIO_HOST_V4}/${RADIO_PORT}" 2>/dev/null; do + + if [ ${waitCount} -ge 30 ]; then + echo "[otbr-radio] Remote serial tunnel not reachable after 30s." >&2 + return 1 + fi + + sleep 5 + waitCount=$((waitCount + 5)) + done + + socat \ + "TCP4:${RADIO_HOST_V4}:${RADIO_PORT},forever,intervall=3,keepalive,keepidle=10,keepintvl=5,keepcnt=3" \ + "PTY,link=${VIRTUAL_TTY},raw,echo=0,b115200" & + SOCAT_PID=$! + + local ptyWait=0 + + while [ ! -L "${VIRTUAL_TTY}" ]; do + + if ! kill -0 ${SOCAT_PID} 2>/dev/null; then + echo "[otbr-radio] socat exited before creating ${VIRTUAL_TTY}." >&2 + return 1 + fi + + if [ ${ptyWait} -ge 10 ]; then + echo "[otbr-radio] socat did not create ${VIRTUAL_TTY} after 10s." >&2 + return 1 + fi + + sleep 1 + ptyWait=$((ptyWait + 1)) + done + + echo "[otbr-radio] socat restarted (PID ${SOCAT_PID}, ${VIRTUAL_TTY})." + return 0 +} + +# restart_cpcd — kill the old cpcd instance and start a fresh one. +# Writes a fresh config and waits for the "Daemon startup was successful" +# log message. Returns non-zero on failure. +restart_cpcd() { + echo "[otbr-radio] Restarting cpcd..." + [ -n "${CPCD_PID:-}" ] && kill ${CPCD_PID} 2>/dev/null || true + [ -n "${CPCD_PID:-}" ] && wait ${CPCD_PID} 2>/dev/null || true + sleep 1 + + cat > "${CPCD_CONF}" < "${CPCD_LOG}" + stdbuf -oL cpcd --conf "${CPCD_CONF}" > >(tee "${CPCD_LOG}") 2>&1 & + CPCD_PID=$! + + local waitCount=0 + + while ! grep -q "Daemon startup was successful" "${CPCD_LOG}" 2>/dev/null; do + + if ! kill -0 ${CPCD_PID} 2>/dev/null; then + echo "[otbr-radio] cpcd exited during restart." >&2 + return 1 + fi + + if [ ${waitCount} -ge ${CPCD_WAIT_MAX} ]; then + echo "[otbr-radio] cpcd did not become ready after ${CPCD_WAIT_MAX}s." >&2 + return 1 + fi + + sleep 1 + waitCount=$((waitCount + 1)) + done + + echo "[otbr-radio] cpcd restarted (PID ${CPCD_PID})." + return 0 +} + ############################################################################### # 5. Start bt_host_cpc_hci_bridge (CPC → virtual HCI serial device) # @@ -473,37 +577,77 @@ ble_attach() { return 1 } -# ble_restart_bridge — restart the entire BLE chain from the CPC bridge -# onwards (bridge → HCI proxy → btattach → bluetoothd). Called by the -# monitor when bt_host_cpc_hci_bridge dies. -ble_restart_bridge() { - echo "[otbr-radio] Restarting full BLE chain (bridge → proxy → btattach)..." +# is_process_alive — check if a process is alive and not a zombie. +# kill -0 returns true for zombie processes, which misleads the monitor +# into thinking a dead bridge is still running. +is_process_alive() { + local pid=$1 + kill -0 "$pid" 2>/dev/null || return 1 + local state + state=$(awk '/^State:/ {print $2}' /proc/"$pid"/status 2>/dev/null) + [[ "$state" != "Z" ]] +} + +############################################################################### +# Unified service monitor +# +# The radio stack forms a strict dependency chain: +# +# socat (remote only) → cpcd → bridge → proxy → btattach → bluetoothd +# ↓ +# otbr-agent (foreground loop, separate) +# +# When a layer fails, everything above it is invalid and must be torn down +# top-to-bottom before restarting bottom-to-top from the failed layer. +# +# A single service_monitor loop replaces the previous ble_monitor and +# infra_monitor, avoiding coordination problems between them. +############################################################################### + +# teardown_ble_chain — kill the BLE stack top-down and clean up HCI devices. +# Always safe to call even if some processes are already dead. +teardown_ble_chain() { + # Skip if the chain is already torn down. + if [ -z "${BT_BRIDGE_PID:-}" ] || ! kill -0 ${BT_BRIDGE_PID} 2>/dev/null; then - # Tear down everything downstream and reap zombies. + if [ -z "${BTATTACH_PID:-}" ] || ! kill -0 ${BTATTACH_PID} 2>/dev/null; then + return 0 + fi + fi + + echo "[monitor] Tearing down BLE chain..." + pkill -x bluetoothd 2>/dev/null || true [ -n "${BTATTACH_PID:-}" ] && kill ${BTATTACH_PID} 2>/dev/null || true [ -n "${HCI_PROXY_PID:-}" ] && kill ${HCI_PROXY_PID} 2>/dev/null || true - pkill -x bluetoothd 2>/dev/null || true - wait ${BT_BRIDGE_PID} 2>/dev/null || true + [ -n "${BT_BRIDGE_PID:-}" ] && kill ${BT_BRIDGE_PID} 2>/dev/null || true + + # Reap zombies. + [ -n "${BT_BRIDGE_PID:-}" ] && wait ${BT_BRIDGE_PID} 2>/dev/null || true [ -n "${BTATTACH_PID:-}" ] && wait ${BTATTACH_PID} 2>/dev/null || true [ -n "${HCI_PROXY_PID:-}" ] && wait ${HCI_PROXY_PID} 2>/dev/null || true sleep 1 - # Bring down any stale HCI devices that were created by previous - # btattach instances. This prevents hci index accumulation + # Bring down stale HCI devices to prevent index accumulation # (hci1, hci2, hci3...) across restarts. local adapterFile="${DBUS_DIR}/ble_adapter_id" + if [ -f "$adapterFile" ]; then local oldIdx oldIdx=$(cat "$adapterFile" 2>/dev/null) + if [ -n "$oldIdx" ]; then nsenter --net="${HOST_NETNS}" hciconfig "hci${oldIdx}" down 2>/dev/null || true fi fi +} - # Start a fresh bridge. The CPC endpoint (ep14) may take time to be - # released by cpcd after the previous session ended. Retry up to - # BT_BRIDGE_RETRY_MAX times with a short delay to ride out transient - # EAGAIN / "Resource temporarily unavailable" failures. +# start_ble_chain — start the BLE stack bottom-up: bridge → proxy → attach. +# Expects cpcd to already be running. Returns non-zero on failure. +start_ble_chain() { + echo "[monitor] Starting BLE chain (bridge → proxy → btattach → bluetoothd)..." + + # Start bridge with retries. The CPC Bluetooth endpoint (ep14) may + # take time to be released by cpcd after the previous session. local BT_BRIDGE_RETRY_MAX=5 local bridgeAttempt=0 @@ -531,29 +675,30 @@ ble_restart_bridge() { done if [ -L "${BT_BRIDGE_DIR}/pts_hci" ]; then - break # Bridge started successfully. + break fi - # Bridge failed — wait for zombie and retry. wait ${BT_BRIDGE_PID} 2>/dev/null || true bridgeAttempt=$((bridgeAttempt + 1)) if [ ${bridgeAttempt} -lt ${BT_BRIDGE_RETRY_MAX} ]; then local retryDelay=$((bridgeAttempt * 3)) - echo "[otbr-radio] Bridge attempt ${bridgeAttempt}/${BT_BRIDGE_RETRY_MAX} failed (EAGAIN?); retrying in ${retryDelay}s..." >&2 + echo "[monitor] Bridge attempt ${bridgeAttempt}/${BT_BRIDGE_RETRY_MAX} failed; retrying in ${retryDelay}s..." >&2 sleep "${retryDelay}" fi done if [ ! -L "${BT_BRIDGE_DIR}/pts_hci" ]; then - echo "[otbr-radio] bt_host_cpc_hci_bridge failed after ${BT_BRIDGE_RETRY_MAX} attempts." >&2 + echo "[monitor] bt_host_cpc_hci_bridge failed after ${BT_BRIDGE_RETRY_MAX} attempts." >&2 return 1 fi PTS_DEVICE=$(readlink -f "${BT_BRIDGE_DIR}/pts_hci") - echo "[otbr-radio] Bridge restarted (PID ${BT_BRIDGE_PID}, pts: ${PTS_DEVICE})." + echo "[monitor] Bridge started (PID ${BT_BRIDGE_PID}, pts: ${PTS_DEVICE})." + + # Start HCI PTY proxy if available. + HCI_PROXY_PID="" - # Restart the HCI PTY proxy if the script is available. if [ -f "${HCI_PROXY_SCRIPT}" ]; then python3 -u "${HCI_PROXY_SCRIPT}" "${PTS_DEVICE}" "${BT_BRIDGE_DIR}/pts_hci" & HCI_PROXY_PID=$! @@ -561,131 +706,259 @@ ble_restart_bridge() { if kill -0 ${HCI_PROXY_PID} 2>/dev/null; then PTS_DEVICE=$(readlink -f "${BT_BRIDGE_DIR}/pts_hci") - echo "[otbr-radio] HCI proxy restarted (PID ${HCI_PROXY_PID}, pts: ${PTS_DEVICE})." + echo "[monitor] HCI proxy started (PID ${HCI_PROXY_PID}, pts: ${PTS_DEVICE})." else - echo "[otbr-radio] WARNING: HCI proxy exited during restart." >&2 + echo "[monitor] WARNING: HCI proxy exited during startup." >&2 + HCI_PROXY_PID="" fi fi - return 0 -} + # Attach HCI device and start bluetoothd. + if ! ble_attach; then + echo "[monitor] BLE attach failed." >&2 + return 1 + fi -# is_process_alive — check if a process is alive and not a zombie. -# kill -0 returns true for zombie processes, which misleads the monitor -# into thinking a dead bridge is still running. -is_process_alive() { - local pid=$1 - kill -0 "$pid" 2>/dev/null || return 1 - local state - state=$(awk '/^State:/ {print $2}' /proc/"$pid"/status 2>/dev/null) - [[ "$state" != "Z" ]] + echo "[monitor] BLE chain started successfully." + return 0 } -# hci_transport_healthy — verify the HCI transport is responsive by -# sending a Read BD ADDR command (0x04|0x0009) and checking for a -# valid response. Returns non-zero if the adapter doesn't respond -# within 5 seconds (dead transport or hung CPC endpoint). +# hci_transport_healthy — verify the HCI transport is responsive. +# Two-stage check: +# 1. hciconfig version — tests basic HCI transport and response parsing. +# Catches total transport failures and desynced responses. +# 2. LE Read Local Supported Features (0x08|0x0003) — tests the LE +# controller path specifically. Verifies the response is a Command +# Complete event (0x0e), not a stale LE Meta Event (0x3e) from a +# desynced transport. # -# NOTE: The Silicon Labs CPC BLE firmware does NOT support some common -# HCI commands like Read Local Name (0x03|0x0014). Using those would -# cause false "unhealthy" results. Read BD ADDR is always supported. +# The SiLabs CPC BLE firmware can enter a state where basic HCI commands +# still work but LE scanning is completely broken (Set Scan Parameters +# times out). Stage 2 catches this by testing the LE controller path +# and verifying the response event type. hci_transport_healthy() { local adapterFile="${DBUS_DIR}/ble_adapter_id" [ -f "$adapterFile" ] || return 1 local idx idx=$(cat "$adapterFile" 2>/dev/null) [ -n "$idx" ] || return 1 - # Timeout protects against hung transports. - timeout 5 nsenter --net="${HOST_NETNS}" \ - hcitool -i "hci${idx}" cmd 0x04 0x0009 >/dev/null 2>&1 + + # Stage 1: basic transport — hciconfig parses the response and fails + # if it gets a timeout or unexpected data. + if ! timeout 5 nsenter --net="${HOST_NETNS}" \ + hciconfig "hci${idx}" version >/dev/null 2>&1; then + echo "[monitor] HCI health: hciconfig version failed (transport dead)." >&2 + return 1 + fi + + # Stage 2: LE controller — send LE Read Local Supported Features and + # verify we get Command Complete (0x0e), not LE Meta Event (0x3e). + local leResult + leResult=$(timeout 5 nsenter --net="${HOST_NETNS}" \ + hcitool -i "hci${idx}" cmd 0x08 0x0003 2>&1) || return 1 + + if ! echo "$leResult" | grep -q "0x0e"; then + echo "[monitor] HCI health: LE command got unexpected response (transport desynced)." >&2 + return 1 + fi + + return 0 } -# ble_monitor — background loop that watches the full BLE chain -# (bridge, HCI proxy, btattach) and restarts from the point of failure. -# If the bridge dies, the entire chain is restarted. If only btattach -# dies (e.g. USB-IP reconnect), only the attach step is retried. -# Additionally performs periodic HCI transport health checks to catch -# cases where the bridge process is alive but the CPC endpoint died. -ble_monitor() { - local backoff=2 - local healthCheckInterval=0 +# service_monitor — unified background loop that watches the entire stack. +# +# Checks all layers bottom-to-top each iteration. The lowest failed layer +# determines the restart scope: everything from that layer up is torn down +# and restarted in dependency order. +# +# Restart scopes: +# socat died → teardown BLE + cpcd, restart socat → cpcd → BLE chain +# cpcd died → teardown BLE + cpcd, restart cpcd → BLE chain +# bridge/proxy → teardown BLE, restart BLE chain +# btattach → kill bluetoothd, restart btattach → bluetoothd +# HCI stuck → teardown BLE, restart BLE chain +service_monitor() { + local backoff=5 + local healthCheckCounter=0 local healthFailCount=0 - # Grace period — skip health checks for the first 30 seconds after - # startup or a successful restart to let the adapter stabilise. + # Grace period — skip health checks for 30s after startup or restart + # to let the adapter stabilise. local graceUntil=$((SECONDS + 30)) while true; do - local needRestart=false - local bridgeDied=false - - if ! is_process_alive ${BT_BRIDGE_PID}; then - needRestart=true - bridgeDied=true - elif [ -n "${HCI_PROXY_PID:-}" ] && ! is_process_alive ${HCI_PROXY_PID}; then - # The proxy owns the PTY that btattach is connected to. - # If the proxy dies, the PTY becomes invalid — full restart. - echo "[otbr-radio] HCI PTY proxy (PID ${HCI_PROXY_PID}) died." >&2 - needRestart=true - bridgeDied=true - kill ${BT_BRIDGE_PID} 2>/dev/null || true - elif [ -n "${BTATTACH_PID:-}" ] && ! is_process_alive ${BTATTACH_PID}; then - needRestart=true - elif [ ${SECONDS} -ge ${graceUntil} ] && [ ${healthCheckInterval} -ge 5 ]; then - # Every ~10s (5 iterations × 2s sleep), verify the HCI - # transport is actually responsive. This catches dead CPC - # endpoints where the bridge PID is still alive. - # Require 3 consecutive failures to avoid false positives - # from transient slowness. - healthCheckInterval=0 - - if ! hci_transport_healthy; then - healthFailCount=$((healthFailCount + 1)) - - if [ ${healthFailCount} -ge 3 ]; then - echo "[otbr-radio] HCI transport unresponsive (${healthFailCount} consecutive failures) — restarting BLE chain." >&2 - needRestart=true - bridgeDied=true - healthFailCount=0 - # Kill the stale bridge so ble_restart_bridge can start fresh. - kill ${BT_BRIDGE_PID} 2>/dev/null || true + sleep "${backoff}" + + local restartFrom="" + + # --------------------------------------------------------------- + # Check layers bottom-to-top. First failure wins (lowest layer). + # --------------------------------------------------------------- + + # Layer 0: socat (remote serial mode only). + if [ -z "${restartFrom}" ] && [ -n "${RADIO_PORT}" ] && [ -n "${SOCAT_PID:-}" ]; then + + if ! is_process_alive ${SOCAT_PID}; then + echo "[monitor] socat (PID ${SOCAT_PID}) died." >&2 + restartFrom="socat" + fi + fi + + # Layer 1: cpcd. + if [ -z "${restartFrom}" ] && ! is_process_alive ${CPCD_PID}; then + echo "[monitor] cpcd (PID ${CPCD_PID}) died." >&2 + restartFrom="cpcd" + fi + + # Layer 2: bt_host_cpc_hci_bridge. + if [ -z "${restartFrom}" ] && [ -n "${BT_BRIDGE_PID:-}" ] && ! is_process_alive ${BT_BRIDGE_PID}; then + echo "[monitor] bt_host_cpc_hci_bridge (PID ${BT_BRIDGE_PID}) died." >&2 + restartFrom="bridge" + fi + + # Layer 3: HCI PTY proxy — proxy death invalidates the PTY that + # btattach is attached to, so restart the whole BLE chain. + if [ -z "${restartFrom}" ] && [ -n "${HCI_PROXY_PID:-}" ] && ! is_process_alive ${HCI_PROXY_PID}; then + echo "[monitor] HCI PTY proxy (PID ${HCI_PROXY_PID}) died." >&2 + restartFrom="bridge" + fi + + # Layer 4: btattach. + if [ -z "${restartFrom}" ] && [ -n "${BTATTACH_PID:-}" ] && ! is_process_alive ${BTATTACH_PID}; then + echo "[monitor] btattach (PID ${BTATTACH_PID}) died." >&2 + restartFrom="btattach" + fi + + # HCI health check — only when all processes are alive and the + # grace period has elapsed. Catches stuck/desynced transports. + if [ -z "${restartFrom}" ] && [ ${SECONDS} -ge ${graceUntil} ]; then + healthCheckCounter=$((healthCheckCounter + 1)) + + if [ ${healthCheckCounter} -ge 3 ]; then + healthCheckCounter=0 + + if ! hci_transport_healthy; then + healthFailCount=$((healthFailCount + 1)) + + if [ ${healthFailCount} -ge 2 ]; then + echo "[monitor] HCI transport unresponsive (${healthFailCount} consecutive failures)." >&2 + restartFrom="bridge" + healthFailCount=0 + fi else - echo "[otbr-radio] HCI health check failed (${healthFailCount}/3)." >&2 + healthFailCount=0 fi - else - healthFailCount=0 fi - else - healthCheckInterval=$((healthCheckInterval + 1)) fi - if ${needRestart}; then - healthCheckInterval=0 - healthFailCount=0 + # --------------------------------------------------------------- + # Execute restart from the identified layer. + # --------------------------------------------------------------- + [ -z "${restartFrom}" ] && continue + + # Any restart resets the health check state. + healthCheckCounter=0 + healthFailCount=0 + + case "${restartFrom}" in + socat) + # Socat died — the serial link is gone. Everything above + # is invalid: cpcd's fd is dead, bridge's CPC socket is + # dead. Tear down the whole stack and restart bottom-up. + teardown_ble_chain + kill ${CPCD_PID} 2>/dev/null || true + wait ${CPCD_PID} 2>/dev/null || true + + if ! restart_socat; then + backoff=$((backoff < 60 ? backoff * 2 : 60)) + echo "[monitor] socat restart failed; retrying in ${backoff}s..." >&2 + continue + fi - if ${bridgeDied}; then - echo "[otbr-radio] bt_host_cpc_hci_bridge (PID ${BT_BRIDGE_PID}) died." >&2 + if ! restart_cpcd; then + backoff=$((backoff < 60 ? backoff * 2 : 60)) + echo "[monitor] cpcd restart failed; retrying in ${backoff}s..." >&2 + continue + fi - if ! ble_restart_bridge; then - backoff=$((backoff < 30 ? backoff * 2 : 30)) - echo "[otbr-radio] Bridge restart failed; retrying in ${backoff}s..." >&2 - sleep "${backoff}" + if ! start_ble_chain; then + backoff=$((backoff < 60 ? backoff * 2 : 60)) + echo "[monitor] BLE chain restart failed; retrying in ${backoff}s..." >&2 continue fi - else - echo "[otbr-radio] btattach (PID ${BTATTACH_PID}) died — restarting BLE attach..." >&2 - fi - if ble_attach; then - echo "[otbr-radio] BLE stack restarted successfully." - backoff=2 + backoff=5 graceUntil=$((SECONDS + 30)) - else - backoff=$((backoff < 30 ? backoff * 2 : 30)) - echo "[otbr-radio] BLE restart failed; retrying in ${backoff}s..." >&2 - fi - fi + echo "[monitor] Full stack recovered (socat → cpcd → BLE)." + ;; - sleep "${backoff}" + cpcd) + # cpcd died — bridge's CPC socket is dead. + # Tear down BLE chain, restart cpcd, then BLE chain. + teardown_ble_chain + + if ! restart_cpcd; then + backoff=$((backoff < 60 ? backoff * 2 : 60)) + echo "[monitor] cpcd restart failed; retrying in ${backoff}s..." >&2 + continue + fi + + if ! start_ble_chain; then + backoff=$((backoff < 60 ? backoff * 2 : 60)) + echo "[monitor] BLE chain restart failed; retrying in ${backoff}s..." >&2 + continue + fi + + backoff=5 + graceUntil=$((SECONDS + 30)) + echo "[monitor] Stack recovered (cpcd → BLE)." + ;; + + bridge) + # Bridge, proxy, or HCI transport failed — tear down and + # restart just the BLE chain. cpcd is still healthy. + teardown_ble_chain + + if ! start_ble_chain; then + backoff=$((backoff < 60 ? backoff * 2 : 60)) + echo "[monitor] BLE chain restart failed; retrying in ${backoff}s..." >&2 + continue + fi + + backoff=5 + graceUntil=$((SECONDS + 30)) + echo "[monitor] BLE chain recovered." + ;; + + btattach) + # Only btattach died — bluetoothd is now orphaned but the + # bridge and proxy are fine. Restart attach + bluetoothd. + pkill -x bluetoothd 2>/dev/null || true + [ -n "${BTATTACH_PID:-}" ] && kill ${BTATTACH_PID} 2>/dev/null || true + [ -n "${BTATTACH_PID:-}" ] && wait ${BTATTACH_PID} 2>/dev/null || true + + # Bring down the stale HCI device. + local adapterFile="${DBUS_DIR}/ble_adapter_id" + + if [ -f "$adapterFile" ]; then + local oldIdx + oldIdx=$(cat "$adapterFile" 2>/dev/null) + + if [ -n "$oldIdx" ]; then + nsenter --net="${HOST_NETNS}" hciconfig "hci${oldIdx}" down 2>/dev/null || true + fi + fi + + if ! ble_attach; then + backoff=$((backoff < 60 ? backoff * 2 : 60)) + echo "[monitor] BLE attach restart failed; retrying in ${backoff}s..." >&2 + continue + fi + + backoff=5 + graceUntil=$((SECONDS + 30)) + echo "[monitor] btattach recovered." + ;; + esac done } @@ -707,13 +980,14 @@ else else echo "[otbr-radio] WARNING: Bridge did not create pts_hci during startup; monitor will retry." >&2 fi - - # Start background monitor to handle bridge/btattach failures. - ble_monitor & - BLE_MONITOR_PID=$! - echo "[otbr-radio] BLE monitor started (PID ${BLE_MONITOR_PID})." fi +# Start unified service monitor for the entire radio stack +# (socat, cpcd, bridge, proxy, btattach, bluetoothd). +service_monitor & +SERVICE_MONITOR_PID=$! +echo "[otbr-radio] Service monitor started (PID ${SERVICE_MONITOR_PID})." + ############################################################################### # 7. Start otbr-agent with auto-restart # @@ -729,6 +1003,14 @@ echo "[otbr-radio] Starting otbr-agent (backbone: ${BACKBONE_IF})..." otbr_backoff=2 while true; do + # Wait for cpcd to be alive before starting otbr-agent. + # If cpcd was restarted by service_monitor, this avoids spawning + # otbr-agent just to have it crash immediately. + while ! pgrep -x cpcd >/dev/null 2>&1; do + echo "[otbr-radio] Waiting for cpcd before starting otbr-agent..." >&2 + sleep 5 + done + otbr_start=$(date +%s) otbr-agent \ -I wpan0 \ diff --git a/docs/REMOTE_RADIO_FOR_DEVELOPMENT.md b/docs/REMOTE_RADIO_FOR_DEVELOPMENT.md index 6d532bd5..68f1ec60 100644 --- a/docs/REMOTE_RADIO_FOR_DEVELOPMENT.md +++ b/docs/REMOTE_RADIO_FOR_DEVELOPMENT.md @@ -107,7 +107,7 @@ Two methods are supported for connecting the radio to the otbr-radio container: | Method | When to use | Environment variable | |--------|-------------|---------------------| | **Local USB** | Radio is plugged directly into the Docker host | `RADIO_DEVICE=/dev/ttyACM0` | -| **Remote serial tunnel** | Radio is on your laptop, Docker runs on a remote server | `RADIO_PORT=21000` | +| **Remote serial tunnel** | Radio is on your laptop, Docker runs on a remote server | `RADIO_PORT=21234` | ### Local USB Radio @@ -132,7 +132,7 @@ Remote-SSH) but the BRD2703 is plugged into your **local workstation**, use │ ▼ │ │ │ /dev/ttyRadio │ │ │ remote-serial.py │ SSH tunnel │ │ │ │ │ │ (serial→TCP │───────────────▶│ │ ▼ │ │ - │ relay + SSH -R) │ port 21000 │ │ cpcd │ │ + │ relay + SSH -R) │ port 21234 │ │ cpcd │ │ │ │ │ │ (same as local USB) │ │ └──────────────────┘ │ └─────────────────────────┘ │ └───────────────────────────────┘ @@ -177,28 +177,28 @@ Example output: ``` [remote-serial] OK: Auto-detected radio: /dev/ttyACM0 (Silicon Labs CP210x) -[remote-serial] OK: Remote UID: 1000 -[remote-serial] OK: Tunnel port: 21000 -[remote-serial] OK: TCP server listening on 127.0.0.1:21000 +[remote-serial] OK: Remote UID: 1234 +[remote-serial] OK: Tunnel port: 21234 +[remote-serial] OK: TCP server listening on 127.0.0.1:21234 ======================================= REMOTE SERIAL TUNNEL ACTIVE ======================================= Radio: /dev/ttyACM0 - Local TCP: 127.0.0.1:21000 - Tunnel port: 21000 on remote (all interfaces) + Local TCP: 127.0.0.1:21234 + Tunnel port: 21234 on remote (all interfaces) ======================================= - RADIO_PORT=21000 + RADIO_PORT=21234 ``` **Step 2 — On the dev server** (or in your devcontainer): ```bash -RADIO_PORT=21000 ./dockerw -T bash +RADIO_PORT=21234 ./dockerw -T bash ``` -Or add `RADIO_PORT=21000` to `docker/.env`. +Or add `RADIO_PORT=21234` to `docker/.env`. **Step 3 — Validate** (inside the Barton or otbr-radio container): @@ -213,9 +213,9 @@ developer on a shared server gets a unique port automatically: | UID | Port | |-----|------| -| 1000 | 21000 | -| 1001 | 21001 | -| 1002 | 21002 | +| 1234 | 21234 | +| 1235 | 21235 | +| 1236 | 21236 | #### Command-Line Options @@ -243,7 +243,7 @@ Use the `-T` flag to add `docker/compose.otbr-radio.yaml` to the compose stack: RADIO_DEVICE=/dev/ttyACM0 ./dockerw -T bash # Remote serial tunnel: -RADIO_PORT=21000 ./dockerw -T bash +RADIO_PORT=21234 ./dockerw -T bash ``` To override the backbone interface: @@ -289,7 +289,7 @@ before opening VS Code: export RADIO_DEVICE=/dev/ttyACM0 # Remote tunnel: -export RADIO_PORT=21000 +export RADIO_PORT=21234 ``` Then rebuild the devcontainer (**Dev Containers: Rebuild Container**). diff --git a/scripts/remote-radio/remote-serial.py b/scripts/remote-radio/remote-serial.py index 65ccd753..06be24d6 100755 --- a/scripts/remote-radio/remote-serial.py +++ b/scripts/remote-radio/remote-serial.py @@ -436,7 +436,14 @@ def _tcp_to_serial_loop(self, client: socket.socket, ser, serial_mod) -> None: try: ser.write(data) except serial_mod.SerialException as e: - info(f"tcp→serial: serial write error: {e}") + warn(f"tcp→serial: serial write error: {e}") + # Close the port so _run's is_open check triggers a reopen. + # On Windows, PermissionError / "Access is denied" leaves the + # pyserial is_open flag True even though the handle is dead. + try: + ser.close() + except Exception: + pass break From 28aeabeabcd474bbbe2ca4b75950c20b0fcf786d Mon Sep 17 00:00:00 2001 From: Thomas Lea Date: Tue, 2 Jun 2026 18:35:50 +0000 Subject: [PATCH 13/17] fix(docker): stabilize CPC BLE/Thread radio sharing for Matter commissioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The EFR32 CPC chip shares a single radio between BLE and 802.15.4 (Thread). This causes multiple reliability issues when both protocols are active: HCI command timeouts crash the adapter, scan-disable races prevent BLE connections, and overzealous monitoring kills bluetoothd during active commissioning. hci_pty_proxy.py — major rewrite for robustness: All filter functions rewritten to use a new _iter_h4_packets() helper that properly parses H4 UART packet boundaries (Command, ACL, SCO, Event). Previously, byte-level pattern matching could match inside ACL data payloads, corrupting packets. Always intercept cached HCI commands. All Command Complete responses are cached during adapter init (when firmware is responsive). After init, ANY occurrence of a cached command gets the cached response — regardless of scan state, connection state, or idle state. This single rule prevents the 18-second adapter reset loop caused by health-check command timeouts when the CPC radio is shared with Thread. Buffer connection commands during scan-disable. The firmware takes ~2s to process LE Set Scan Enable (disable). The proxy sends a synthetic CC immediately so the kernel doesn't timeout, but buffers any LE Create Connection commands until the firmware confirms scanning stopped. Without this, the connection command arrives while the firmware is busy and times out. Move _cache_cc_response to end of FW-to-HOST pipeline so cached CCs contain modified values (ext-adv bits cleared, short CCs padded, Enhanced Connection Complete translated to legacy). Strip Extended Advertising command support bits from Read Local Supported Commands response (bytes 36-37) in addition to the existing LE feature bit stripping. otbr-radio-entrypoint.sh — fix stuck discovery detection: Split hci_clear_stuck_discovery() into hci_discovery_is_active() (detection) and hci_clear_stuck_discovery() (remediation). Require 12 consecutive monitor iterations (~60s) of active discovery before considering it stuck. Previously the check ran every 5 seconds and immediately killed bluetoothd, which would terminate it during normal Matter commissioning that legitimately uses BLE discovery for <30s. OpenThreadClient.cpp — improve CPC attach reliability: Increase ATTACH_WAIT_SECONDS from 25 to 60 for CPC radio hardware where shared radio scheduling causes slower attach. Add retry loop for GetActiveDatasetTlvs after successful attach. The attach callback can fire before OTBR finishes applying the dataset. Poll with D-Bus dispatch (1s intervals) until the dataset is available or time expires. barton-core-reference-io.c — prevent pipe deadlock: Set O_NONBLOCK on the log output pipe write end. Prevents deadlock when subsystem init (e.g. Zigbee) blocks the main thread before the GLib main loop starts draining the pipe. validate.sh — BLE/Thread radio coordination: Add 3-second sleep after BLE scan check to let the firmware finish processing scan-disable before Thread (OTBR) checks. The shared CPC radio needs time to transition between protocols. --- .../subsystems/thread/OpenThreadClient.cpp | 37 +- docker/otbr-radio-entrypoint.sh | 105 +++ reference/src/barton-core-reference-io.c | 11 + scripts/remote-radio/hci_pty_proxy.py | 850 ++++++++++++++---- scripts/remote-radio/validate.sh | 11 + 5 files changed, 822 insertions(+), 192 deletions(-) diff --git a/core/src/subsystems/thread/OpenThreadClient.cpp b/core/src/subsystems/thread/OpenThreadClient.cpp index bfda0e3b..1b1f73c7 100644 --- a/core/src/subsystems/thread/OpenThreadClient.cpp +++ b/core/src/subsystems/thread/OpenThreadClient.cpp @@ -68,8 +68,10 @@ using namespace otbr::DBus; namespace { // Note: I've seen approx 7 seconds on a standard Ubuntu desktop environment for a new network, about 18 seconds for - // creating and replacing an existing network. This should just "go away" if we implement a proper main loop. - constexpr int ATTACH_WAIT_SECONDS = 25; + // creating and replacing an existing network. CPC-based radio hardware (e.g., EFR32 via serial/SPI) can take 35+ + // seconds due to shared radio scheduling and slower transport. This should just "go away" if we implement a proper + // main loop. + constexpr int ATTACH_WAIT_SECONDS = 60; } // namespace namespace barton @@ -142,10 +144,35 @@ namespace barton if (threadApiAttachCallbackCalled && threadApiAttachError == ClientError::ERROR_NONE) { - threadApiCallError = threadApiBus->GetActiveDatasetTlvs(retVal); - if (threadApiCallError != ClientError::ERROR_NONE) + // The Attach callback may fire before OTBR has finished applying the new dataset. + // Retry GetActiveDatasetTlvs while time remains, processing D-Bus messages in between + // so we receive the "Active dataset tlvs changed" signal. + while (true) { - icError("Failed to get active dataset tlvs. Error = %d", (int) threadApiCallError); + threadApiCallError = threadApiBus->GetActiveDatasetTlvs(retVal); + + if (threadApiCallError == ClientError::ERROR_NONE && !retVal.empty()) + { + break; + } + + auto next = steady_clock::now(); + timer = timer - duration_cast(next - current); + current = next; + + if (timer.count() <= 0) + { + icError("Timed out waiting for active dataset tlvs after successful attach."); + break; + } + + icDebug("Active dataset not yet available (error=%d), retrying...", (int) threadApiCallError); + + // Dispatch D-Bus messages for up to 1 second before retrying + dbus_connection_read_write_dispatch(dbusConnection.get(), 1000); + next = steady_clock::now(); + timer = timer - duration_cast(next - current); + current = next; } } else if (timer.count() <= 0) diff --git a/docker/otbr-radio-entrypoint.sh b/docker/otbr-radio-entrypoint.sh index 64829d84..1dc5bc3e 100755 --- a/docker/otbr-radio-entrypoint.sh +++ b/docker/otbr-radio-entrypoint.sh @@ -765,6 +765,88 @@ hci_transport_healthy() { return 0 } +# hci_clear_stuck_discovery — detect and clear stuck BLE discovery mode. +# +# bluetoothd can get stuck in discovery if a client (e.g. the validate +# script or a killed bluetoothctl session) started scanning but the +# StopDiscovery D-Bus call never completed. Once stuck, all subsequent +# scan attempts return "InProgress" and no new device events are emitted. +# +# bluetoothctl "scan off" from a new session returns org.bluez.Error.Failed +# because BlueZ only allows the D-Bus client that started discovery to +# stop it. Restarting bluetoothd clears all D-Bus client state without +# disrupting the HCI transport (btattach, bridge, proxy stay alive). +# + +# hci_discovery_is_active — returns 0 if BLE discovery is active on the +# CPC adapter, 1 otherwise. +hci_discovery_is_active() { + local adapterFile="${DBUS_DIR}/ble_adapter_id" + [ -f "$adapterFile" ] || return 1 + local idx + idx=$(cat "$adapterFile" 2>/dev/null) + [ -n "$idx" ] || return 1 + local target_hci="hci${idx}" + + local bd_addr + bd_addr=$(nsenter --net="${HOST_NETNS}" hciconfig "${target_hci}" 2>/dev/null \ + | awk '/BD Address:/{print $3}') || bd_addr="" + [ -n "$bd_addr" ] || return 1 + + # Query bluetoothctl "show" to check if discovery is active. + local show_output + show_output=$( + { + echo "select ${bd_addr}" + sleep 0.3 + echo "show" + sleep 0.5 + } | DBUS_SYSTEM_BUS_ADDRESS="unix:path=${DBUS_SOCKET_PATH}" \ + timeout 5 nsenter --net="${HOST_NETNS}" \ + bluetoothctl --agent=NoInputNoOutput 2>&1 + ) || true + + if echo "$show_output" | grep -qi "Discovering: yes"; then + return 0 + fi + + return 1 +} + +# hci_clear_stuck_discovery — kills and restarts bluetoothd to clear +# orphaned discovery state. +# +# Returns 0 if discovery was cleared. Returns 1 if bluetoothd did not +# come back on D-Bus (caller should consider a BLE restart). +hci_clear_stuck_discovery() { + echo "[monitor] BLE discovery stuck — restarting bluetoothd..." >&2 + # SIGKILL — bluetoothd's clean shutdown sends HCI Reset which + # kills the CPC transport and takes down btattach + the adapter. + pkill -9 -x bluetoothd 2>/dev/null || true + sleep 1 + nsenter --net="${HOST_NETNS}" bluetoothd 2>/dev/null & + + # Wait for bluetoothd to register on D-Bus. + local waited=0 + + while [ ${waited} -lt 5 ]; do + + if DBUS_SYSTEM_BUS_ADDRESS="unix:path=${DBUS_SOCKET_PATH}" \ + dbus-send --system --dest=org.bluez --print-reply \ + /org/bluez org.freedesktop.DBus.Introspectable.Introspect \ + >/dev/null 2>&1; then + echo "[monitor] BLE discovery cleared (bluetoothd restarted)." >&2 + return 0 + fi + + sleep 1 + waited=$((waited + 1)) + done + + echo "[monitor] bluetoothd did not come back on D-Bus after restart." >&2 + return 1 +} + # service_monitor — unified background loop that watches the entire stack. # # Checks all layers bottom-to-top each iteration. The lowest failed layer @@ -781,6 +863,7 @@ service_monitor() { local backoff=5 local healthCheckCounter=0 local healthFailCount=0 + local discoveryActiveCount=0 # Grace period — skip health checks for 30s after startup or restart # to let the adapter stabilise. local graceUntil=$((SECONDS + 30)) @@ -848,6 +931,28 @@ service_monitor() { healthFailCount=0 fi fi + + # Stuck discovery check — if a bluetoothctl session left + # discovery running, clear it. Only consider discovery + # "stuck" after it has been continuously active for several + # monitor iterations (≥60s) — normal Matter commissioning + # legitimately uses discovery and completes in <30s. + if [ -z "${restartFrom}" ]; then + if hci_discovery_is_active; then + discoveryActiveCount=$((discoveryActiveCount + 1)) + # ~12 iterations × 5s backoff ≈ 60s of continuous discovery + if [ ${discoveryActiveCount} -ge 12 ]; then + echo "[monitor] BLE discovery stuck for ${discoveryActiveCount} checks — clearing..." >&2 + if ! hci_clear_stuck_discovery; then + echo "[monitor] Stuck discovery could not be cleared — restarting BLE chain." >&2 + restartFrom="bridge" + fi + discoveryActiveCount=0 + fi + else + discoveryActiveCount=0 + fi + fi fi # --------------------------------------------------------------- diff --git a/reference/src/barton-core-reference-io.c b/reference/src/barton-core-reference-io.c index 94c1d187..429b9b68 100644 --- a/reference/src/barton-core-reference-io.c +++ b/reference/src/barton-core-reference-io.c @@ -40,6 +40,7 @@ */ #include "barton-core-reference-io.h" +#include #include #include #include @@ -95,6 +96,16 @@ static void ioSetup(void) { outputReceivePipe = fds[0]; outputSendPipe = fds[1]; + + // Set write end to non-blocking so log writes never block when the + // pipe buffer is full. This prevents a deadlock when subsystem + // initialization (e.g. Zigbee) blocks the main thread before the + // GLib main loop starts draining the pipe. + int flags = fcntl(outputSendPipe, F_GETFL); + if (flags != -1) + { + fcntl(outputSendPipe, F_SETFL, flags | O_NONBLOCK); + } } atexit(ioShutdown); } diff --git a/scripts/remote-radio/hci_pty_proxy.py b/scripts/remote-radio/hci_pty_proxy.py index 0353663e..e1f526aa 100644 --- a/scripts/remote-radio/hci_pty_proxy.py +++ b/scripts/remote-radio/hci_pty_proxy.py @@ -83,6 +83,49 @@ import tty +def _iter_h4_packets(data: bytes): + """Yield (start, end) index pairs for each H4 packet in *data*. + + Properly handles HCI commands (0x01), ACL data (0x02), SCO data + (0x03), and events (0x04). Incomplete packets at the tail are + yielded as a single final span so callers can pass them through. + """ + i = 0 + + while i < len(data): + h4 = data[i] + + if h4 == 0x01 and i + 3 < len(data): + # Command: type(1) + opcode(2) + plen(1) + params(plen) + plen = data[i + 3] + end = i + 4 + plen + elif h4 == 0x02 and i + 4 < len(data): + # ACL data: type(1) + handle(2) + dlen(2) + payload(dlen) + dlen = data[i + 3] | (data[i + 4] << 8) + end = i + 5 + dlen + elif h4 == 0x03 and i + 3 < len(data): + # SCO data: type(1) + handle(2) + dlen(1) + payload(dlen) + dlen = data[i + 3] + end = i + 4 + dlen + elif h4 == 0x04 and i + 2 < len(data): + # Event: type(1) + code(1) + plen(1) + params(plen) + plen = data[i + 2] + end = i + 3 + plen + else: + # Unknown type or insufficient header — yield single byte. + yield i, i + 1 + i += 1 + continue + + if end > len(data): + # Incomplete packet at tail — yield all remaining bytes. + yield i, len(data) + break + + yield i, end + i = end + + # Feature bit to clear — byte offset 1, bit 4 (overall bit 12) # in the 8-byte LE features array. # @@ -167,6 +210,50 @@ [0x04, 0x0E, 0x07, 0x01, 0x23, 0x0C, 0x00, 0x00, 0x00, 0x00] ) +# --- LE Scan command interception --- +# +# The CPC firmware cannot reliably stop LE scanning. When the kernel +# sends LE Set Scan Enable (0x200C) with enable=0, the firmware either +# fails to respond or responds incorrectly, causing a kernel command +# timeout (-ETIMEDOUT) that permanently corrupts the HCI command queue. +# All subsequent commands fail with "InProgress" / "command tx timeout". +# +# Fix: intercept all scan-related commands and manage them in the proxy. +# +# - Scan disable (0x200C enable=0): return synthetic CC to the kernel +# immediately (prevents timeout), AND forward the command to +# firmware as a best-effort stop. The firmware's CC response is +# filtered out on the FW→HOST path (kernel already got its CC). +# When the firmware's CC arrives, _fw_scanning is cleared so +# subsequent commands pass through normally. This is critical for +# Thread: the EFR32 shares the radio between BLE and 802.15.4, +# and active BLE scanning starves Thread operations. +# +# - Scan enable (0x200C enable=1) while firmware is already scanning: +# return synthetic CC without forwarding (idempotent — the firmware +# is already producing scan results). The first scan enable is +# forwarded with filter_duplicates=0 so the firmware always reports +# all devices. +# +# - Scan parameters (0x200B) while firmware is scanning: return +# synthetic CC without forwarding (firmware rejects param changes +# during active scan with "Command Disallowed"). +# +# HCI LE Set Scan Enable (H4 format): +# 01 0c 20 02 XX FF +# XX = enable (00=disable, 01=enable), FF = filter_duplicates +_LE_SCAN_ENABLE_PREFIX = bytes([0x01, 0x0C, 0x20, 0x02]) +_LE_SCAN_CMD_LEN = 6 # H4(1) + opcode(2) + plen(1) + enable(1) + filter(1) + +# HCI LE Set Scan Parameters (H4 format): +# 01 0b 20 07 ... (7 parameter bytes) +_LE_SCAN_PARAMS_PREFIX = bytes([0x01, 0x0B, 0x20, 0x07]) +_LE_SCAN_PARAMS_CMD_LEN = 11 # H4(1) + opcode(2) + plen(1) + params(7) + +# Pre-built Command Complete responses (H4 format): +_LE_SCAN_ENABLE_CC = bytes([0x04, 0x0E, 0x04, 0x01, 0x0C, 0x20, 0x00]) +_LE_SCAN_PARAMS_CC = bytes([0x04, 0x0E, 0x04, 0x01, 0x0B, 0x20, 0x00]) + # --- Table of HCI commands that the CPC BLE firmware cannot handle --- # # For each (opcode_le_bytes, param_len_byte, description) we store the @@ -331,30 +418,39 @@ def _configure_raw(fd: int) -> None: def _strip_ext_adv(data: bytes) -> bytes: """If *data* contains the LE features response, clear the Extended Advertising bit so the kernel uses legacy BLE commands.""" - idx = data.find(_MATCH_PREFIX) + # Only match at proper H4 event boundaries — never inside ACL data. + for start, end in _iter_h4_packets(data): + if data[start] != 0x04: + continue - if idx < 0: - return data + pkt = data[start:end] + idx = pkt.find(_MATCH_PREFIX) - feat_start = idx + _FEATURES_OFFSET + if idx != 0: + continue - if feat_start + 8 > len(data): - return data # Incomplete — leave unchanged (rare edge-case). + feat_start = _FEATURES_OFFSET - original = data[feat_start + _LE_FEAT_BYTE] + if feat_start + 8 > len(pkt): + continue # Incomplete — leave unchanged. - if not (original & _LE_EXT_ADV_BIT): - return data # Already clear — nothing to do. + original = pkt[feat_start + _LE_FEAT_BYTE] - buf = bytearray(data) - buf[feat_start + _LE_FEAT_BYTE] = original & ~_LE_EXT_ADV_BIT - print( - f"[hci-proxy] Cleared LE Extended Advertising feature bit " - f"(0x{original:02X} -> 0x{buf[feat_start + _LE_FEAT_BYTE]:02X})", - flush=True, - ) + if not (original & _LE_EXT_ADV_BIT): + continue # Already clear. - return bytes(buf) + buf = bytearray(data) + buf[start + feat_start + _LE_FEAT_BYTE] = original & ~_LE_EXT_ADV_BIT + print( + f"[hci-proxy] Cleared LE Extended Advertising feature bit " + f"(0x{original:02X} -> " + f"0x{buf[start + feat_start + _LE_FEAT_BYTE]:02X})", + flush=True, + ) + + return bytes(buf) + + return data def _strip_ext_adv_cmds(data: bytes) -> bytes: @@ -367,35 +463,29 @@ def _strip_ext_adv_cmds(data: bytes) -> bytes: bitmap — instead of the LE features. We must zero these bytes so the kernel sticks to legacy scan / advertising commands. """ - # Parse HCI event structure (same approach as _log_hci_event) - # to robustly find the Command Complete for opcode 0x1002. - i = 0 - - while i < len(data) - 6: - if data[i] != 0x04: - i += 1 + # Parse HCI event structure to robustly find the Command Complete + # for opcode 0x1002. + for start, end in _iter_h4_packets(data): + if data[start] != 0x04 or end - start < 7: continue - evt_code = data[i + 1] - plen = data[i + 2] - pkt_end = i + 3 + plen + evt_code = data[start + 1] + plen = data[start + 2] - if evt_code != 0x0E or i + 6 >= len(data): - i = max(pkt_end, i + 1) + if evt_code != 0x0E: continue - opcode = data[i + 5] << 8 | data[i + 4] + opcode = data[start + 5] << 8 | data[start + 4] if opcode != 0x1002: - i = max(pkt_end, i + 1) continue - status = data[i + 6] + status = data[start + 6] if _DEBUG: - snippet = data[i : min(i + 15, len(data))].hex(" ") + snippet = data[start : min(start + 15, end)].hex(" ") print( - f"[hci-proxy] _strip_ext_adv_cmds: found 0x1002 at i={i} " + f"[hci-proxy] _strip_ext_adv_cmds: found 0x1002 at i={start} " f"plen={plen} status=0x{status:02X} raw={snippet}", flush=True, ) @@ -408,9 +498,9 @@ def _strip_ext_adv_cmds(data: bytes) -> bytes: ) return data - # Supported commands bitmap starts at i+7 (after H4, event code, - # plen, num_hci_cmd_pkts, opcode_lo, opcode_hi, status). - cmds_start = i + 7 + # Supported commands bitmap starts at start+7 (after H4, event + # code, plen, num_hci_cmd_pkts, opcode_lo, opcode_hi, status). + cmds_start = start + 7 if cmds_start + 64 > len(data): return data # Incomplete — leave unchanged. @@ -437,35 +527,429 @@ def _strip_ext_adv_cmds(data: bytes) -> bytes: return data +# Track firmware and host scanning state independently. +# +# _fw_scanning: True once a real scan-enable is forwarded to firmware. +# Cleared when scan-disable CC is received from firmware, +# or when a BLE connection command passes through. +# +# _host_scanning: True when the kernel believes scanning is active. +# Set True on scan-enable, False on scan-disable. +# Used to gate advertising reports on the FW→HOST path — +# reports are dropped when the host thinks scanning is +# stopped, preventing PTY buffer flooding that would +# block command forwarding and kill the transport. +# +# _pending_fw_scan_disable: True when we've forwarded a scan-disable to +# firmware and are waiting for its Command Complete. +# The CC must be filtered from the FW→HOST stream +# (kernel already received a synthetic CC). +_fw_scanning = False +_host_scanning = False +_pending_fw_scan_disable = False +_adv_report_count = 0 +_adv_report_dropped = 0 + +# Connection commands buffered while firmware is processing scan-disable. +# The firmware takes ~2 seconds to stop scanning. If a connection +# command arrives during that window, the firmware can't respond with +# a Command Status in time and the kernel times out. We buffer the +# command and replay it once the firmware confirms scanning stopped. +_pending_conn_cmds: list[bytes] = [] + +# Firmware-side file descriptor, set once in main(). Used by +# _filter_fw_scan_disable_cc to replay buffered connection commands. +_fw_fd: int = -1 + +# --- Response cache --- +# +# The CPC firmware shares the radio between BLE and 802.15.4 (Thread). +# After init, the firmware is often too slow to respond to HCI commands +# within the kernel's 2-second timeout, which kills the HCI transport. +# +# We cache ALL Command Complete responses seen during init (when the +# firmware is responsive) and ALWAYS replay cached responses for any +# subsequent occurrence of those commands — regardless of whether we +# are idle, scanning, or in an active BLE connection. This is the +# single rule that keeps the adapter alive. +# +# For commands with no cached response that arrive during scanning +# (when the firmware is completely unresponsive), we return a minimal +# synthetic CC. Outside scanning, uncached commands pass through +# to the firmware. +_cached_cc: dict[int, bytes] = {} # opcode → full CC event bytes (H4 format) + + +def _cache_cc_response(data: bytes) -> None: + """Scan FW→HOST data for Command Complete events and cache them.""" + for start, end in _iter_h4_packets(data): + if data[start] != 0x04: + continue + + if end - start < 6 or data[start + 1] != 0x0E: + continue + + opcode = data[start + 4] | (data[start + 5] << 8) + + if opcode not in _cached_cc: + _cached_cc[opcode] = bytes(data[start:end]) + print( + f"[hci-proxy] Cached CC response for opcode " + f"0x{opcode:04X} ({end - start} bytes)", + flush=True, + ) + + +def _filter_fw_scan_disable_cc(data: bytes) -> bytes: + """Filter out the firmware's CC response for a forwarded scan-disable. + + When we intercept scan-disable, we return a synthetic CC to the + kernel and also forward the command to firmware. The firmware's CC + must be filtered out so the kernel doesn't receive a duplicate. + When the CC is received, _fw_scanning is cleared — the firmware has + stopped scanning and can handle other HCI commands normally. This + is critical for Thread: the EFR32 CPC chip shares the radio between + BLE and 802.15.4, and active BLE scanning starves Thread operations. + """ + global _fw_scanning, _pending_fw_scan_disable + + if not _pending_fw_scan_disable: + return data + + # Look for CC event with opcode 0x200C (LE Set Scan Enable). + # H4 format: 04 0E 0C 20 ... + _SCAN_ENABLE_OPCODE_LE = bytes([0x0C, 0x20]) + + result = bytearray() + + for start, end in _iter_h4_packets(data): + if data[start] != 0x04: + result.extend(data[start:end]) + continue + + if ( + end - start >= 7 + and data[start + 1] == 0x0E + and data[start + 4 : start + 6] == _SCAN_ENABLE_OPCODE_LE + ): + # This is the CC for our forwarded scan-disable. + status = data[start + 6] + _fw_scanning = False + _pending_fw_scan_disable = False + print( + f"[hci-proxy] Firmware scan-disable CC received " + f"(status=0x{status:02X}) — firmware scanning stopped", + flush=True, + ) + + # Replay any connection commands that were buffered while + # waiting for the firmware to stop scanning. + if _pending_conn_cmds: + for cmd in _pending_conn_cmds: + os.write(_fw_fd, cmd) + cmd_opcode = cmd[1] | (cmd[2] << 8) + print( + f"[hci-proxy] Forwarding buffered connection " + f"command 0x{cmd_opcode:04X}", + flush=True, + ) + _pending_conn_cmds.clear() + + continue + + result.extend(data[start:end]) + + return bytes(result) + + +def _intercept_cached_commands(data: bytes, host_fd: int) -> bytes: + """Intercept HCI commands with cached responses to prevent timeouts. + + The CPC firmware shares the radio between BLE and 802.15.4 (Thread). + After init, the firmware is often too slow to respond to HCI + commands, causing kernel command timeouts that kill the HCI + transport. This happens during idle, during scanning, AND during + active BLE connections (the radio is shared three ways). + + Rule: if a command has a cached CC from init, ALWAYS return it. + No conditions, no state checks. This is the single mechanism that + keeps the adapter alive. + + Scan-related opcodes (0x200B, 0x200C) are skipped — they are managed + by _intercept_scan_commands. + + Connection commands (0x200D, 0x200E, 0x2043) always pass through + because they use Command Status events (not CC) and must reach the + firmware to establish BLE connections. + """ + global _fw_scanning + + # Opcodes handled by _intercept_scan_commands — pass through. + _SCAN_OPCODES = {0x200B, 0x200C} + + # Connection commands — pass through AND end scanning state. + # These use Command Status (not CC) so they can't be cached. + # 0x200D = LE Create Connection + # 0x200E = LE Create Connection Cancel + # 0x2043 = LE Extended Create Connection (v2) + _CONN_OPCODES = {0x200D, 0x200E, 0x2043} + + result = bytearray() + + for start, end in _iter_h4_packets(data): + if data[start] != 0x01: + # Non-command packet (ACL, SCO, etc.) — pass through. + result.extend(data[start:end]) + continue + + if end - start < 4: + result.extend(data[start:end]) + continue + + opcode = data[start + 1] | (data[start + 2] << 8) + + if opcode in _SCAN_OPCODES: + # Let scan commands pass to firmware. + result.extend(data[start:end]) + continue + + if opcode in _CONN_OPCODES: + if _pending_fw_scan_disable: + # Firmware is still processing scan-disable (~2s). + # Buffer the connection command and replay it once + # the firmware confirms scanning stopped. + _pending_conn_cmds.append(bytes(data[start:end])) + print( + f"[hci-proxy] Buffered connection command " + f"0x{opcode:04X} — waiting for scan-disable", + flush=True, + ) + continue + + # Connection command — pass through to firmware. + # _fw_scanning is cleared because the firmware stops + # scanning internally when a connection is initiated. + _fw_scanning = False + print( + f"[hci-proxy] Connection command 0x{opcode:04X} " f"— passing through", + flush=True, + ) + result.extend(data[start:end]) + continue + + if opcode in _cached_cc: + os.write(host_fd, _cached_cc[opcode]) + print( + f"[hci-proxy] Intercepted opcode 0x{opcode:04X} " f"— cached response", + flush=True, + ) + continue + + if _fw_scanning: + # Firmware is scanning and won't respond to any command. + # Return a synthetic CC so the kernel doesn't time out. + cc = bytes( + [ + 0x04, # H4 event + 0x0E, # Command Complete + 0x04, # param length + 0x01, # num_hci_command_packets + opcode & 0xFF, + (opcode >> 8) & 0xFF, # opcode + 0x00, # status: Success + ] + ) + os.write(host_fd, cc) + print( + f"[hci-proxy] Intercepted opcode 0x{opcode:04X} " + f"— synthetic success (firmware scanning)", + flush=True, + ) + continue + + # Not scanning, no cached response — pass through to firmware. + # This is critical during init so the firmware gets configured. + result.extend(data[start:end]) + + return bytes(result) + + +def _intercept_scan_commands(data: bytes, host_fd: int) -> bytes: + """Intercept LE scan commands to prevent scan-disable timeouts. + + See the module-level comment block above _LE_SCAN_ENABLE_PREFIX for + the full rationale. + """ + global _fw_scanning, _host_scanning, _pending_fw_scan_disable + global _adv_report_count, _adv_report_dropped + + result = bytearray() + + for start, end in _iter_h4_packets(data): + pkt = data[start:end] + + # --- LE Set Scan Enable (0x200C) --- + if pkt[: len(_LE_SCAN_ENABLE_PREFIX)] == _LE_SCAN_ENABLE_PREFIX: + if len(pkt) >= _LE_SCAN_CMD_LEN: + enable = pkt[4] + + if enable == 0: + # Scan DISABLE — return synthetic CC to kernel + # immediately (prevents timeout), AND forward the + # command to firmware so it actually stops scanning. + # The firmware's CC response will be filtered out + # by _filter_fw_scan_disable_cc on the FW→HOST path. + _host_scanning = False + _pending_fw_scan_disable = True + print( + "[hci-proxy] Intercepted LE Set Scan Enable (disable) " + "— synthetic CC + forwarding to firmware", + flush=True, + ) + os.write(host_fd, _LE_SCAN_ENABLE_CC) + result.extend(pkt[:_LE_SCAN_CMD_LEN]) + continue + + if enable == 1 and _fw_scanning: + # Scan ENABLE but firmware already scanning — no-op. + _host_scanning = True + print( + "[hci-proxy] Intercepted LE Set Scan Enable (enable) " + f"— firmware already scanning, synthetic CC " + f"(adv_reports={_adv_report_count})", + flush=True, + ) + os.write(host_fd, _LE_SCAN_ENABLE_CC) + continue + + if enable == 1: + # First scan enable — forward to firmware with + # filter_duplicates=0 so the firmware reports all + # devices on every advertising interval. + _fw_scanning = True + _host_scanning = True + _pending_fw_scan_disable = False + cmd = bytearray(pkt[:_LE_SCAN_CMD_LEN]) + cmd[5] = 0x00 # filter_duplicates = disabled + print( + "[hci-proxy] Forwarding LE Set Scan Enable (enable) " + "— first scan start (filter_duplicates forced off)", + flush=True, + ) + result.extend(cmd) + continue + + # --- LE Set Scan Parameters (0x200B) --- + if pkt[: len(_LE_SCAN_PARAMS_PREFIX)] == _LE_SCAN_PARAMS_PREFIX: + if len(pkt) >= _LE_SCAN_PARAMS_CMD_LEN and _fw_scanning: + # Can't change params while scanning — synthetic CC. + print( + "[hci-proxy] Intercepted LE Set Scan Parameters " + "— firmware scanning, synthetic CC", + flush=True, + ) + os.write(host_fd, _LE_SCAN_PARAMS_CC) + continue + + result.extend(pkt) + + return bytes(result) + + +def _filter_adv_reports(data: bytes) -> bytes: + """Drop LE Advertising Report events when the host is not scanning. + + The firmware scans perpetually (we never forward scan-disable), so + it continuously sends LE Advertising Report events. When the host + thinks scanning is stopped, these events are meaningless and — + critically — they flood the PTY write buffer. If the buffer fills, + the proxy blocks on os.write() and can no longer forward HOST→FW + commands, causing kernel command timeouts that kill the HCI + transport. + + Returns *data* with advertising report events stripped when + _host_scanning is False. Passes data through unchanged when the + host is scanning. + """ + if _host_scanning: + return data + + # Quick reject — LE Meta Events start with H4 0x04 + evt 0x3E. + if b"\x04\x3e" not in data: + return data + + global _adv_report_dropped + out = bytearray() + + for start, end in _iter_h4_packets(data): + if data[start] != 0x04: + # Non-event packet (ACL, SCO, etc.) — pass through. + out.extend(data[start:end]) + continue + + # LE Meta Event (0x3E) with subevent Advertising Report + # (0x02) or Direct Advertising Report (0x05). + if ( + end - start >= 4 + and data[start + 1] == 0x3E + and data[start + 3] in (0x02, 0x05) + ): + _adv_report_dropped += 1 + continue + + out.extend(data[start:end]) + + return bytes(out) + + def _intercept_unsupported_commands(data: bytes, host_fd: int) -> bytes: """Intercept HCI commands that the CPC BLE firmware does not support. - For each command in _INTERCEPTED_COMMANDS, if the command bytes appear - in *data* (heading to the firmware), we: + For each command in _INTERCEPTED_COMMANDS, if a matching HCI command + packet appears in *data* (heading to the firmware), we: 1. Remove the command from *data* (don't forward to firmware) 2. Write a synthetic Command Complete response to *host_fd* (kernel) + Only matches at proper H4 packet boundaries — ACL data payloads are + never inspected. + Returns *data* with intercepted commands removed. """ - for cmd_bytes, (response, desc) in _INTERCEPTED_COMMANDS.items(): - idx = data.find(cmd_bytes) + # Quick reject — check if any intercepted command bytes are present. + found = False + + for cmd_bytes in _INTERCEPTED_COMMANDS: + if cmd_bytes in data: + found = True + break + + if not found: + return data + + out = bytearray() - if idx < 0: + for start, end in _iter_h4_packets(data): + if data[start] != 0x01: + # Not an HCI command — pass through (ACL, SCO, etc.). + out.extend(data[start:end]) continue - print( - f"[hci-proxy] Intercepted {desc} — " - f"returning synthetic response (CPC firmware does not support this command)", - flush=True, - ) + pkt = data[start:end] - # Send the pre-built fake response back to the kernel. - os.write(host_fd, response) + if pkt in _INTERCEPTED_COMMANDS: + response, desc = _INTERCEPTED_COMMANDS[pkt] + print( + f"[hci-proxy] Intercepted {desc} — " + f"returning synthetic response " + f"(CPC firmware does not support this command)", + flush=True, + ) + os.write(host_fd, response) + continue - # Remove the command from the data. - data = data[:idx] + data[idx + len(cmd_bytes) :] + out.extend(pkt) - return data + return bytes(out) def _fix_short_cc(data: bytes) -> bytes: @@ -485,79 +969,61 @@ def _fix_short_cc(data: bytes) -> bytes: buf = bytearray(data) out = bytearray() - i = 0 - while i < len(buf): - # Look for H4 Event (0x04) + Command Complete (0x0E) - if buf[i] == 0x04 and i + 1 < len(buf) and buf[i + 1] == 0x0E: - if i + 2 >= len(buf): - out.extend(buf[i:]) - break - - plen = buf[i + 2] - pkt_end = i + 3 + plen + for start, end in _iter_h4_packets(data): + if data[start] != 0x04 or end - start < 2 or data[start + 1] != 0x0E: + out.extend(buf[start:end]) + continue - if pkt_end > len(buf): - out.extend(buf[i:]) - break - - # Command Complete: plen includes num_cmd(1) + opcode(2) + params - # Minimum plen = 4 means just num_cmd(1) + opcode(2) + status(1) - if plen >= 4 and i + 6 < len(buf): - opcode = buf[i + 4] | (buf[i + 5] << 8) - status = buf[i + 6] - - # --- Strip ext adv/scan command bits from 0x1002 --- - # On kernel 6.8+, use_ext_scan()/use_ext_adv() check - # hdev->commands[36-37] instead of LE features. - if opcode == 0x1002 and status == 0x00: - cmds_start = i + 7 # supported commands bitmap - if cmds_start + 64 <= len(buf): - cleared = [] - for byte_idx in _EXT_ADV_CMD_BYTES: - offset = cmds_start + byte_idx - if buf[offset] != 0: - cleared.append( - f"byte {byte_idx}: " f"0x{buf[offset]:02X} -> 0x00" - ) - buf[offset] = 0x00 - if cleared: - print( - "[hci-proxy] Cleared ext adv/scan command " - "bits in Supported Commands: " + ", ".join(cleared), - flush=True, + plen = data[start + 2] + + # Command Complete: plen includes num_cmd(1) + opcode(2) + params + # Minimum plen = 4 means just num_cmd(1) + opcode(2) + status(1) + if plen >= 4 and end - start >= 7: + opcode = buf[start + 4] | (buf[start + 5] << 8) + status = buf[start + 6] + + # --- Strip ext adv/scan command bits from 0x1002 --- + # On kernel 6.8+, use_ext_scan()/use_ext_adv() check + # hdev->commands[36-37] instead of LE features. + if opcode == 0x1002 and status == 0x00: + cmds_start = start + 7 # supported commands bitmap + if cmds_start + 64 <= end: + cleared = [] + for byte_idx in _EXT_ADV_CMD_BYTES: + offset = cmds_start + byte_idx + if buf[offset] != 0: + cleared.append( + f"byte {byte_idx}: " f"0x{buf[offset]:02X} -> 0x00" ) + buf[offset] = 0x00 + if cleared: + print( + "[hci-proxy] Cleared ext adv/scan command " + "bits in Supported Commands: " + ", ".join(cleared), + flush=True, + ) + + # Only fix if status is non-zero (error) AND the response + # is short (just status, no payload). + payload_len = plen - 4 # bytes after status + if status != 0 and payload_len == 0 and opcode in _OPCODE_MIN_RESPONSE_LEN: + needed = _OPCODE_MIN_RESPONSE_LEN[opcode] + new_plen = 4 + needed + print( + f"[hci-proxy] Padding short CC for opcode 0x{opcode:04X} " + f"(status=0x{status:02X}): {plen} → {new_plen} bytes", + flush=True, + ) + out.append(0x04) + out.append(0x0E) + out.append(new_plen) + out.extend(buf[start + 3 : end]) # original params + out.extend(b"\x00" * needed) # padding + continue - # Only fix if status is non-zero (error) AND the response - # is short (just status, no payload). - payload_len = plen - 4 # bytes after status - if ( - status != 0 - and payload_len == 0 - and opcode in _OPCODE_MIN_RESPONSE_LEN - ): - needed = _OPCODE_MIN_RESPONSE_LEN[opcode] - new_plen = 4 + needed - print( - f"[hci-proxy] Padding short CC for opcode 0x{opcode:04X} " - f"(status=0x{status:02X}): {plen} → {new_plen} bytes", - flush=True, - ) - out.append(0x04) - out.append(0x0E) - out.append(new_plen) - out.extend(buf[i + 3 : pkt_end]) # original params - out.extend(b"\x00" * needed) # padding - i = pkt_end - continue - - # Not a short CC we need to fix — pass through. - out.extend(buf[i:pkt_end]) - i = pkt_end - continue - - out.append(buf[i]) - i += 1 + # Not a short CC we need to fix — pass through. + out.extend(buf[start:end]) return bytes(out) @@ -580,77 +1046,66 @@ def _translate_conn_complete(data: bytes) -> bytes: buf = bytearray(data) out = bytearray() - i = 0 - while i < len(buf): - # Look for H4 Event indicator (0x04) followed by LE Meta (0x3E). - if ( - buf[i] == 0x04 - and i + 3 < len(buf) - and buf[i + 1] == 0x3E - ): - plen = buf[i + 2] - pkt_end = i + 3 + plen - - if pkt_end > len(buf): - # Incomplete packet at end of buffer — pass through as-is. - out.extend(buf[i:]) - break - - subevent = buf[i + 3] + for start, end in _iter_h4_packets(data): + if data[start] != 0x04 or end - start < 4 or data[start + 1] != 0x3E: + out.extend(buf[start:end]) + continue - if subevent == _SUBEVENT_ENH_CONN_COMPLETE_V2 and plen >= 34: - # v2: subevent(1) + common(11) + RPAs(12) + interval/lat/to/acc(7) + adv(1) + sync(2) = 34 - common = buf[i + 4 : i + 4 + _CONN_COMMON_PREFIX_LEN] # 11 bytes - tail_start = i + 4 + _CONN_COMMON_PREFIX_LEN + _RPA_TOTAL_LEN - tail_end = pkt_end - _V2_EXTRA_LEN - tail = buf[tail_start:tail_end] # interval + latency + timeout + accuracy = 7 bytes - new_plen = 1 + _CONN_COMMON_PREFIX_LEN + len(tail) # 1 + 11 + 7 = 19 + plen = data[start + 2] + subevent = data[start + 3] + + if subevent == _SUBEVENT_ENH_CONN_COMPLETE_V2 and plen >= 34: + # v2: subevent(1) + common(11) + RPAs(12) + interval/lat/to/acc(7) + adv(1) + sync(2) = 34 + common = buf[start + 4 : start + 4 + _CONN_COMMON_PREFIX_LEN] # 11 bytes + tail_start = start + 4 + _CONN_COMMON_PREFIX_LEN + _RPA_TOTAL_LEN + tail_end = end - _V2_EXTRA_LEN + tail = buf[ + tail_start:tail_end + ] # interval + latency + timeout + accuracy = 7 bytes + new_plen = 1 + _CONN_COMMON_PREFIX_LEN + len(tail) # 1 + 11 + 7 = 19 + + out.append(0x04) # H4 event + out.append(0x3E) # LE Meta Event + out.append(new_plen) # parameter length + out.append(_SUBEVENT_CONN_COMPLETE) # subevent 0x01 + out.extend(common) # status + handle + role + addrtype + addr + out.extend(tail) # interval + latency + timeout + accuracy - out.append(0x04) # H4 event - out.append(0x3E) # LE Meta Event - out.append(new_plen) # parameter length - out.append(_SUBEVENT_CONN_COMPLETE) # subevent 0x01 - out.extend(common) # status + handle + role + addrtype + addr - out.extend(tail) # interval + latency + timeout + accuracy + print( + f"[hci-proxy] Translated Enhanced Connection Complete v2 " + f"(0x{subevent:02X}) -> legacy (0x01) " + f"handle={common[1] | (common[2] << 8)} " + f"status=0x{common[0]:02X}", + flush=True, + ) + continue - print( - f"[hci-proxy] Translated Enhanced Connection Complete v2 " - f"(0x{subevent:02X}) -> legacy (0x01) " - f"handle={common[1] | (common[2] << 8)} " - f"status=0x{common[0]:02X}", - flush=True, - ) - i = pkt_end - continue + if subevent == _SUBEVENT_ENH_CONN_COMPLETE_V1 and plen >= 31: + # v1: subevent(1) + common(11) + RPAs(12) + interval/lat/to/acc(7) = 31 + common = buf[start + 4 : start + 4 + _CONN_COMMON_PREFIX_LEN] + tail_start = start + 4 + _CONN_COMMON_PREFIX_LEN + _RPA_TOTAL_LEN + tail = buf[tail_start:end] # interval + latency + timeout + accuracy = 7 + new_plen = 1 + _CONN_COMMON_PREFIX_LEN + len(tail) - if subevent == _SUBEVENT_ENH_CONN_COMPLETE_V1 and plen >= 31: - # v1: subevent(1) + common(11) + RPAs(12) + interval/lat/to/acc(7) = 31 - common = buf[i + 4 : i + 4 + _CONN_COMMON_PREFIX_LEN] - tail_start = i + 4 + _CONN_COMMON_PREFIX_LEN + _RPA_TOTAL_LEN - tail = buf[tail_start:pkt_end] # interval + latency + timeout + accuracy = 7 - new_plen = 1 + _CONN_COMMON_PREFIX_LEN + len(tail) + out.append(0x04) + out.append(0x3E) + out.append(new_plen) + out.append(_SUBEVENT_CONN_COMPLETE) + out.extend(common) + out.extend(tail) - out.append(0x04) - out.append(0x3E) - out.append(new_plen) - out.append(_SUBEVENT_CONN_COMPLETE) - out.extend(common) - out.extend(tail) - - print( - f"[hci-proxy] Translated Enhanced Connection Complete v1 " - f"(0x{subevent:02X}) -> legacy (0x01) " - f"handle={common[1] | (common[2] << 8)} " - f"status=0x{common[0]:02X}", - flush=True, - ) - i = pkt_end - continue + print( + f"[hci-proxy] Translated Enhanced Connection Complete v1 " + f"(0x{subevent:02X}) -> legacy (0x01) " + f"handle={common[1] | (common[2] << 8)} " + f"status=0x{common[0]:02X}", + flush=True, + ) + continue - # Default: copy byte as-is. - out.append(buf[i]) - i += 1 + # Other LE Meta Event — pass through. + out.extend(buf[start:end]) return bytes(out) @@ -686,6 +1141,8 @@ def main() -> None: ) try: + global _adv_report_count, _adv_report_dropped, _fw_fd + _fw_fd = bridge_fd while True: readable, _, _ = select.select([bridge_fd, master_fd], [], []) @@ -701,17 +1158,36 @@ def main() -> None: if fd == bridge_fd: # Firmware → kernel: filter features, translate events, - # and fix short error responses. + # fix short error responses, and gate adv reports. _log_hci_event(data, "FW→HOST") + if b"\x04\x3e" in data: + _adv_report_count += 1 + + if _adv_report_count % 500 == 0: + print( + f"[hci-proxy] adv report stats: " + f"total={_adv_report_count} " + f"dropped={_adv_report_dropped} " + f"host_scanning={_host_scanning}", + flush=True, + ) + data = _filter_fw_scan_disable_cc(data) + data = _filter_adv_reports(data) data = _strip_ext_adv(data) data = _strip_ext_adv_cmds(data) data = _translate_conn_complete(data) data = _fix_short_cc(data) - os.write(master_fd, data) + # Cache AFTER all modifications so replayed CCs + # have ext-adv bits cleared, short CCs padded, etc. + _cache_cc_response(data) + if data: + os.write(master_fd, data) else: # Kernel → firmware: intercept unsupported commands. _log_hci_event(data, "HOST→FW") + data = _intercept_scan_commands(data, master_fd) data = _intercept_unsupported_commands(data, master_fd) + data = _intercept_cached_commands(data, master_fd) if data: # Forward remaining bytes (if any). os.write(bridge_fd, data) finally: diff --git a/scripts/remote-radio/validate.sh b/scripts/remote-radio/validate.sh index 970b6178..745f27c0 100755 --- a/scripts/remote-radio/validate.sh +++ b/scripts/remote-radio/validate.sh @@ -672,6 +672,7 @@ check_hci() { ############################################################################### # Section 6: BLE Scanning Capability ############################################################################### + check_ble_scan() { section "BLE Scanning" @@ -932,6 +933,16 @@ check_radio check_ble_chain check_hci check_ble_scan + +# After BLE scanning, the HCI proxy forwards scan-disable to firmware. +# Allow a few seconds for the firmware to process the stop so the radio +# is available for Thread (802.15.4) operations. +if ! $JSON_MODE; then + echo "" + echo " ⏳ Waiting 3s for BLE scan cleanup (radio shared with Thread)..." +fi +sleep 3 + check_otbr check_known_pitfalls print_summary From aae12a88561396d88a33b49e5fd7ede57eaeb5f0 Mon Sep 17 00:00:00 2001 From: mvelam850 Date: Thu, 4 Jun 2026 08:41:35 -0500 Subject: [PATCH 14/17] fix(otbr-radio): increase cpcd retry timeout for high-latency serial tunnels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the remote-radio SSH tunnel has high RTT (e.g., 300ms across continents), cpcd's default 100ms retry timeout causes commands to be retransmitted before the radio can respond. For the reboot command, this results in the radio receiving multiple reset requests, triggering an infinite "Secondary has reset, reset the daemon" loop. Changes: - Patch cpcd source at build time to increase retry timeout from 100ms to 1000ms (server_core.c) — no impact on low-latency setups since the timer is cancelled as soon as the response arrives - Add TCP_NODELAY to socat's TCP connection to eliminate Nagle buffering - Add TCP_NODELAY to remote-serial.py relay client sockets Root cause: cpcd retransmits reboot commands every 100ms. With 300ms RTT, 3 duplicate reboots reach the radio before the first response returns. The radio resets repeatedly, sending unsolicited reset notifications that cpcd interprets as unexpected secondary resets. --- docker/Dockerfile.otbr-radio | 6 ++++++ docker/otbr-radio-entrypoint.sh | 6 ++++-- scripts/remote-radio/remote-serial.py | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docker/Dockerfile.otbr-radio b/docker/Dockerfile.otbr-radio index e75c37f9..a5333517 100644 --- a/docker/Dockerfile.otbr-radio +++ b/docker/Dockerfile.otbr-radio @@ -98,6 +98,12 @@ RUN cd /tmp && \ git clone --depth 1 --branch ${CPCD_GIT_TAG} \ https://github.com/SiliconLabs/cpc-daemon.git && \ cd cpc-daemon && \ + # Increase CPC system command retry timeout from 100ms to 1000ms. \ + # The default 100ms causes retransmissions over high-latency links \ + # (e.g. TCP serial tunnel via SSH) before the radio can respond. \ + # For the reboot command, duplicates cause an infinite reset loop. \ + sed -i 's/100000\([, ]*\)\/\* 100ms between retries\*\//1000000\1\/\* 1s between retries *\//g' \ + server_core/server_core.c && \ mkdir build && cd build && \ cmake .. \ -DCMAKE_BUILD_TYPE=Release \ diff --git a/docker/otbr-radio-entrypoint.sh b/docker/otbr-radio-entrypoint.sh index 1dc5bc3e..fb22ef9a 100755 --- a/docker/otbr-radio-entrypoint.sh +++ b/docker/otbr-radio-entrypoint.sh @@ -178,8 +178,10 @@ if [ -n "${RADIO_PORT}" ]; then # PTY slave state. If cpcd briefly closes/reopens the device during # CPC init retries, wait-slave causes socat to disconnect TCP and # destroy the relay connection. + # nodelay: disable Nagle's algorithm — critical for low-latency CPC + # frame delivery over SSH tunnels. socat \ - "TCP4:${RADIO_HOST_V4}:${RADIO_PORT},forever,intervall=3,keepalive,keepidle=10,keepintvl=5,keepcnt=3" \ + "TCP4:${RADIO_HOST_V4}:${RADIO_PORT},nodelay,forever,intervall=3,keepalive,keepidle=10,keepintvl=5,keepcnt=3" \ "PTY,link=${VIRTUAL_TTY},raw,echo=0,b115200" & SOCAT_PID=$! @@ -334,7 +336,7 @@ restart_socat() { done socat \ - "TCP4:${RADIO_HOST_V4}:${RADIO_PORT},forever,intervall=3,keepalive,keepidle=10,keepintvl=5,keepcnt=3" \ + "TCP4:${RADIO_HOST_V4}:${RADIO_PORT},nodelay,forever,intervall=3,keepalive,keepidle=10,keepintvl=5,keepcnt=3" \ "PTY,link=${VIRTUAL_TTY},raw,echo=0,b115200" & SOCAT_PID=$! diff --git a/scripts/remote-radio/remote-serial.py b/scripts/remote-radio/remote-serial.py index 06be24d6..84d15fe8 100755 --- a/scripts/remote-radio/remote-serial.py +++ b/scripts/remote-radio/remote-serial.py @@ -356,6 +356,7 @@ def _run(self) -> None: break info(f"TCP client connected from {addr}") + client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) client.settimeout(0.5) self._set_client(client) From d3ee1aea6af311830451671ab81c75a1db237468 Mon Sep 17 00:00:00 2001 From: Thomas Lea Date: Thu, 4 Jun 2026 11:51:45 -0500 Subject: [PATCH 15/17] Revert "fix(otbr-radio): increase cpcd retry timeout for high-latency serial tunnels" This reverts commit aae12a88561396d88a33b49e5fd7ede57eaeb5f0. --- docker/Dockerfile.otbr-radio | 6 ------ docker/otbr-radio-entrypoint.sh | 6 ++---- scripts/remote-radio/remote-serial.py | 1 - 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/docker/Dockerfile.otbr-radio b/docker/Dockerfile.otbr-radio index a5333517..e75c37f9 100644 --- a/docker/Dockerfile.otbr-radio +++ b/docker/Dockerfile.otbr-radio @@ -98,12 +98,6 @@ RUN cd /tmp && \ git clone --depth 1 --branch ${CPCD_GIT_TAG} \ https://github.com/SiliconLabs/cpc-daemon.git && \ cd cpc-daemon && \ - # Increase CPC system command retry timeout from 100ms to 1000ms. \ - # The default 100ms causes retransmissions over high-latency links \ - # (e.g. TCP serial tunnel via SSH) before the radio can respond. \ - # For the reboot command, duplicates cause an infinite reset loop. \ - sed -i 's/100000\([, ]*\)\/\* 100ms between retries\*\//1000000\1\/\* 1s between retries *\//g' \ - server_core/server_core.c && \ mkdir build && cd build && \ cmake .. \ -DCMAKE_BUILD_TYPE=Release \ diff --git a/docker/otbr-radio-entrypoint.sh b/docker/otbr-radio-entrypoint.sh index fb22ef9a..1dc5bc3e 100755 --- a/docker/otbr-radio-entrypoint.sh +++ b/docker/otbr-radio-entrypoint.sh @@ -178,10 +178,8 @@ if [ -n "${RADIO_PORT}" ]; then # PTY slave state. If cpcd briefly closes/reopens the device during # CPC init retries, wait-slave causes socat to disconnect TCP and # destroy the relay connection. - # nodelay: disable Nagle's algorithm — critical for low-latency CPC - # frame delivery over SSH tunnels. socat \ - "TCP4:${RADIO_HOST_V4}:${RADIO_PORT},nodelay,forever,intervall=3,keepalive,keepidle=10,keepintvl=5,keepcnt=3" \ + "TCP4:${RADIO_HOST_V4}:${RADIO_PORT},forever,intervall=3,keepalive,keepidle=10,keepintvl=5,keepcnt=3" \ "PTY,link=${VIRTUAL_TTY},raw,echo=0,b115200" & SOCAT_PID=$! @@ -336,7 +334,7 @@ restart_socat() { done socat \ - "TCP4:${RADIO_HOST_V4}:${RADIO_PORT},nodelay,forever,intervall=3,keepalive,keepidle=10,keepintvl=5,keepcnt=3" \ + "TCP4:${RADIO_HOST_V4}:${RADIO_PORT},forever,intervall=3,keepalive,keepidle=10,keepintvl=5,keepcnt=3" \ "PTY,link=${VIRTUAL_TTY},raw,echo=0,b115200" & SOCAT_PID=$! diff --git a/scripts/remote-radio/remote-serial.py b/scripts/remote-radio/remote-serial.py index 84d15fe8..06be24d6 100755 --- a/scripts/remote-radio/remote-serial.py +++ b/scripts/remote-radio/remote-serial.py @@ -356,7 +356,6 @@ def _run(self) -> None: break info(f"TCP client connected from {addr}") - client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) client.settimeout(0.5) self._set_client(client) From 70837143209700f86a90043306146fbdd5b9a6ac Mon Sep 17 00:00:00 2001 From: mvelam850 Date: Fri, 5 Jun 2026 07:29:42 -0500 Subject: [PATCH 16/17] fix(otbr-radio): increase cpcd retry timeout for high-latency serial tunnels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CPC daemon retries system commands every 100ms. When the serial link RTT exceeds 100ms (e.g. remote radio over SSH tunnel), responses arrive after the retry fires, causing duplicate commands. For the reboot command this triggers an infinite reset loop. Patch server_core.c at build time to use a 1s retry interval. This is safe for low-latency setups — the timer is cancelled immediately when the response arrives. Only the error-path delay (radio genuinely unresponsive) is affected. --- docker/Dockerfile.otbr-radio | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docker/Dockerfile.otbr-radio b/docker/Dockerfile.otbr-radio index e75c37f9..37be7c79 100644 --- a/docker/Dockerfile.otbr-radio +++ b/docker/Dockerfile.otbr-radio @@ -98,6 +98,13 @@ RUN cd /tmp && \ git clone --depth 1 --branch ${CPCD_GIT_TAG} \ https://github.com/SiliconLabs/cpc-daemon.git && \ cd cpc-daemon && \ + # Increase CPC system command retry timeout from 100ms to 1s. \ + # The default 100ms causes retransmission storms over high-latency \ + # links (RTT > 100ms), leading to infinite radio reset loops. \ + # 1s is safe for low-latency links — the timer is cancelled as soon \ + # as the response arrives. \ + sed -i 's/100000\([, ]*\)\/\* 100ms between retries\*\//1000000\1\/\* 1s between retries *\//g' \ + server_core/server_core.c && \ mkdir build && cd build && \ cmake .. \ -DCMAKE_BUILD_TYPE=Release \ From 48e8f2f306ed0c612ecb504cda1ec45ecb632ffc Mon Sep 17 00:00:00 2001 From: Thomas Lea Date: Tue, 9 Jun 2026 13:15:19 +0000 Subject: [PATCH 17/17] some updates for timing and retries --- .../subsystems/thread/OpenThreadClient.cpp | 20 ++++-- docker/Dockerfile.otbr-radio | 10 +-- ...LE-only-adapters-in-BluezObjectManag.patch | 61 +++++++++++++++++++ 3 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 third_party/matter/barton-library/patches/0003-fix-ble-support-LE-only-adapters-in-BluezObjectManag.patch diff --git a/core/src/subsystems/thread/OpenThreadClient.cpp b/core/src/subsystems/thread/OpenThreadClient.cpp index 1b1f73c7..1075670c 100644 --- a/core/src/subsystems/thread/OpenThreadClient.cpp +++ b/core/src/subsystems/thread/OpenThreadClient.cpp @@ -69,9 +69,9 @@ namespace { // Note: I've seen approx 7 seconds on a standard Ubuntu desktop environment for a new network, about 18 seconds for // creating and replacing an existing network. CPC-based radio hardware (e.g., EFR32 via serial/SPI) can take 35+ - // seconds due to shared radio scheduling and slower transport. This should just "go away" if we implement a proper - // main loop. - constexpr int ATTACH_WAIT_SECONDS = 60; + // seconds due to shared radio scheduling and slower transport. After the Attach callback fires, the OTBR may still + // need 30-40 seconds to form the network (detached → leader) before the dataset TLVs become available. + constexpr int ATTACH_WAIT_SECONDS = 120; } // namespace namespace barton @@ -147,6 +147,8 @@ namespace barton // The Attach callback may fire before OTBR has finished applying the new dataset. // Retry GetActiveDatasetTlvs while time remains, processing D-Bus messages in between // so we receive the "Active dataset tlvs changed" signal. + int datasetRetryCount = 0; + while (true) { threadApiCallError = threadApiBus->GetActiveDatasetTlvs(retVal); @@ -166,10 +168,16 @@ namespace barton break; } - icDebug("Active dataset not yet available (error=%d), retrying...", (int) threadApiCallError); + if (datasetRetryCount == 0 || datasetRetryCount % 10 == 0) + { + icDebug("Active dataset not yet available (error=%d), retrying (%" PRId64 "ms remaining)...", + (int) threadApiCallError, (int64_t) timer.count()); + } + + datasetRetryCount++; - // Dispatch D-Bus messages for up to 1 second before retrying - dbus_connection_read_write_dispatch(dbusConnection.get(), 1000); + // Dispatch D-Bus messages for up to 2 seconds before retrying + dbus_connection_read_write_dispatch(dbusConnection.get(), 2000); next = steady_clock::now(); timer = timer - duration_cast(next - current); current = next; diff --git a/docker/Dockerfile.otbr-radio b/docker/Dockerfile.otbr-radio index 37be7c79..c3cd8497 100644 --- a/docker/Dockerfile.otbr-radio +++ b/docker/Dockerfile.otbr-radio @@ -98,12 +98,14 @@ RUN cd /tmp && \ git clone --depth 1 --branch ${CPCD_GIT_TAG} \ https://github.com/SiliconLabs/cpc-daemon.git && \ cd cpc-daemon && \ - # Increase CPC system command retry timeout from 100ms to 1s. \ + # Increase CPC reboot-command retry timeout from 100ms to 1s. \ # The default 100ms causes retransmission storms over high-latency \ # links (RTT > 100ms), leading to infinite radio reset loops. \ - # 1s is safe for low-latency links — the timer is cancelled as soon \ - # as the response arrives. \ - sed -i 's/100000\([, ]*\)\/\* 100ms between retries\*\//1000000\1\/\* 1s between retries *\//g' \ + # Only the two sl_cpc_system_cmd_reboot() call sites are patched — \ + # property-get commands keep the 100ms default so init completes \ + # quickly and doesn't widen the window for UART I/O errors that \ + # crash cpcd. \ + sed -i '/sl_cpc_system_cmd_reboot/,+2 s/100000\([, ]*\)\/\* 100ms between retries\*\//1000000\1\/\* 1s between retries *\//' \ server_core/server_core.c && \ mkdir build && cd build && \ cmake .. \ diff --git a/third_party/matter/barton-library/patches/0003-fix-ble-support-LE-only-adapters-in-BluezObjectManag.patch b/third_party/matter/barton-library/patches/0003-fix-ble-support-LE-only-adapters-in-BluezObjectManag.patch new file mode 100644 index 00000000..cfc4771d --- /dev/null +++ b/third_party/matter/barton-library/patches/0003-fix-ble-support-LE-only-adapters-in-BluezObjectManag.patch @@ -0,0 +1,61 @@ +From 84f4de0e1ae324a8958fa508a829c5e392102f17 Mon Sep 17 00:00:00 2001 +From: Thomas Lea +Date: Mon, 8 Jun 2026 13:37:55 +0000 +Subject: [PATCH] fix(ble): support LE-only adapters in + BluezObjectManager::OnObjectAdded +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +LE-only BLE adapters (e.g. SiLabs CPC HCI bridges) have a BlueZ +Class property of 0 because there is no BR/EDR device class to read +via HCI. The OnObjectAdded callback required Class != 0 to consider +an adapter ready, which meant LE-only adapters were permanently +ignored when they appeared on D-Bus after DriveBLEState init. + +Fix by also accepting an adapter as ready when its Powered property +is true. For dual-mode adapters the Class != 0 check still applies +first; the Powered fallback only triggers for LE-only adapters where +Class remains 0. + +OnInterfacePropertiesChanged is left unchanged — it still only +watches for Class becoming non-zero. The Powered check in +OnObjectAdded is sufficient because LE-only adapters are already +powered when they first appear on D-Bus. + +This resolves BLE_ERROR_ADAPTER_UNAVAILABLE (0x00000401) when +commissioning Matter devices over BLE with CPC-bridged radios. +--- + src/platform/Linux/bluez/BluezObjectManager.cpp | 16 ++++++---------- + 1 file changed, 6 insertions(+), 10 deletions(-) + +diff --git a/src/platform/Linux/bluez/BluezObjectManager.cpp b/src/platform/Linux/bluez/BluezObjectManager.cpp +index 6071ce91..c2add9cc 100644 +--- a/src/platform/Linux/bluez/BluezObjectManager.cpp ++++ b/src/platform/Linux/bluez/BluezObjectManager.cpp +@@ -196,16 +196,12 @@ BluezObjectManager::NotificationsDelegates BluezObjectManager::GetDeviceNotifica + void BluezObjectManager::OnObjectAdded(GDBusObjectManager * aMgr, BluezObject * aObj) + { + GAutoPtr adapter(bluez_object_get_adapter1(aObj)); +- // Verify that the adapter is properly initialized - the class property must be set. +- // BlueZ can export adapter objects on the bus before it is fully initialized. Such +- // adapter objects are not usable and must be ignored. +- // +- // TODO: Find a better way to determine whether the adapter interface exposed by +- // BlueZ D-Bus service is fully functional. The current approach is based on +- // the assumption that the class property is non-zero, which is true only +- // for BR/EDR + LE adapters. LE-only adapters do not have HCI command to read +- // the class property and BlueZ sets it to 0 as a default value. +- if (adapter && bluez_adapter1_get_class(adapter.get()) != 0) ++ // Verify that the adapter is properly initialized. For dual-mode (BR/EDR + LE) adapters ++ // the Class property is non-zero once BlueZ has read the device class via HCI. LE-only ++ // adapters (e.g. CPC HCI bridges) never receive a device class from the controller, so ++ // BlueZ leaves Class at 0. For those adapters, accept the adapter when Powered is true, ++ // which indicates BlueZ has completed its HCI setup sequence. ++ if (adapter && (bluez_adapter1_get_class(adapter.get()) != 0 || bluez_adapter1_get_powered(adapter.get()))) + { + NotifyAdapterAdded(adapter.get()); + return; +-- +2.43.0 +