diff --git a/tools/harness-simulation/README.md b/tools/harness-simulation/README.md index ae9780690..61e1a95c3 100644 --- a/tools/harness-simulation/README.md +++ b/tools/harness-simulation/README.md @@ -4,21 +4,39 @@ THCI (Thread Host Controller Interface) is an implementation of the Python abstr SI (Sniffer Interface) is an implementation of the sniffer abstract class template `ISniffer`, which is used by the Thread Test Harness Software to sniff all packets sent by devices. -Both OpenThread simulation and sniffer simulation are required to run on a POSIX environment. However, Harness has to be run on Windows, which is a non-POSIX environment. So both two systems are needed, and their setup procedures in detail are listed in the following sections. Either two machines or one machine running two (sub)systems (for example, VM, WSL) is feasible. +Both OpenThread simulation and sniffer simulation are required to run on a POSIX environment. However, Harness has to be run on Windows, which is a non-POSIX environment. So two systems are needed, and their setup procedures in detail are listed in the following sections. Either two machines or one machine running two (sub)systems (for example, VM, WSL) is feasible. Platform developers should modify the THCI implementation and/or the SI implementation directly to match their platform (for example, the path of the OpenThread repository). ## POSIX Environment Setup -1. Build OpenThread to generate standalone OpenThread simulation `ot-cli-ftd`. For example, run the following command in the top directory of OpenThread. +1. Build OpenThread to generate standalone OpenThread simulation `ot-cli-ftd`. For example, to run OpenThread 1.2 test cases, run the following command in the top directory of OpenThread: ```bash - $ script/cmake-build simulation + $ CFLAGS='-DOPENTHREAD_CONFIG_IP6_MAX_EXT_MCAST_ADDRS=8' \ + CXXFLAGS='-DOPENTHREAD_CONFIG_IP6_MAX_EXT_MCAST_ADDRS=8' \ + script/cmake-build simulation \ + -DOT_THREAD_VERSION=1.2 \ + -DOT_DUA=ON \ + -DOT_MLR=ON \ + -DOT_COMMISSIONER=ON \ + -DOT_CSL_RECEIVER=ON \ + -DOT_SIMULATION_MAX_NETWORK_SIZE=64 ``` Then `ot-cli-ftd` is built in the directory `build/simulation/examples/apps/cli/`. -2. Run the installation script. +2. Open the configuration file `config.py`: + + - Edit the value of `OT_PATH` to the absolute path where the top directory of the OpenThread repository is located. For example, change the value of `OT_PATH` to `/home//repo/openthread`. + +3. Run the `build_docker_image.sh` with the environment variable `OT_PATH` set properly. `OT_PATH` should be the same as that in the previous step. For example run the following command: + + ```bash + $ OT_PATH=~/repo/openthread ./build_docker_image.sh + ``` + +4. Run the installation script. ```bash $ tools/harness-simulation/posix/install.sh @@ -30,6 +48,8 @@ Platform developers should modify the THCI implementation and/or the SI implemen 2. Check the configuration file `C:\GRL\Thread1.2\Thread_Harness\simulation\config.py` + - Edit the value of `REMOTE_USERNAME` to the username expected to connect to on the remote POSIX environment. + - Edit the value of `REMOTE_PASSWORD` to the password corresponding to the username above. - Edit the value of `REMOTE_OT_PATH` to the absolute path where the top directory of the OpenThread repository is located. 3. Add the additional simulation device information in `harness\Web\data\deviceInputFields.xml` to `C:\GRL\Thread1.2\Web\data\deviceInputFields.xml`. @@ -40,16 +60,17 @@ Platform developers should modify the THCI implementation and/or the SI implemen ```bash $ cd tools/harness-simulation/posix - $ python launch_testbed.py \ - --interface=eth0 \ - --ot1.1=24 \ + $ python3 launch_testbed.py \ + --interface=eth0 \ + --ot=6 \ + --otbr=4 \ --sniffer=2 ``` - It starts 24 OT FTD simulations and 2 sniffer simulations and can be discovered on eth0. + This example starts 6 OT FTD simulations, 4 OTBR simulations, and 2 sniffer simulations and can be discovered on `eth0`. The arguments can be adjusted according to the requirement of test cases. -2. Run Test Harness. The information field of the device is encoded as `@`. Choose the proper device as the DUT accordingly. +2. Run Test Harness. The information field of the device is encoded as `@` for FTDs and `otbr_@` for BRs. Choose the proper device as the DUT accordingly. 3. Select one or more test cases to start the test. diff --git a/tools/harness-simulation/harness/Thread_Harness/Sniffer/SimSniffer.py b/tools/harness-simulation/harness/Thread_Harness/Sniffer/SimSniffer.py index 05fe5195d..194b6ad03 100644 --- a/tools/harness-simulation/harness/Thread_Harness/Sniffer/SimSniffer.py +++ b/tools/harness-simulation/harness/Thread_Harness/Sniffer/SimSniffer.py @@ -34,20 +34,17 @@ import netifaces import select import socket import struct -import subprocess import time import win32api import winreg as wr -from ISniffer import ISniffer +from Sniffer.ISniffer import ISniffer from THCI.OpenThread import watched -from simulation.config import EDITCAP_PATH from simulation.Sniffer.proto import sniffer_pb2 from simulation.Sniffer.proto import sniffer_pb2_grpc DISCOVERY_ADDR = ('ff02::114', 12345) - -IFNAME = 'WLAN' +IFNAME = ISniffer.ethernet_interface_name SCAN_TIME = 3 @@ -67,12 +64,13 @@ class SimSniffer(ISniffer): self.addr_port = kwargs.get('addressofDevice') self.is_active = False self._local_pcapng_location = None + if self.addr_port is not None: self._sniffer = grpc.insecure_channel(self.addr_port) self._stub = sniffer_pb2_grpc.SnifferStub(self._sniffer) - # Close the sniffer only when Harness exits - win32api.SetConsoleCtrlHandler(self.__disconnect, True) + # Close the sniffer only when Harness exits + win32api.SetConsoleCtrlHandler(self.__disconnect, True) def __repr__(self): return '%r' % self.__dict__ @@ -146,9 +144,9 @@ class SimSniffer(ISniffer): self.channel = channelToCapture self._local_pcapng_location = captureFileLocation - response = self._stub.Start(sniffer_pb2.StartRequest(channel=self.channel)) + response = self._stub.Start(sniffer_pb2.StartRequest(channel=self.channel, includeEthernet=includeEthernet)) if response.status != sniffer_pb2.OK: - raise RuntimeError(f'startSniffer error: {sniffer_pb2.Status.Name(response.status)}') + raise RuntimeError('startSniffer error: %s' % sniffer_pb2.Status.Name(response.status)) self.is_active = True @@ -159,19 +157,11 @@ class SimSniffer(ISniffer): response = self._stub.Stop(sniffer_pb2.StopRequest()) if response.status != sniffer_pb2.OK: - raise RuntimeError(f'stopSniffer error: {sniffer_pb2.Status.Name(response.status)}') + raise RuntimeError('stopSniffer error: %s' % sniffer_pb2.Status.Name(response.status)) - # Truncate suffix from .pcapng to .pcap - local_pcap_location = self._local_pcapng_location[:-2] - - with open(local_pcap_location, 'wb') as f: + with open(self._local_pcapng_location, 'wb') as f: f.write(response.pcap_content) - cmd = [EDITCAP_PATH, '-F', 'pcapng', local_pcap_location, self._local_pcapng_location] - self.log('running editcap: %r', cmd) - subprocess.Popen(cmd).wait() - self.log('editcap done') - self.is_active = False def __disconnect(self, dwCtrlType): diff --git a/tools/harness-simulation/harness/Thread_Harness/THCI/OpenThread_BR_Sim.py b/tools/harness-simulation/harness/Thread_Harness/THCI/OpenThread_BR_Sim.py new file mode 100644 index 000000000..e2b04565e --- /dev/null +++ b/tools/harness-simulation/harness/Thread_Harness/THCI/OpenThread_BR_Sim.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python +# +# Copyright (c) 2022, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +""" +>> Thread Host Controller Interface +>> Device : OpenThread_BR_Sim THCI +>> Class : OpenThread_BR_Sim +""" + +import ipaddress +import logging +import paramiko +import pipes +import sys +import time + +from THCI.OpenThread import watched +from THCI.OpenThread_BR import OpenThread_BR +from simulation.config import (REMOTE_USERNAME, REMOTE_PASSWORD, REMOTE_PORT) + +logging.getLogger('paramiko').setLevel(logging.WARNING) + + +class SSHHandle(object): + # Unit: second + KEEPALIVE_INTERVAL = 30 + + def __init__(self, ip, port, username, password, docker_name): + self.ip = ip + self.port = int(port) + self.username = username + self.password = password + self.docker_name = docker_name + self.__handle = None + + self.__connect() + + def __connect(self): + self.close() + + self.__handle = paramiko.SSHClient() + self.__handle.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + self.__handle.connect(self.ip, port=self.port, username=self.username, password=self.password) + except paramiko.AuthenticationException: + if not self.password: + self.__handle.get_transport().auth_none(self.username) + else: + raise Exception('Password error') + + # Avoid SSH disconnection after idle for a long time + self.__handle.get_transport().set_keepalive(self.KEEPALIVE_INTERVAL) + + def close(self): + if self.__handle is not None: + self.__handle.close() + self.__handle = None + + def bash(self, cmd, timeout): + # It is necessary to quote the command when there is stdin/stdout redirection + cmd = pipes.quote(cmd) + + retry = 3 + for i in range(retry): + try: + stdin, stdout, stderr = self.__handle.exec_command('docker exec %s bash -c %s' % + (self.docker_name, cmd), + timeout=timeout) + stdout._set_mode('rb') + + sys.stderr.write(stderr.read()) + output = [r.rstrip() for r in stdout.readlines()] + return output + + except paramiko.SSHException: + if i < retry - 1: + print('SSH connection is lost, try reconnect after 1 second.') + time.sleep(1) + self.__connect() + else: + raise ConnectionError('SSH connection is lost') + + +class OpenThread_BR_Sim(OpenThread_BR): + + def _getHandle(self): + assert self.connectType == 'ip' + assert '@' in self.telnetIp + self.log('SSH connecting ...') + docker_name, ssh_ip = self.telnetIp.split('@') + return SSHHandle(ssh_ip, self.telnetPort, self.telnetUsername, self.telnetPassword, docker_name) + + @watched + def _parseConnectionParams(self, params): + discovery_add = params.get('SerialPort') + if '@' not in discovery_add: + raise ValueError('%r in the field `add` is invalid' % discovery_add) + + docker_name, ssh_ip = discovery_add.split('@') + # Let it crash if it is an invalid IP address + ipaddress.ip_address(ssh_ip) + + self.connectType = 'ip' + self.telnetIp = self.port = discovery_add + self.telnetPort = REMOTE_PORT + self.telnetUsername = REMOTE_USERNAME + self.telnetPassword = REMOTE_PASSWORD + + self.extraParams = { + 'cmd-start-otbr-agent': 'service otbr-agent start', + 'cmd-stop-otbr-agent': 'service otbr-agent stop', + 'cmd-restart-otbr-agent': 'service otbr-agent restart', + 'cmd-restart-radvd': 'service radvd stop; service radvd start', + } diff --git a/tools/harness-simulation/harness/Thread_Harness/THCI/OpenThread_Sim.py b/tools/harness-simulation/harness/Thread_Harness/THCI/OpenThread_Sim.py index 292d542b3..00f876487 100644 --- a/tools/harness-simulation/harness/Thread_Harness/THCI/OpenThread_Sim.py +++ b/tools/harness-simulation/harness/Thread_Harness/THCI/OpenThread_Sim.py @@ -33,36 +33,41 @@ """ import ipaddress -import os import paramiko import socket import time import win32api -from IThci import IThci -from OpenThread import OpenThreadTHCI, watched -from simulation.config import REMOTE_OT_PATH +from THCI.IThci import IThci +from THCI.OpenThread import OpenThreadTHCI, watched +from simulation.config import ( + REMOTE_USERNAME, + REMOTE_PASSWORD, + REMOTE_PORT, + REMOTE_OT_PATH, +) class SSHHandle(object): KEEPALIVE_INTERVAL = 30 def __init__(self, ip, port, username, password, device, node_id): - ipaddress.ip_address(ip) self.ip = ip self.port = int(port) self.username = username self.password = password + self.node_id = node_id self.__handle = None self.__stdin = None self.__stdout = None - self.__connect(device, node_id) + + self.__connect(device) # Close the SSH connection only when Harness exits win32api.SetConsoleCtrlHandler(self.__disconnect, True) @watched - def __connect(self, device, node_id): + def __connect(self, device): if self.__handle is not None: return @@ -80,7 +85,7 @@ class SSHHandle(object): # Avoid SSH connection lost after inactivity for a while self.__handle.get_transport().set_keepalive(self.KEEPALIVE_INTERVAL) - self.__stdin, self.__stdout, _ = self.__handle.exec_command(device + ' ' + str(node_id)) + self.__stdin, self.__stdout, _ = self.__handle.exec_command(device + ' ' + str(self.node_id)) # Receive the output in non-blocking mode self.__stdout.channel.setblocking(0) @@ -120,7 +125,7 @@ class SSHHandle(object): def log(self, fmt, *args): try: msg = fmt % args - print('%s - %s - %s' % (self.port, time.strftime('%b %d %H:%M:%S'), msg)) + print('%d@%s - %s - %s' % (self.node_id, self.ip, time.strftime('%b %d %H:%M:%S'), msg)) except Exception: pass @@ -128,7 +133,8 @@ class SSHHandle(object): class OpenThread_Sim(OpenThreadTHCI, IThci): __handle = None - device = os.path.join(REMOTE_OT_PATH, 'build/simulation/examples/apps/cli/ot-cli-ftd') + # Do not use `os.path.join` as it uses backslash as the separator on Windows + device = REMOTE_OT_PATH + '/build/simulation/examples/apps/cli/ot-cli-ftd' @watched def _connect(self): @@ -149,6 +155,22 @@ class OpenThread_Sim(OpenThreadTHCI, IThci): def _disconnect(self): pass + @watched + def _parseConnectionParams(self, params): + discovery_add = params.get('SerialPort') + if '@' not in discovery_add: + raise ValueError('%r in the field `add` is invalid' % discovery_add) + + node_id, ssh_ip = discovery_add.split('@') + # Let it crash if it is an invalid IP address + ipaddress.ip_address(ssh_ip) + + self.connectType = 'ip' + self.telnetIp = self.port = discovery_add + self.telnetPort = REMOTE_PORT + self.telnetUsername = REMOTE_USERNAME + self.telnetPassword = REMOTE_PASSWORD + def _cliReadLine(self): if len(self.__lines) > 1: return self.__lines.pop(0) diff --git a/tools/harness-simulation/harness/Thread_Harness/simulation/config.py b/tools/harness-simulation/harness/Thread_Harness/simulation/config.py index 930f2cf15..de24adacc 100644 --- a/tools/harness-simulation/harness/Thread_Harness/simulation/config.py +++ b/tools/harness-simulation/harness/Thread_Harness/simulation/config.py @@ -27,6 +27,8 @@ # POSSIBILITY OF SUCH DAMAGE. # -REMOTE_OT_PATH = '/home/pi/openthread/' +REMOTE_USERNAME = 'pi' +REMOTE_PASSWORD = 'raspberry' +REMOTE_PORT = 22 -EDITCAP_PATH = r'C:\Program Files (x86)\Wireshark_Thread\editcap.exe' +REMOTE_OT_PATH = '/home/pi/repo/openthread' diff --git a/tools/harness-simulation/harness/Web/data/deviceInputFields.xml b/tools/harness-simulation/harness/Web/data/deviceInputFields.xml index a9e09d424..3191ac645 100644 --- a/tools/harness-simulation/harness/Web/data/deviceInputFields.xml +++ b/tools/harness-simulation/harness/Web/data/deviceInputFields.xml @@ -1,10 +1,8 @@ - COM1 - + UNSPECIFIED + + + UNSPECIFIED diff --git a/tools/harness-simulation/harness/install.bat b/tools/harness-simulation/harness/install.bat index 7d6a6d175..bcf81e508 100644 --- a/tools/harness-simulation/harness/install.bat +++ b/tools/harness-simulation/harness/install.bat @@ -27,6 +27,9 @@ xcopy /E /Y Thread_Harness %systemdrive%\GRL\Thread1.2\Thread_Harness copy /Y ..\..\harness-thci\OpenThread.py %systemdrive%\GRL\Thread1.2\Thread_Harness\THCI +copy /Y ..\..\harness-thci\OpenThread_BR.py %systemdrive%\GRL\Thread1.2\Thread_Harness\THCI +copy /Y ..\..\harness-thci\OpenThread.png %systemdrive%\GRL\Thread1.2\Web\images +copy /Y ..\..\harness-thci\OpenThread_BR.png %systemdrive%\GRL\Thread1.2\Web\images xcopy /E /Y ..\posix\sniffer_sim\proto %systemdrive%\GRL\Thread1.2\Thread_Harness\simulation\Sniffer\proto %systemdrive%\GRL\Thread1.2\Python27\python.exe -m pip install --upgrade pip diff --git a/tools/harness-simulation/posix/build_docker_image.sh b/tools/harness-simulation/posix/build_docker_image.sh new file mode 100755 index 000000000..7664b7c89 --- /dev/null +++ b/tools/harness-simulation/posix/build_docker_image.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# +# Copyright (c) 2022, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +# NOTE: This script has not been fully tested up to now + +set -euxo pipefail + +if [[ $OT_PATH == "" ]]; then + OT_PATH="/home/pi/repo/openthread" +fi + +if [[ $OTBR_DOCKER_IMAGE == "" ]]; then + OTBR_DOCKER_IMAGE="otbr-reference-device-1.2" +fi + +DOCKER_BUILD_OTBR_OPTIONS=( + "-DOTBR_DUA_ROUTING=ON" + "-DOT_DUA=ON" + "-DOT_MLR=ON" + "-DOT_THREAD_VERSION=1.2" + "-DOT_SIMULATION_MAX_NETWORK_SIZE=64" +) + +git clone https://github.com/openthread/ot-br-posix.git --recurse-submodules --shallow-submodules --depth=1 +ETC_PATH="${OT_PATH}/tools/harness-simulation/posix/etc" + +( + cd ot-br-posix + # Use system V `service` command instead + mkdir -p root/etc/init.d + cp "${ETC_PATH}/commissionerd" root/etc/init.d/commissionerd + sudo chown root:root root/etc/init.d/commissionerd + sudo chmod +x root/etc/init.d/commissionerd + + cp "${ETC_PATH}/server.patch" script/server.patch + patch script/server script/server.patch + mkdir -p root/tmp + cp "${ETC_PATH}/requirements.txt" root/tmp/requirements.txt + + docker build . \ + -t "${OTBR_DOCKER_IMAGE}" \ + -f "${ETC_PATH}/Dockerfile" \ + --build-arg REFERENCE_DEVICE=1 \ + --build-arg BORDER_ROUTING=0 \ + --build-arg BACKBONE_ROUTER=1 \ + --build-arg NAT64=0 \ + --build-arg WEB_GUI=0 \ + --build-arg REST_API=0 \ + --build-arg EXTERNAL_COMMISSIONER=1 \ + --build-arg OTBR_OPTIONS="'${DOCKER_BUILD_OTBR_OPTIONS[*]}'" +) + +rm -rf ot-br-posix diff --git a/tools/harness-simulation/posix/config.py b/tools/harness-simulation/posix/config.py new file mode 100644 index 000000000..8e12d8d84 --- /dev/null +++ b/tools/harness-simulation/posix/config.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2022, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +MAX_NODES_NUM = 64 +MAX_SNIFFER_NUM = 2 +SNIFFER_SERVER_PORT_BASE = 50051 + +OT_PATH = '/home/pi/work/src/openthread-pr' +OTBR_DOCKER_IMAGE = 'otbr-reference-device-1.2' +OTBR_DOCKER_NAME_PREFIX = 'otbr_' diff --git a/tools/harness-simulation/posix/etc/Dockerfile b/tools/harness-simulation/posix/etc/Dockerfile new file mode 100644 index 000000000..736349389 --- /dev/null +++ b/tools/harness-simulation/posix/etc/Dockerfile @@ -0,0 +1,119 @@ +# Copyright (c) 2022, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +ARG BASE_IMAGE=ubuntu:focal +FROM ${BASE_IMAGE} + +ARG INFRA_IF_NAME +ARG BORDER_ROUTING +ARG BACKBONE_ROUTER +ARG OTBR_OPTIONS +ARG EXTERNAL_COMMISSIONER +ARG DNS64 +ARG NAT64 +ARG NAT64_SERVICE +ARG REFERENCE_DEVICE +ARG REST_API +ARG WEB_GUI +ARG MDNS + +ENV INFRA_IF_NAME=${INFRA_IF_NAME:-eth0} +ENV BORDER_ROUTING=${BORDER_ROUTING:-1} +ENV BACKBONE_ROUTER=${BACKBONE_ROUTER:-1} +ENV OTBR_MDNS=${MDNS:-mDNSResponder} +ENV OTBR_OPTIONS=${OTBR_OPTIONS} +ENV EXTERNAL_COMMISSIONER=${EXTERNAL_COMMISSIONER:-1} +ENV DEBIAN_FRONTEND noninteractive +ENV PLATFORM ubuntu +ENV REFERENCE_DEVICE=${REFERENCE_DEVICE:-0} +ENV NAT64=${NAT64:-1} +ENV NAT64_SERVICE=${NAT64_SERVICE:-tayga} +ENV DNS64=${DNS64:-0} +ENV WEB_GUI=${WEB_GUI:-1} +ENV REST_API=${REST_API:-1} +ENV DOCKER 1 + +RUN env + +COPY . /app +WORKDIR /app + +# Required during build or run +ENV OTBR_DOCKER_REQS sudo python2 python3 python-is-python2 + +# Required during build, could be removed +ENV OTBR_DOCKER_DEPS git ca-certificates python3-pip wget + +# Required during run python scripts +ENV OTBR_PYTHON_REQS zeroconf + +# Required and installed during build (script/bootstrap), could be removed +ENV OTBR_BUILD_DEPS apt-utils build-essential psmisc ninja-build cmake ca-certificates \ + libreadline-dev libncurses-dev libcpputest-dev libdbus-1-dev libavahi-common-dev \ + libavahi-client-dev libboost-dev libboost-filesystem-dev libboost-system-dev \ + libnetfilter-queue-dev + +RUN apt-get update \ + && cp -r ./root/. / \ + && rm -rf ./root \ + && apt-get install --no-install-recommends -y $OTBR_DOCKER_REQS $OTBR_DOCKER_DEPS \ + && wget -P /tmp "https://bootstrap.pypa.io/pip/2.7/get-pip.py" \ + && python2 /tmp/get-pip.py \ + && pip2 install -r /tmp/requirements.txt \ + && pip3 install $OTBR_PYTHON_REQS \ + && ln -fs /usr/share/zoneinfo/UTC /etc/localtime \ + && ([ "${EXTERNAL_COMMISSIONER}" != "1" ] || ( \ + git clone https://github.com/openthread/ot-commissioner.git --recurse-submodules --shallow-submodules --depth=1 \ + && cd ot-commissioner \ + && ./script/bootstrap.sh \ + && mkdir -p build \ + && cd build \ + && cmake -GNinja -DCMAKE_INSTALL_PREFIX="/usr/local" -DOT_COMM_REFERENCE_DEVICE=ON .. \ + && ninja \ + && ninja install \ + && cd /app \ + && rm -rf ot-commissioner \ + )) \ + && ./script/bootstrap \ + && ./script/setup \ + && ([ "${DNS64}" = "0" ] || chmod 644 /etc/bind/named.conf.options) \ + && mv ./script /tmp \ + && mv ./etc /tmp \ + && find . -delete \ + && rm -rf /usr/include \ + && mv /tmp/script . \ + && mv /tmp/etc . \ + && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false $OTBR_DOCKER_DEPS \ + && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false $OTBR_BUILD_DEPS \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /tmp/* \ + && sed -i "s/\/root/\/home\/pi/g" /etc/passwd + # The command above changes root home directory to /home/pi + +ENTRYPOINT ["/app/etc/docker/docker_entrypoint.sh"] + +EXPOSE 80 diff --git a/tools/harness-simulation/posix/etc/commissionerd b/tools/harness-simulation/posix/etc/commissionerd new file mode 100755 index 000000000..6a264e163 --- /dev/null +++ b/tools/harness-simulation/posix/etc/commissionerd @@ -0,0 +1,100 @@ +#!/bin/sh +# +# Copyright (c) 2022, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +### BEGIN INIT INFO +# Provides: commissionerd +# Required-Start: +# Required-Stop: +# Should-Start: +# Should-Stop: +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: OT-commissioner daemon +# Description: OT-commissioner daemon +### END INIT INFO + +set -e + +PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin +DESC="OT-commissioner daemon" +NAME=commissionerd +DAEMON=/usr/bin/python2 +PIDFILE=/var/run/commissionerd.pid + +# shellcheck source=/dev/null +. /lib/lsb/init-functions +# shellcheck source=/dev/null +. /lib/init/vars.sh + +start_commissionerd() +{ + if [ -e $PIDFILE ]; then + if $0 status >/dev/null; then + log_success_msg "$DESC already started; not starting." + return + else + log_success_msg "Removing stale PID file $PIDFILE." + rm -f $PIDFILE + fi + fi + + log_daemon_msg "Starting $DESC" "$NAME" + start-stop-daemon --start --quiet \ + --pidfile $PIDFILE --make-pidfile \ + -b --exec $DAEMON -- \ + -u /usr/local/bin/commissionerd.py -c /usr/local/bin/commissioner-cli + log_end_msg $? +} + +stop_commissionerd() +{ + log_daemon_msg "Stopping $DESC" "$NAME" + start-stop-daemon --stop --retry 5 --quiet --oknodo \ + --pidfile $PIDFILE --remove-pidfile + log_end_msg $? +} + +case "$1" in + start) + start_commissionerd + ;; + restart | reload | force-reload) + stop_commissionerd + start_commissionerd + ;; + stop | force-stop) + stop_commissionerd + ;; + status) + status_of_proc -p $PIDFILE $DAEMON $NAME && exit 0 || exit $? + ;; + *) + log_action_msg "Usage: /etc/init.d/$NAME {start | stop | status | restart | reload | force-reload}" + exit 2 + ;; +esac diff --git a/tools/harness-simulation/posix/etc/requirements.txt b/tools/harness-simulation/posix/etc/requirements.txt new file mode 100644 index 000000000..5804683d2 --- /dev/null +++ b/tools/harness-simulation/posix/etc/requirements.txt @@ -0,0 +1,3 @@ +pexpect==4.7.0 +ptyprocess==0.6.0 +pyserial==3.4 diff --git a/tools/harness-simulation/posix/etc/server.patch b/tools/harness-simulation/posix/etc/server.patch new file mode 100644 index 000000000..0f29c9573 --- /dev/null +++ b/tools/harness-simulation/posix/etc/server.patch @@ -0,0 +1,18 @@ +--- script/server 2022-08-15 11:41:53.915673348 +0800 ++++ script/server2 2022-08-15 11:43:12.387100651 +0800 +@@ -50,6 +50,7 @@ + systemctl is-active avahi-daemon || sudo systemctl start avahi-daemon || die 'Failed to start avahi!' + without WEB_GUI || systemctl is-active otbr-web || sudo systemctl start otbr-web || die 'Failed to start otbr-web!' + systemctl is-active otbr-agent || sudo systemctl start otbr-agent || die 'Failed to start otbr-agent!' ++ systemctl is-active commissionerd || sudo systemctl start commissionerd || die 'Failed to start commissionerd!' + elif have service; then + sudo service rsyslog status || sudo service rsyslog start || die 'Failed to start rsyslog!' + sudo service dbus status || sudo service dbus start || die 'Failed to start dbus!' +@@ -58,6 +59,7 @@ + sudo service avahi-daemon status || sudo service avahi-daemon start || die 'Failed to start avahi!' + sudo service otbr-agent status || sudo service otbr-agent start || die 'Failed to start otbr-agent!' + without WEB_GUI || sudo service otbr-web status || sudo service otbr-web start || die 'Failed to start otbr-web!' ++ sudo service commissionerd status || sudo service commissionerd start || die 'Failed to start commissionerd!' + else + die 'Unable to find service manager. Try script/console to start in console mode!' + fi diff --git a/tools/harness-simulation/posix/launch_testbed.py b/tools/harness-simulation/posix/launch_testbed.py index ba73d796b..c26f41bb3 100644 --- a/tools/harness-simulation/posix/launch_testbed.py +++ b/tools/harness-simulation/posix/launch_testbed.py @@ -39,12 +39,18 @@ import socket import struct import subprocess import sys +from typing import Iterable + +from config import ( + MAX_NODES_NUM, + MAX_SNIFFER_NUM, + SNIFFER_SERVER_PORT_BASE, + OTBR_DOCKER_NAME_PREFIX, +) +from otbr_sim import otbr_docker GROUP = 'ff02::114' PORT = 12345 -MAX_OT11_NUM = 33 -MAX_SNIFFER_NUM = 4 -SNIFFER_SERVER_PORT_BASE = 50051 def if_nametoindex(ifname: str) -> int: @@ -88,20 +94,19 @@ def _advertise(s: socket.socket, dst, info): s.sendto(json.dumps(info).encode('utf-8'), dst) -def advertise_ftd(s: socket.socket, dst, ven: str, ver: str, add: str, por: int, number: int): - # Node ID of ot-cli-ftd is 1-indexed - for i in range(1, number + 1): +def advertise_devices(s: socket.socket, dst, ven: str, add: str, nodeids: Iterable[int], prefix: str = ''): + for nodeid in nodeids: info = { 'ven': ven, - 'mod': f'{ven}_{i}', - 'ver': ver, - 'add': f'{i}@{add}', - 'por': por, + 'mod': 'OpenThread', + 'ver': '4', + 'add': f'{prefix}{nodeid}@{add}', + 'por': 22, } _advertise(s, dst, info) -def advertise_sniffer(s: socket.socket, dst, add: str, number: int): +def advertise_sniffers(s: socket.socket, dst, add: str, number: int): for i in range(number): info = { 'add': add, @@ -135,17 +140,24 @@ def main(): required=True, help='the interface used for discovery') - # Determine the number of OpenThread 1.1 FTD simulations to be "detected" and then started - parser.add_argument('--ot1.1', - dest='ot11_num', + # Determine the number of OpenThread FTD simulations to be "detected" and then started + parser.add_argument('--ot', + dest='ot_num', type=int, required=False, default=0, - help=f'the number of OpenThread FTD simulations, no more than {MAX_OT11_NUM}') + help=f'the number of OpenThread FTD simulations') + + # Determine the number of OpenThread BR simulations to be initiated and then detected + parser.add_argument('--otbr', + dest='otbr_num', + type=int, + required=False, + default=0, + help=f'the number of OpenThread BR simulations') # Determine the number of sniffer simulations to be started and then detected - parser.add_argument('-s', - '--sniffer', + parser.add_argument('--sniffer', dest='sniffer_num', type=int, required=False, @@ -155,15 +167,24 @@ def main(): args = parser.parse_args() # Check validation of arguments - if not 0 <= args.ot11_num <= MAX_OT11_NUM: - raise ValueError(f'The number of FTDs should be between 0 and {MAX_OT11_NUM}') + if args.ot_num < 0: + raise ValueError(f'The number of FTDs should be non-negative') + + if args.otbr_num < 0: + raise ValueError(f'The number of OTBRs should be non-negative') if not 0 <= args.sniffer_num <= MAX_SNIFFER_NUM: - raise ValueError(f'The number of FTDs should be between 0 and {MAX_SNIFFER_NUM}') + raise ValueError(f'The number of sniffers should be between 0 and {MAX_SNIFFER_NUM}') - if args.ot11_num == args.sniffer_num == 0: + if args.ot_num == args.otbr_num == 0: raise ValueError('At least one device is required') + if args.sniffer_num == 0: + raise ValueError('At least one sniffer is required') + + if args.ot_num + args.otbr_num > MAX_NODES_NUM: + raise ValueError(f'The number of all devices should be no more than {MAX_NODES_NUM}') + # Get the local IP address on the specified interface addr = get_ipaddr(args.ifname) @@ -172,6 +193,11 @@ def main(): for i in range(args.sniffer_num): sniffer_procs.append(start_sniffer(addr, i + SNIFFER_SERVER_PORT_BASE)) + # Start the BRs + otbr_dockers = [] + for nodeid in range(args.ot_num + 1, args.ot_num + args.otbr_num + 1): + otbr_dockers.append(otbr_docker.OtbrDocker(nodeid, OTBR_DOCKER_NAME_PREFIX + str(nodeid))) + s = init_socket(args.ifname, GROUP, PORT) logging.info('Advertising on interface %s group %s ...', args.ifname, GROUP) @@ -183,6 +209,10 @@ def main(): for sniffer_proc in sniffer_procs: sniffer_proc.terminate() ret = max(ret, sniffer_proc.wait()) + + for otbr in otbr_dockers: + otbr.close() + sys.exit(ret) signal.signal(signal.SIGINT, exit_handler) @@ -194,11 +224,17 @@ def main(): if data == b'BBR': logging.info('Received OpenThread simulation query, advertising') - advertise_ftd(s, src, ven='OpenThread_Sim', ver='4', add=addr, por=22, number=args.ot11_num) + advertise_devices(s, src, ven='OpenThread_Sim', add=addr, nodeids=range(1, args.ot_num + 1)) + advertise_devices(s, + src, + ven='OpenThread_BR_Sim', + add=addr, + nodeids=range(args.ot_num + 1, args.ot_num + args.otbr_num + 1), + prefix=OTBR_DOCKER_NAME_PREFIX) elif data == b'Sniffer': logging.info('Received sniffer simulation query, advertising') - advertise_sniffer(s, src, add=addr, number=args.sniffer_num) + advertise_sniffers(s, src, add=addr, number=args.sniffer_num) else: logging.warning('Received %r, but ignored', data) diff --git a/tools/harness-simulation/posix/otbr_sim/otbr_docker.py b/tools/harness-simulation/posix/otbr_sim/otbr_docker.py new file mode 100644 index 000000000..b116163f7 --- /dev/null +++ b/tools/harness-simulation/posix/otbr_sim/otbr_docker.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2022, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +import logging +import os +import re +import subprocess +import time + +from config import (OT_PATH, OTBR_DOCKER_IMAGE) + + +class OtbrDocker: + device_pattern = re.compile('(?<=PTY is )/dev/.+$') + + def __init__(self, nodeid: int, docker_name: str): + self.nodeid = nodeid + self.docker_name = docker_name + + self.logger = logging.getLogger('otbr_docker.OtbrDocker') + self.logger.setLevel(logging.INFO) + + self._socat_proc = None + self._ot_rcp_proc = None + + self._rcp_device_pty = None + self._rcp_device = None + + self._launch() + + def __repr__(self) -> str: + return f'OTBR<{self.nodeid}>' + + def _launch(self): + self.logger.info('Launching %r ...', self) + self._launch_socat() + self._launch_ot_rcp() + self._launch_docker() + self.logger.info('Launched %r successfully', self) + + def close(self): + self.logger.info('Shutting down %r ...', self) + self._shutdown_docker() + self._shutdown_ot_rcp() + self._shutdown_socat() + self.logger.info('Shut down %r successfully', self) + + def _launch_socat(self): + self._socat_proc = subprocess.Popen(['socat', '-d', '-d', 'pty,raw,echo=0', 'pty,raw,echo=0'], + stderr=subprocess.PIPE, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL) + + line = self._socat_proc.stderr.readline().decode('ascii').strip() + self._rcp_device_pty = self.device_pattern.findall(line)[0] + line = self._socat_proc.stderr.readline().decode('ascii').strip() + self._rcp_device = self.device_pattern.findall(line)[0] + self.logger.info(f"socat running: device PTY: {self._rcp_device_pty}, device: {self._rcp_device}") + + def _shutdown_socat(self): + if self._socat_proc is None: + return + + self._socat_proc.stderr.close() + self._socat_proc.terminate() + self._socat_proc.wait() + self._socat_proc = None + + self._rcp_device_pty = None + self._rcp_device = None + + def _launch_ot_rcp(self): + ot_rcp_path = os.path.join(OT_PATH, 'build/simulation/examples/apps/ncp/ot-rcp') + self._ot_rcp_proc = subprocess.Popen( + f'{ot_rcp_path} {self.nodeid} > {self._rcp_device_pty} < {self._rcp_device_pty}', + shell=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + try: + self._ot_rcp_proc.wait(1) + except subprocess.TimeoutExpired: + # We expect ot-rcp not to quit in 1 second. + pass + else: + raise Exception(f"ot-rcp {self.nodeid} exited unexpectedly!") + + def _shutdown_ot_rcp(self): + if self._ot_rcp_proc is None: + return + + self._ot_rcp_proc.terminate() + self._ot_rcp_proc.wait() + self._ot_rcp_proc = None + + def _launch_docker(self): + local_cmd_path = f'/tmp/{self.docker_name}' + os.makedirs(local_cmd_path, exist_ok=True) + + cmd = [ + 'docker', + 'run', + '--rm', + '--name', + self.docker_name, + '-d', + '--sysctl', + 'net.ipv6.conf.all.disable_ipv6=0 net.ipv4.conf.all.forwarding=1 net.ipv6.conf.all.forwarding=1', + '--privileged', + '-v', + f'{self._rcp_device}:/dev/ttyUSB0', + '-v', + f'{OT_PATH.rstrip("/")}:/home/pi/repo/openthread', + OTBR_DOCKER_IMAGE, + ] + self.logger.info('Launching docker: %s', ' '.join(cmd)) + launch_proc = subprocess.Popen(cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + launch_docker_deadline = time.time() + 60 + launch_ok = False + time.sleep(5) + + while time.time() < launch_docker_deadline: + try: + subprocess.check_call(['docker', 'exec', self.docker_name, 'ot-ctl', 'state'], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + launch_ok = True + logging.info("OTBR Docker %s is ready!", self.docker_name) + break + except subprocess.CalledProcessError: + time.sleep(5) + continue + + if not launch_ok: + raise RuntimeError('Cannot start OTBR Docker %s!' % self.docker_name) + launch_proc.wait() + + def _shutdown_docker(self): + subprocess.run(['docker', 'stop', self.docker_name]) diff --git a/tools/harness-simulation/posix/sniffer_sim/pcap_codec.py b/tools/harness-simulation/posix/sniffer_sim/pcap_codec.py index 47f58ac10..3817c7001 100644 --- a/tools/harness-simulation/posix/sniffer_sim/pcap_codec.py +++ b/tools/harness-simulation/posix/sniffer_sim/pcap_codec.py @@ -42,11 +42,17 @@ PCAP_VERSION_MINOR = 4 class PcapCodec(object): """ Utility class for .pcap formatters. """ - def __init__(self, channel): + def __init__(self, channel, filename): self._dlt = DLT_IEEE802_15_4_WITHFCS - self._pcap_contents = [self._encode_header()] self._channel = channel + self._pcap_writer = open(filename, 'wb') + self._write(self._encode_header()) + + def _write(self, content): + self._pcap_writer.write(content) + self._pcap_writer.flush() + def _encode_header(self): """ Return a pcap file header. """ @@ -88,7 +94,7 @@ class PcapCodec(object): return timestamp = self._get_timestamp() - self._pcap_contents.append(self._encode_frame(frame, *timestamp)) + self._write(self._encode_frame(frame, *timestamp)) - def pop_all(self) -> bytes: - return b''.join(self._pcap_contents) + def close(self): + self._pcap_writer.close() diff --git a/tools/harness-simulation/posix/sniffer_sim/proto/sniffer.proto b/tools/harness-simulation/posix/sniffer_sim/proto/sniffer.proto index 620d4910e..436bd3bf0 100644 --- a/tools/harness-simulation/posix/sniffer_sim/proto/sniffer.proto +++ b/tools/harness-simulation/posix/sniffer_sim/proto/sniffer.proto @@ -32,6 +32,9 @@ enum Status { message StartRequest { // Specify the channel that the sniffer is going to sniff int32 channel = 1; + + // Specify whether to include Ethernet packets between OTBR and infra + bool includeEthernet = 2; } message StartResponse { diff --git a/tools/harness-simulation/posix/sniffer_sim/sniffer.py b/tools/harness-simulation/posix/sniffer_sim/sniffer.py index accc6284e..2e4bc8e89 100644 --- a/tools/harness-simulation/posix/sniffer_sim/sniffer.py +++ b/tools/harness-simulation/posix/sniffer_sim/sniffer.py @@ -32,71 +32,76 @@ from concurrent import futures import enum import grpc import logging +import os import signal -import pcap_codec +import socket +import subprocess +import tempfile import threading +import pcap_codec from proto import sniffer_pb2 from proto import sniffer_pb2_grpc import sniffer_transport +class CaptureState(enum.Flag): + NONE = 0 + THREAD = enum.auto() + ETHERNET = enum.auto() + + class SnifferServicer(sniffer_pb2_grpc.Sniffer): """ Class representing the Sniffing node, whose main task is listening. """ logger = logging.getLogger('sniffer.SnifferServicer') RECV_BUFFER_SIZE = 4096 - MAX_NODES_NUM = 33 - - class State(enum.Enum): - STOPPED = 0 - RUNNING = 1 + TIMEOUT = 0.1 + MAX_NODES_NUM = 64 def _reset(self): - self._state = SnifferServicer.State.STOPPED + self._state = CaptureState.NONE self._pcap = None self._allowed_nodeids = None self._transport = None self._thread = None self._thread_alive.clear() + self._pcapng_filename = None + self._tshark_proc = None def __init__(self): self._thread_alive = threading.Event() self._mutex = threading.Lock() # for self._allowed_nodeids self._reset() - def _sniffer_main_loop(self): - """ Sniffer main loop. """ - - self.logger.debug('Sniffer started.') - - while self._thread_alive.is_set(): - # Avoid being blocked endlessly when there is no data - if not self._transport.ready(0.1): - continue - data, nodeid = self._transport.recv(self.RECV_BUFFER_SIZE) - - with self._mutex: - allowed_nodeids = self._allowed_nodeids - - # Equivalent to RF enclosure - if allowed_nodeids is None or nodeid in allowed_nodeids: - self._pcap.append(data) - - self.logger.debug('Sniffer stopped.') - def Start(self, request, context): """ Start sniffing. """ self.logger.debug('call Start') # Validate and change the state - if self._state != SnifferServicer.State.STOPPED: + if self._state != CaptureState.NONE: return sniffer_pb2.StartResponse(status=sniffer_pb2.OPERATION_ERROR) - self._state = SnifferServicer.State.RUNNING + self._state = CaptureState.THREAD - self._pcap = pcap_codec.PcapCodec(request.channel) + # Create a temporary named pipe + tempdir = tempfile.mkdtemp() + fifo_name = os.path.join(tempdir, 'pcap.fifo') + os.mkfifo(fifo_name) + + cmd = ['tshark', '-i', fifo_name] + if request.includeEthernet: + self._state |= CaptureState.ETHERNET + cmd += ['-i', 'docker0'] + self._pcapng_filename = os.path.join(tempdir, 'sim.pcapng') + cmd += ['-w', self._pcapng_filename, '-q', 'not ip and not tcp and not arp and not ether proto 0x8899'] + + self.logger.debug('Running command: %s', ' '.join(cmd)) + self._tshark_proc = subprocess.Popen(cmd) + + # Construct pcap codec after initiating tshark to avoid blocking + self._pcap = pcap_codec.PcapCodec(request.channel, fifo_name) # Sniffer all nodes in default, i.e. there is no RF enclosure # In this case, self._allowed_nodeids is set to None @@ -115,13 +120,29 @@ class SnifferServicer(sniffer_pb2_grpc.Sniffer): return sniffer_pb2.StartResponse(status=sniffer_pb2.OK) + def _sniffer_main_loop(self): + """ Sniffer main loop. """ + + while self._thread_alive.is_set(): + try: + data, nodeid = self._transport.recv(self.RECV_BUFFER_SIZE, self.TIMEOUT) + except socket.timeout: + continue + + with self._mutex: + allowed_nodeids = self._allowed_nodeids + + # Equivalent to RF enclosure + if allowed_nodeids is None or nodeid in allowed_nodeids: + self._pcap.append(data) + def FilterNodes(self, request, context): """ Only sniffer the specified nodes. """ self.logger.debug('call FilterNodes') # Validate the state - if self._state != SnifferServicer.State.RUNNING: + if not (self._state & CaptureState.THREAD): return sniffer_pb2.FilterNodesResponse(status=sniffer_pb2.OPERATION_ERROR) allowed_nodeids = set(request.nodeids) @@ -141,15 +162,21 @@ class SnifferServicer(sniffer_pb2_grpc.Sniffer): self.logger.debug('call Stop') # Validate and change the state - if self._state != SnifferServicer.State.RUNNING: + if self._state == CaptureState.NONE: return sniffer_pb2.StopResponse(status=sniffer_pb2.OPERATION_ERROR, pcap_content=b'') - self._state = SnifferServicer.State.STOPPED + self._state = CaptureState.NONE self._thread_alive.clear() self._thread.join(timeout=1) self._transport.close() + self._pcap.close() + + self._tshark_proc.terminate() + self._tshark_proc.wait() + + with open(self._pcapng_filename, 'rb') as f: + pcap_content = f.read() - pcap_content = self._pcap.pop_all() self._reset() return sniffer_pb2.StopResponse(status=sniffer_pb2.OK, pcap_content=pcap_content) diff --git a/tools/harness-simulation/posix/sniffer_sim/sniffer_transport.py b/tools/harness-simulation/posix/sniffer_sim/sniffer_transport.py index 2a9bff80f..721f4d28d 100644 --- a/tools/harness-simulation/posix/sniffer_sim/sniffer_transport.py +++ b/tools/harness-simulation/posix/sniffer_sim/sniffer_transport.py @@ -28,7 +28,6 @@ # import os -import select import socket @@ -72,17 +71,21 @@ class SnifferTransport(object): """ raise NotImplementedError - def recv(self, bufsize): + def recv(self, bufsize, timeout): """ Receive data sent by other node. Args: bufsize (int): size of buffer for incoming data. + timeout (float | None): socket timeout. Returns: A tuple contains data and node id. For example: (bytearray([0x00, 0x01...], 1) + + Raises: + socket.timeout: when receiving the packets times out. """ raise NotImplementedError @@ -140,16 +143,14 @@ class SnifferSocketTransport(SnifferTransport): return self._socket.sendto(data, address) - def recv(self, bufsize): + def recv(self, bufsize, timeout): + self._socket.settimeout(timeout) data, address = self._socket.recvfrom(bufsize) nodeid = self._port_to_nodeid(address[1]) return bytearray(data), nodeid - def ready(self, timeout): - return select.select([self._socket], [], [], timeout)[0] - class SnifferTransportFactory(object): diff --git a/tools/harness-thci/OpenThread.py b/tools/harness-thci/OpenThread.py index 79f1ec4bc..fdcbc1c94 100644 --- a/tools/harness-thci/OpenThread.py +++ b/tools/harness-thci/OpenThread.py @@ -254,13 +254,29 @@ class OpenThreadTHCI(object): line str: data send to device """ - @abstractmethod + # Override the following empty methods in the dervied classes when needed def _onCommissionStart(self): - """Called when commissioning starts.""" + """Called when commissioning starts""" - @abstractmethod def _onCommissionStop(self): - """Called when commissioning stops.""" + """Called when commissioning stops""" + + def _deviceBeforeReset(self): + """Called before the device resets""" + + def _deviceAfterReset(self): + """Called after the device resets""" + + def _restartAgentService(self): + """Restart the agent service""" + + def _beforeRegisterMulticast(self, sAddr, timeout): + """Called before the ipv6 address being subscribed in interface + + Args: + sAddr : str : Multicast address to be subscribed and notified OTA + timeout : int : The allowed maximal time to end normally + """ def __sendCommand(self, cmd, expectEcho=True): cmd = self._cmdPrefix + cmd @@ -384,31 +400,13 @@ class OpenThreadTHCI(object): @API def intialize(self, params): """initialize the serial port with baudrate, timeout parameters""" - self.port = params.get('SerialPort', '') - # params example: {'EUI': 1616240311388864514L, 'SerialBaudRate': None, 'TelnetIP': '192.168.8.181', 'SerialPort': None, 'Param7': None, 'Param6': None, 'Param5': 'ip', 'TelnetPort': '22', 'Param9': None, 'Param8': None} - - try: - - ipaddress.ip_address(self.port) - # handle TestHarness Discovery Protocol - self.connectType = 'ip' - self.telnetIp = self.port - self.telnetPort = 22 - self.telnetUsername = 'pi' if params.get('Param6') is None else params.get('Param6') - self.telnetPassword = 'raspberry' if params.get('Param7') is None else params.get('Param7') - except ValueError: - self.connectType = (params.get('Param5') or 'usb').lower() - self.telnetIp = params.get('TelnetIP') - self.telnetPort = int(params.get('TelnetPort')) if params.get('TelnetPort') else 22 - # username for SSH - self.telnetUsername = 'pi' if params.get('Param6') is None else params.get('Param6') - # password for SSH - self.telnetPassword = 'raspberry' if params.get('Param7') is None else params.get('Param7') - self.mac = params.get('EUI') self.backboneNetif = params.get('Param8') or 'eth0' self.extraParams = self.__parseExtraParams(params.get('Param9')) + # Potentially changes `self.extraParams` + self._parseConnectionParams(params) + self.UIStatusMsg = '' self.AutoDUTEnable = False self.isPowerDown = False @@ -449,6 +447,33 @@ class OpenThreadTHCI(object): else: return '[%s]' % self.port + def _parseConnectionParams(self, params): + """Parse parameters related to connection to the device + + Args: + params: Arbitrary keyword arguments including 'EUI' and 'SerialPort' + """ + self.port = params.get('SerialPort', '') + # params example: {'EUI': 1616240311388864514L, 'SerialBaudRate': None, 'TelnetIP': '192.168.8.181', 'SerialPort': None, 'Param7': None, 'Param6': None, 'Param5': 'ip', 'TelnetPort': '22', 'Param9': None, 'Param8': None} + self.log('All parameters: %r', params) + + try: + ipaddress.ip_address(self.port) + # handle TestHarness Discovery Protocol + self.connectType = 'ip' + self.telnetIp = self.port + self.telnetPort = 22 + self.telnetUsername = 'pi' if params.get('Param6') is None else params.get('Param6') + self.telnetPassword = 'raspberry' if params.get('Param7') is None else params.get('Param7') + except ValueError: + self.connectType = (params.get('Param5') or 'usb').lower() + self.telnetIp = params.get('TelnetIP') + self.telnetPort = int(params.get('TelnetPort')) if params.get('TelnetPort') else 22 + # username for SSH + self.telnetUsername = 'pi' if params.get('Param6') is None else params.get('Param6') + # password for SSH + self.telnetPassword = 'raspberry' if params.get('Param7') is None else params.get('Param7') + @watched def __parseExtraParams(self, Param9): """ @@ -460,12 +485,14 @@ class OpenThreadTHCI(object): - "cmd-start-otbr-agent" : The command to start otbr-agent (default: systemctl start otbr-agent) - "cmd-stop-otbr-agent" : The command to stop otbr-agent (default: systemctl stop otbr-agent) - "cmd-restart-otbr-agent" : The command to restart otbr-agent (default: systemctl restart otbr-agent) + - "cmd-restart-radvd" : The command to restart radvd (default: service radvd restart) For example, Param9 can be generated as below: Param9 = base64.urlsafe_b64encode(json.dumps({ "cmd-start-otbr-agent": "service otbr-agent start", "cmd-stop-otbr-agent": "service otbr-agent stop", "cmd-restart-otbr-agent": "service otbr-agent restart", + "cmd-restart-radvd": "service radvd stop; service radvd start", })) :param Param9: A JSON string encoded in URL-safe base64 encoding. @@ -1354,7 +1381,7 @@ class OpenThreadTHCI(object): self.__executeCommand('state', timeout=0.1) break except Exception: - self.__restartAgentService() + self._restartAgentService() time.sleep(2) self.__sendCommand('factoryreset', expectEcho=False) time.sleep(0.5) @@ -3278,18 +3305,6 @@ class OpenThread(OpenThreadTHCI, IThci): self.__handle.close() self.__handle = None - def _deviceBeforeReset(self): - pass - - def _deviceAfterReset(self): - pass - - def __restartAgentService(self): - pass - - def _beforeRegisterMulticast(self, sAddr, timeout): - pass - def __socRead(self, size=512): if self._is_net: return self.__handle.recv(size) @@ -3327,9 +3342,3 @@ class OpenThread(OpenThreadTHCI, IThci): self.__socWrite(line + '\r') else: self.__socWrite(line + '\r\n') - - def _onCommissionStart(self): - pass - - def _onCommissionStop(self): - pass diff --git a/tools/harness-thci/OpenThread_BR.py b/tools/harness-thci/OpenThread_BR.py index 3e699245f..0764fec32 100644 --- a/tools/harness-thci/OpenThread_BR.py +++ b/tools/harness-thci/OpenThread_BR.py @@ -298,17 +298,21 @@ class OpenThread_BR(OpenThreadTHCI, IThci): IsBorderRouter = True __is_root = False + def _getHandle(self): + if self.connectType == 'ip': + return SSHHandle(self.telnetIp, self.telnetPort, self.telnetUsername, self.telnetPassword) + else: + return SerialHandle(self.port, 115200) + def _connect(self): self.log("logging in to Raspberry Pi ...") self.__cli_output_lines = [] self.__syslog_skip_lines = None self.__syslog_last_read_ts = 0 + self.__handle = self._getHandle() if self.connectType == 'ip': - self.__handle = SSHHandle(self.telnetIp, self.telnetPort, self.telnetUsername, self.telnetPassword) self.__is_root = self.telnetUsername == 'root' - else: - self.__handle = SerialHandle(self.port, 115200) def _disconnect(self): if self.__handle: @@ -331,7 +335,7 @@ class OpenThread_BR(OpenThreadTHCI, IThci): self.__truncateSyslog() self.__enableAcceptRa() if not self.IsHost: - self.__restartAgentService() + self._restartAgentService() time.sleep(2) def __enableAcceptRa(self): @@ -587,7 +591,7 @@ class OpenThread_BR(OpenThreadTHCI, IThci): cmd = 'sh -c "cat >/etc/radvd.conf <<%s"' % conf self.bash(cmd) - self.bash('service radvd restart') + self.bash(self.extraParams.get('cmd-restart-radvd', 'service radvd restart')) self.bash('service radvd status') @watched @@ -624,7 +628,7 @@ class OpenThread_BR(OpenThreadTHCI, IThci): for line in output: self.__cli_output_lines.append(line) - def __restartAgentService(self): + def _restartAgentService(self): restart_cmd = self.extraParams.get('cmd-restart-otbr-agent', 'systemctl restart otbr-agent') self.bash(restart_cmd)