diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0ebe65c0..2d73862a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,7 +7,8 @@ "initializeCommand": "docker/setupDockerEnv.sh", "dockerComposeFile": [ "../docker/compose.yaml", - "../docker/compose.devcontainer.yaml" + "../docker/compose.devcontainer.yaml", + "../docker/compose.otbr-radio.yaml" ], "service": "barton", @@ -51,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 @@ -110,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/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/core/src/subsystems/thread/OpenThreadClient.cpp b/core/src/subsystems/thread/OpenThreadClient.cpp index bfda0e3b..1075670c 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. 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 @@ -142,10 +144,43 @@ 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. + int datasetRetryCount = 0; + + 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; + } + + 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 2 seconds before retrying + dbus_connection_read_write_dispatch(dbusConnection.get(), 2000); + next = steady_clock::now(); + timer = timer - duration_cast(next - current); + current = next; } } else if (timer.count() <= 0) diff --git a/docker/Dockerfile b/docker/Dockerfile index bca7707a..ee4a9de7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -355,7 +355,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 new file mode 100644 index 00000000..c3cd8497 --- /dev/null +++ b/docker/Dockerfile.otbr-radio @@ -0,0 +1,214 @@ +# ------------------------------ 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 USB radio. +# +# This container runs two daemons: +# 1. cpcd - 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: This overlay is included by default in the devcontainer +# compose stack. +# +# 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. +# + +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 \ + bluez \ + && 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 — Co-Processor Communication Daemon +# +# cpcd manages the USB serial link to the physical radio 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.4.6" + +RUN cd /tmp && \ + git clone --depth 1 --branch ${CPCD_GIT_TAG} \ + https://github.com/SiliconLabs/cpc-daemon.git && \ + cd cpc-daemon && \ + # 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. \ + # 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 .. \ + -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_REV="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_REV} && \ + git checkout ${OTBR_GIT_REV} && \ + ${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 + +############################################################################### +# 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 + +# 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 + +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 f625678a..6dfe97ee 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 | 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 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 new file mode 100644 index 00000000..b70bc8f5 --- /dev/null +++ b/docker/compose.otbr-radio.yaml @@ -0,0 +1,183 @@ +# ------------------------------ 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 real USB radio. +# +# The container runs: +# - cpcd : CPC daemon (USB serial <-> CPC socket) +# - otbr-agent : OpenThread Border Router agent (CPC socket <-> D-Bus API) +# +# A named volume (dbus-socket) shares a private 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. +# This is not the host's /var/run/dbus; the otbr-radio container starts and +# owns its own private dbus-daemon for this overlay. +# +# 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 +# This overlay is included by default in the devcontainer +# compose stack. +# +# ─── Environment variables ──────────────────────────────────────────────────── +# +# RADIO_DEVICE Host path of the USB serial device. +# On a shared build server the device may appear at a +# non-default path (e.g. /dev/ttyACM8 if multiple users are +# simultaneously forwarding radios via USB-IP). Always set +# this explicitly in docker/.env (generated by +# docker/setupDockerEnv.sh) or export it before running: +# export RADIO_DEVICE=/dev/ttyACM8 +# +# 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/REMOTE_RADIO_FOR_DEVELOPMENT.md for remote serial tunnel setup. +# +# ───────────────────────────────────────────────────────────────────────────── + +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 + # 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 + # (host networking would put all wpan0 interfaces into the same namespace) + # - NAT64/iptables rules are scoped to this container's network namespace + # - D-Bus communication with the barton container is still via the + # dbus-socket volume, so no direct network path is required + networks: + - barton-ip6net + volumes: + # Share a private D-Bus socket directory with the barton/devcontainer + # service so Barton code can talk to otbr-agent over D-Bus without + # ever touching the host's system bus. + - dbus-socket:/var/run/otbr-dbus + # Mount the host /dev tree so the container can access whatever + # 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. + # The privileged: true above grants the necessary device access. + - /dev:/dev + # Bind-mount the host's network namespace so that btattach and + # 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 + # 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 + 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 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 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 + # 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 + # to Barton when explicitly requested, without requiring any code changes. + # By default Barton continues to use the standard system bus socket so + # simulated/default OTBR flows keep working unless the private socket is + # explicitly selected by setting DBUS_SYSTEM_BUS_ADDRESS in the compose + # environment before startup. + # In devcontainer mode, this file is layered into the compose stack via + # devcontainer.json, so this barton service fragment is merged in there. + 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/otbr-dbus/system_bus_socket} + +networks: + # Reuse the same per-user IPv6 bridge network defined in compose.yaml so + # that otbr-radio is properly segmented from the host network and each + # user on a shared server gets their own isolated namespace. + barton-ip6net: + name: $BUILDER_USER-$BARTON_WORKSPACE_ID-barton-ip6net + # Network is created by setupDockerEnv.sh and declared in compose.yaml + external: true + +volumes: + # Per-user/workspace named volume that shares /var/run/otbr-dbus between + # the otbr-radio container (where the private dbus-daemon writes its socket) + # and the devcontainer/barton container (where Barton connects to it). + # The explicit name uses the same $BUILDER_USER-$BARTON_WORKSPACE_ID prefix + # as the network above, making per-user D-Bus isolation self-documenting. + dbus-socket: + name: $BUILDER_USER-$BARTON_WORKSPACE_ID-otbr-dbus-socket 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 new file mode 100755 index 00000000..1dc5bc3e --- /dev/null +++ b/docker/otbr-radio-entrypoint.sh @@ -0,0 +1,1143 @@ +#!/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 (CPC daemon — USB serial ↔ CPC socket) +# 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 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 +# CPCD_CONF - path to cpcd config file +# DBUS_DIR - private shared D-Bus socket directory +# 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 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 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}" +DBUS_SOCKET_PATH="${DBUS_SOCKET_PATH:-${DBUS_DIR}/system_bus_socket}" +DBUS_SYSTEM_BUS_ADDRESS="${DBUS_SYSTEM_BUS_ADDRESS:-unix:path=${DBUS_SOCKET_PATH}}" +export DBUS_SYSTEM_BUS_ADDRESS + +# 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 + +# 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 +# +# The shared 'dbus-socket' named volume is mounted at ${DBUS_DIR}. This is a +# private socket directory used only by the otbr-radio and barton containers; +# it is not the host's /var/run/dbus. +# +# Keep only the socket in the shared volume. Do not create pid files there. +# If the previous container instance exited uncleanly, remove any stale socket +# before starting a new private dbus-daemon bound to the explicit address. +############################################################################### +echo "[otbr-radio] Starting private D-Bus system bus at ${DBUS_SYSTEM_BUS_ADDRESS}..." +mkdir -p "${DBUS_DIR}" +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 --config-file=/etc/otbr-dbus.conf --fork --nopidfile +echo "[otbr-radio] Private D-Bus started." + +############################################################################### +# 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 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. +############################################################################### + +if [ -n "${RADIO_PORT}" ]; then + #-------------------------------------------------------------------------- + # 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 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 + + # 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 directly. + #-------------------------------------------------------------------------- + if [ ! -e "${RADIO_DEVICE}" ]; then + echo "[otbr-radio] ERROR: USB radio device '${RADIO_DEVICE}' not found." >&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=21234" >&2 + exit 1 +fi + +############################################################################### +# 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. +# +# 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. +############################################################################### + +echo "[otbr-radio] Writing cpcd config to ${CPCD_CONF}..." +mkdir -p "$(dirname "${CPCD_CONF}")" +cat > "${CPCD_CONF}" < "${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)." + +############################################################################### +# 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) +# +# 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}" +# 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=$! + +# Wait for the pts_hci symlink that bt_host_cpc_hci_bridge creates once ready. +# 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] 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] 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)." + +############################################################################### +# 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 -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 + 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 + +fi # end: bridge created pts_hci successfully + +############################################################################### +# 6. Attach the virtual HCI device and start bluetoothd +# +# 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 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 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. +# +# 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" + +# 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. + local hciBefore + hciBefore=$(ls /sys/class/bluetooth/ 2>/dev/null | sort) + + 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=$! + + # 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)) + + 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 + + # 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 & + sleep 3 + echo "[otbr-radio] BLE stack ready (${radioHci})." + + return 0 + fi + done + + 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 + + return 1 +} + +# 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 + + 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 + [ -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 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_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 + + 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 + fi + + wait ${BT_BRIDGE_PID} 2>/dev/null || true + bridgeAttempt=$((bridgeAttempt + 1)) + + if [ ${bridgeAttempt} -lt ${BT_BRIDGE_RETRY_MAX} ]; then + local retryDelay=$((bridgeAttempt * 3)) + 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 "[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 "[monitor] Bridge started (PID ${BT_BRIDGE_PID}, pts: ${PTS_DEVICE})." + + # Start HCI PTY proxy if available. + HCI_PROXY_PID="" + + 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 "[monitor] HCI proxy started (PID ${HCI_PROXY_PID}, pts: ${PTS_DEVICE})." + else + echo "[monitor] WARNING: HCI proxy exited during startup." >&2 + HCI_PROXY_PID="" + fi + fi + + # Attach HCI device and start bluetoothd. + if ! ble_attach; then + echo "[monitor] BLE attach failed." >&2 + return 1 + fi + + echo "[monitor] BLE chain started successfully." + return 0 +} + +# 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. +# +# 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 + + # 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 +} + +# 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 +# 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 + local discoveryActiveCount=0 + # Grace period — skip health checks for 30s after startup or restart + # to let the adapter stabilise. + local graceUntil=$((SECONDS + 30)) + + while true; do + 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 + 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 + + # --------------------------------------------------------------- + # 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 ! 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] Full stack recovered (socat → cpcd → BLE)." + ;; + + 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 +} + +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= + HCI_PROXY_PID=${HCI_PROXY_PID:-} + + if [ -L "${BT_BRIDGE_DIR}/pts_hci" ]; then + + 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: Bridge did not create pts_hci during startup; monitor will retry." >&2 + fi +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 +# +# 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})..." + +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 \ + -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/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 fed9d99b..22e6bce1 100755 --- a/docker/setupDockerEnv.sh +++ b/docker/setupDockerEnv.sh @@ -78,8 +78,16 @@ fi HIGHEST_BUILDER_TAG=$(cat "$VERSION_FILE") IMAGE_TAG=$HIGHEST_BUILDER_TAG BUILDER_TAG_CHANGED=false +existingRadioDevice="" +existingBackboneIf="" +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) + 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=//') @@ -182,6 +190,55 @@ 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 USB radio serial device. +# - 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. +# - If already present in docker/.env, preserve that value unless overridden +# by exporting RADIO_DEVICE before running setupDockerEnv.sh or dockerw. +# +# 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. +# - If already present in docker/.env, preserve that value unless overridden +# by exporting BACKBONE_IF before running setupDockerEnv.sh or dockerw, +# e.g.: export BACKBONE_IF=enp6s0 +# +# 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. +# +# 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 +# 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="" +if command -v ip >/dev/null 2>&1; then + detectedBackboneIf=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}') +fi + +radioDeviceValue="${RADIO_DEVICE:-$existingRadioDevice}" +backboneIfValue="${BACKBONE_IF:-${existingBackboneIf:-$detectedBackboneIf}}" +radioPortValue="${RADIO_PORT:-$existingRadioPort}" +radioHostValue="${RADIO_HOST:-${existingRadioHost:-host.docker.internal}}" + +echo "RADIO_DEVICE=$radioDeviceValue" >> $OUTFILE +echo "BACKBONE_IF=$backboneIfValue" >> $OUTFILE +echo "RADIO_PORT=$radioPortValue" >> $OUTFILE +echo "RADIO_HOST=$radioHostValue" >> $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/docker/version b/docker/version index 37989bd1..9f55b2cc 100644 --- a/docker/version +++ b/docker/version @@ -1 +1 @@ -2.10 +3.0 diff --git a/dockerw b/dockerw index 7efb7f55..b2a64cd7 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 + 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,24 @@ 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. +# 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 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 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} \ @@ -83,4 +99,4 @@ docker compose -f "${COMPOSE_FILE}" run \ ${VOLUMES_ARGS} \ ${EXTRA_ENV} \ barton \ - $* + "$@" diff --git a/docs/REMOTE_RADIO_FOR_DEVELOPMENT.md b/docs/REMOTE_RADIO_FOR_DEVELOPMENT.md new file mode 100644 index 00000000..68f1ec60 --- /dev/null +++ b/docs/REMOTE_RADIO_FOR_DEVELOPMENT.md @@ -0,0 +1,400 @@ +# 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 connection drops, 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). + +--- + +## Radio Connection Methods + +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=21234` | + +### Local USB Radio + +If the radio is plugged directly into the machine running Docker: + +```bash +RADIO_DEVICE=/dev/ttyACM0 ./dockerw -T bash +``` + +### Remote Serial Tunnel + +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. + +``` + 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 21234 │ │ cpcd │ │ + │ │ │ │ (same as local USB) │ │ + └──────────────────┘ │ └─────────────────────────┘ │ + └───────────────────────────────┘ +``` + +The tunnel is self-healing — if the SSH connection drops, `remote-serial.py` +automatically reconnects. The socat bridge inside the container also reconnects +automatically. + +#### Prerequisites + +1. **Python 3.7+** and **pyserial** on the workstation: + + ```bash + pip install pyserial + ``` + +2. **SSH key authentication** from your workstation to the dev server (no + password prompts). + +3. **`GatewayPorts clientspecified`** in the dev server's sshd config: + + ``` + # /etc/ssh/sshd_config on the dev server + GatewayPorts clientspecified + ``` + + Then restart sshd: `sudo systemctl restart sshd` + +#### Quick Start + +**Step 1 — On your workstation** (where the radio is plugged in): + +```bash +python3 scripts/remote-radio/remote-serial.py @ +``` + +The script auto-detects the radio, starts a TCP relay, and opens an SSH reverse +tunnel. Leave this terminal open. + +Example output: + +``` +[remote-serial] OK: Auto-detected radio: /dev/ttyACM0 (Silicon Labs CP210x) +[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:21234 + Tunnel port: 21234 on remote (all interfaces) +======================================= + + RADIO_PORT=21234 +``` + +**Step 2 — On the dev server** (or in your devcontainer): + +```bash +RADIO_PORT=21234 ./dockerw -T bash +``` + +Or add `RADIO_PORT=21234` to `docker/.env`. + +**Step 3 — Validate** (inside the Barton or otbr-radio container): + +```bash +scripts/remote-radio/validate.sh +``` + +#### Port Isolation on Shared Servers + +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 | +|-----|------| +| 1234 | 21234 | +| 1235 | 21235 | +| 1236 | 21236 | + +#### Command-Line Options + +``` +usage: remote-serial.py [-h] [--port PORT] [--baud BAUD] ssh_target + +positional arguments: + ssh_target SSH target (e.g. user@hostname) + +options: + --port PORT Serial port path (auto-detected if omitted) + --baud BAUD Baud rate (default: 115200) +``` + +--- + +## Starting the Real-Radio Container + +### CLI (`dockerw`) + +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=21234 ./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` (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` +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 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 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=21234 +``` + +Then rebuild the devcontainer (**Dev Containers: Rebuild Container**). + +--- + +## Verifying the Full Stack + +Run the validation script from inside the Barton container: + +```bash +scripts/remote-radio/validate.sh +``` + +This checks: + +- 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 + +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 +``` + +### Stopping the remote serial tunnel + +Press `Ctrl-C` in the terminal running `remote-serial.py` on your workstation. + +--- + +## 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 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/docs/THREAD_BORDER_ROUTER_SUPPORT.md b/docs/THREAD_BORDER_ROUTER_SUPPORT.md deleted file mode 100644 index 1a205501..00000000 --- a/docs/THREAD_BORDER_ROUTER_SUPPORT.md +++ /dev/null @@ -1,3 +0,0 @@ -# Adding Thread Border Router Support to Barton - -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. 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/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 new file mode 100644 index 00000000..e1f526aa --- /dev/null +++ b/scripts/remote-radio/hci_pty_proxy.py @@ -0,0 +1,1200 @@ +#!/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 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 +---------- +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 + + +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. +# +# 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 + +# --- 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] +) + +# --- 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 +# 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 +# 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.""" + # Only match at proper H4 event boundaries — never inside ACL data. + for start, end in _iter_h4_packets(data): + if data[start] != 0x04: + continue + + pkt = data[start:end] + idx = pkt.find(_MATCH_PREFIX) + + if idx != 0: + continue + + feat_start = _FEATURES_OFFSET + + if feat_start + 8 > len(pkt): + continue # Incomplete — leave unchanged. + + original = pkt[feat_start + _LE_FEAT_BYTE] + + if not (original & _LE_EXT_ADV_BIT): + continue # Already clear. + + 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: + """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 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[start + 1] + plen = data[start + 2] + + if evt_code != 0x0E: + continue + + opcode = data[start + 5] << 8 | data[start + 4] + + if opcode != 0x1002: + continue + + status = data[start + 6] + + if _DEBUG: + snippet = data[start : min(start + 15, end)].hex(" ") + print( + f"[hci-proxy] _strip_ext_adv_cmds: found 0x1002 at i={start} " + 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 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. + + 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 + + +# 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 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. + """ + # 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() + + 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 + + pkt = data[start:end] + + 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 + + out.extend(pkt) + + return bytes(out) + + +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() + + 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 + + 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 + + # Not a short CC we need to fix — pass through. + out.extend(buf[start:end]) + + 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. + + 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() + + 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 + + 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 + + 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 + + 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) + + 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, + ) + continue + + # Other LE Meta Event — pass through. + out.extend(buf[start:end]) + + 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: + global _adv_report_count, _adv_report_dropped, _fw_fd + _fw_fd = bridge_fd + 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, translate events, + # 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) + # 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: + os.close(bridge_fd) + os.close(master_fd) + os.close(slave_fd) + + +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..06be24d6 --- /dev/null +++ b/scripts/remote-radio/remote-serial.py @@ -0,0 +1,730 @@ +#!/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: + 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 + + +# --------------------------------------------------------------------------- +# 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/validate.sh b/scripts/remote-radio/validate.sh new file mode 100755 index 00000000..745f27c0 --- /dev/null +++ b/scripts/remote-radio/validate.sh @@ -0,0 +1,953 @@ +#!/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 ---------------------------------- + +# +# 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: +# ./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 +# 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). + # 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 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 [ "$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)" \ + --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}" +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}" +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" + +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 + + # HCI PTY proxy script + 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 socat cpcd; 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: Radio Connection +############################################################################### +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 "Tunnel TCP connectivity" "Cannot connect to ${radio_host_v4}:${RADIO_PORT} — is remote-serial.py running on your workstation?" + fi + + # 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 "socat bridge" "socat not running — virtual serial device will not exist" + fi + + # 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 "Virtual serial device" "/dev/ttyRadio not found — socat may not have started" + fi + + 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 "Radio device" "${RADIO_DEVICE} not found — is the radio connected?" + fi + + 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 "cpcd process" "PID $cpcd_pid (ZOMBIE)" + fi + else + fail "cpcd process" "cpcd not running" + 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 +} + +############################################################################### +# Section 4: BLE Chain +############################################################################### +check_ble_chain() { + section "BLE Chain (bridge → pty_proxy → btattach → bluetoothd)" + + # bt_host_cpc_hci_bridge + 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 + 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 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 + 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". + 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}" + + 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 '{ + 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 + + 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 + + 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 — 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="" + + if [ -z "$bd_addr" ]; then + fail "BLE LE scan" "Cannot determine BD address for ${target_hci}" + return + fi + + 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)" + + 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}" + + 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 + 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 CPC sockets + if [ -S "${CPC_SOCKET_BASE}/ctrl.cpcd.sock" ]; then + 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 + + # 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" + else + pass "ep14 EAGAIN check" "Bridge alive (PID $bridge_pid)" + fi + + # 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 + 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 + + # 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 + + # 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 + + # 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 + + # 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 + 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 +############################################################################### + +# 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 "║ Radio Stack Validation ║" + echo "╠══════════════════════════════════════════════════════════════╣" + printf "║ Instance: %-49s║\n" "${CPC_INSTANCE}" + printf "║ Radio: %-49s║\n" "${RADIO_MODE_DISPLAY}" + printf "║ Date: %-49s║\n" "$(date -Iseconds)" + echo "╚══════════════════════════════════════════════════════════════╝" +fi + +check_container_env +check_dbus +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 + +if [ "$FAIL_COUNT" -gt 0 ]; then + exit 1 +fi +exit 0 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 +