diff --git a/tools/harness-simulation/README.md b/tools/harness-simulation/README.md new file mode 100644 index 000000000..1412e9c43 --- /dev/null +++ b/tools/harness-simulation/README.md @@ -0,0 +1,49 @@ +# Test Harness on Simulation Environment Setup + +THCI (Thread Host Controller Interface) is an implementation of the Python abstract class template `IThci`, which is used by the Thread Test Harness Software to control OpenThread-based reference devices according to each test scenario. + +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. + +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. + ```bash + $ script/cmake-build simulation + ``` + Then `ot-cli-ftd` is built in the directory `build/simulation/examples/apps/cli/`. + +## Test Harness Environment Setup + +1. Double click the file `harness\install.bat` on the machine which installed Harness. + +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`. + +## Run Test Harness on Simulation + +1. On POSIX machine, change directory to the top of OpenThread repository, and run the following commands. + + ```bash + $ cd tools/harness-simulation/posix + $ python harness_dev_discovery.py \ + --interface=eth0 \ + --ot1.1=24 \ + --sniffer=2 + ``` + + It starts 24 OT FTD 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. + +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 new file mode 100644 index 000000000..fffdc831b --- /dev/null +++ b/tools/harness-simulation/harness/Thread_Harness/Sniffer/SimSniffer.py @@ -0,0 +1,225 @@ +#!/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. +# + +import ipaddress +import netifaces +import os +import paramiko +import select +import socket +import struct +import subprocess +import time +import winreg as wr + +from ISniffer import ISniffer +from THCI.OpenThread import watched +from simulation.config import ( + REMOTE_PORT, + REMOTE_USERNAME, + REMOTE_PASSWORD, + REMOTE_OT_PATH, + REMOTE_SNIFFER_OUTPUT_PREFIX, + EDITCAP_PATH, +) + +DISCOVERY_ADDR = ('ff02::114', 12345) + +IFNAME = 'WLAN' + +SCAN_TIME = 3 + +# `socket.IPPROTO_IPV6` only exists in Python 3, so the constant is manually defined. +IPPROTO_IPV6 = 41 + +# The subkey represents the class of network adapter devices supported by the system, +# which is used to filter physical network cards and avoid virtual network adapters. +WINREG_KEY = r'SYSTEM\CurrentControlSet\Control\Network\{4d36e972-e325-11ce-bfc1-08002be10318}' + + +class SimSniffer(ISniffer): + + @watched + def __init__(self, **kwargs): + self.channel = kwargs.get('channel') + self.ipaddr = kwargs.get('addressofDevice') + self.is_active = False + self._local_pcapng_location = None + self._ssh = None + self._remote_pcap_location = None + self._remote_pid = None + + def __repr__(self): + return '%r' % self.__dict__ + + def _get_connection_name_from_guid(self, iface_guids): + iface_names = ['(unknown)' for i in range(len(iface_guids))] + reg = wr.ConnectRegistry(None, wr.HKEY_LOCAL_MACHINE) + reg_key = wr.OpenKey(reg, WINREG_KEY) + for i in range(len(iface_guids)): + try: + reg_subkey = wr.OpenKey(reg_key, iface_guids[i] + r'\Connection') + iface_names[i] = wr.QueryValueEx(reg_subkey, 'Name')[0] + except Exception, e: + pass + return iface_names + + def _find_index(self, iface_name): + ifaces_guid = netifaces.interfaces() + absolute_iface_name = self._get_connection_name_from_guid(ifaces_guid) + try: + _required_iface_index = absolute_iface_name.index(iface_name) + _required_guid = ifaces_guid[_required_iface_index] + ip = netifaces.ifaddresses(_required_guid)[netifaces.AF_INET6][-1]['addr'] + self.log('Local IP: %s', ip) + return int(ip.split('%')[1]) + except Exception, e: + self.log('%r', e) + self.log('Interface %s not found', iface_name) + return None + + def _encode_address_port(self, addr, port): + port = str(port) + if isinstance(ipaddress.ip_address(addr), ipaddress.IPv6Address): + return '[' + addr + ']:' + port + return addr + ':' + port + + @watched + def discoverSniffer(self): + sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + + # Define the output interface of the socket + ifn = self._find_index(IFNAME) + if ifn is None: + self.log('%s interface has not enabled IPv6.', IFNAME) + return [] + ifn = struct.pack('I', ifn) + sock.setsockopt(IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, ifn) + + # Send the request + sock.sendto(('Sniffer').encode(), DISCOVERY_ADDR) + + # Scan for responses + devs = set() + start = time.time() + while time.time() - start < SCAN_TIME: + if select.select([sock], [], [], 1)[0]: + addr, _ = sock.recvfrom(1024) + devs.add(addr) + else: + # Re-send the request, due to unreliability of UDP especially on WLAN + sock.sendto(('Sniffer').encode(), DISCOVERY_ADDR) + + devs = [SimSniffer(addressofDevice=addr, channel=None) for addr in devs] + self.log('List of SimSniffers: %r', devs) + + return devs + + @watched + def startSniffer(self, channelToCapture, captureFileLocation, includeEthernet=False): + self.channel = channelToCapture + self._local_pcapng_location = captureFileLocation + self._remote_pcap_location = os.path.join(REMOTE_SNIFFER_OUTPUT_PREFIX, self.ipaddr.split('@')[0] + '.pcap') + + self._ssh = paramiko.SSHClient() + self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + remote_ip = self.ipaddr.split('@')[1] + self._ssh.connect(remote_ip, port=REMOTE_PORT, username=REMOTE_USERNAME, password=REMOTE_PASSWORD) + + _, stdout, _ = self._ssh.exec_command( + 'echo $$ && exec python3 %s -o %s -c %d' % + (os.path.join(REMOTE_OT_PATH, 'tools/harness-simulation/posix/sniffer_sim/sniffer.py'), + self._remote_pcap_location, self.channel)) + self._remote_pid = int(stdout.readline()) + + self.log('local pcapng location = %s', self._local_pcapng_location) + self.log('remote pcap location = %s', self._remote_pcap_location) + self.log('remote pid = %d', self._remote_pid) + + self.is_active = True + + @watched + def stopSniffer(self): + if not self.is_active: + return + self.is_active = False + + assert self._ssh is not None + self._ssh.exec_command('kill -s TERM %d' % self._remote_pid) + # Wait to make sure the file is closed + time.sleep(3) + + # Truncate suffix from .pcapng to .pcap + local_pcap_location = self._local_pcapng_location[:-2] + + with self._ssh.open_sftp() as sftp: + sftp.get(self._remote_pcap_location, local_pcap_location) + + self._ssh.close() + + 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._local_pcapng_location = None + self._ssh = None + self._remote_pcap_location = None + self._remote_pid = None + + @watched + def setChannel(self, channelToCapture): + self.channel = channelToCapture + + @watched + def getChannel(self): + return self.channel + + @watched + def validateFirmwareVersion(self, device): + return True + + @watched + def isSnifferCapturing(self): + return self.is_active + + @watched + def getSnifferAddress(self): + return self.ipaddr + + @watched + def globalReset(self): + pass + + def log(self, fmt, *args): + try: + msg = fmt % args + print('%s - %s - %s' % ('SimSniffer', time.strftime('%b %d %H:%M:%S'), msg)) + except Exception: + pass diff --git a/tools/harness-simulation/harness/Thread_Harness/THCI/OpenThread_Sim.py b/tools/harness-simulation/harness/Thread_Harness/THCI/OpenThread_Sim.py new file mode 100644 index 000000000..e0db9bd84 --- /dev/null +++ b/tools/harness-simulation/harness/Thread_Harness/THCI/OpenThread_Sim.py @@ -0,0 +1,144 @@ +#!/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_Sim THCI +>> Class : OpenThread_Sim +""" + +import ipaddress +import os +import paramiko +import socket +import time + +from IThci import IThci +from OpenThread import OpenThreadTHCI, watched +from simulation.config import REMOTE_OT_PATH + + +class SSHHandle(object): + + 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.__handle = None + self.__stdin = None + self.__stdout = None + self.__connect(device, node_id) + + @watched + def __connect(self, device, node_id): + self.close() + + self.__handle = paramiko.SSHClient() + self.__handle.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + self.log('Connecting to %s:%s with username=%s', self.ip, self.port, self.username) + 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') + + self.__stdin, self.__stdout, _ = self.__handle.exec_command(device + ' ' + str(node_id), get_pty=True) + self.__stdout.channel.setblocking(0) + + # Wait some time for initiation + time.sleep(0.1) + + @watched + def close(self): + if self.__handle is None: + return + self.__stdin.write('exit\n') + # Wait some time for termination + time.sleep(0.1) + self.__handle.close() + self.__stdin = None + self.__stdout = None + self.__handle = None + + def send(self, cmd): + self.__stdin.write(cmd) + + def recv(self): + try: + return self.__stdout.readline().rstrip() + except socket.timeout: + return '' + + def log(self, fmt, *args): + try: + msg = fmt % args + print('%s - %s - %s' % (self.port, time.strftime('%b %d %H:%M:%S'), msg)) + except Exception: + pass + + +class OpenThread_Sim(OpenThreadTHCI, IThci): + DEFAULT_COMMAND_TIMEOUT = 20 + + __handle = None + + device = os.path.join(REMOTE_OT_PATH, 'build/simulation/examples/apps/cli/ot-cli-ftd') + + @watched + def _connect(self): + # Only actually connect once. + if self.__handle is None: + assert self.connectType == 'ip' + assert '@' in self.telnetIp + self.log('SSH connecting ...') + node_id, ssh_ip = self.telnetIp.split('@') + self.__handle = SSHHandle(ssh_ip, self.telnetPort, self.telnetUsername, self.telnetPassword, self.device, + node_id) + + self.log('connected to %s successfully', self.telnetIp) + + @watched + def _disconnect(self): + pass + + def _cliReadLine(self): + tail = self.__handle.recv() + return tail if tail else None + + def _cliWriteLine(self, line): + self.__handle.send(line + '\n') + + def _onCommissionStart(self): + pass + + def _onCommissionStop(self): + pass diff --git a/tools/harness-simulation/harness/Thread_Harness/simulation/__init__.py b/tools/harness-simulation/harness/Thread_Harness/simulation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/harness-simulation/harness/Thread_Harness/simulation/config.py b/tools/harness-simulation/harness/Thread_Harness/simulation/config.py new file mode 100644 index 000000000..44e866e55 --- /dev/null +++ b/tools/harness-simulation/harness/Thread_Harness/simulation/config.py @@ -0,0 +1,38 @@ +#!/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. +# + +REMOTE_PORT = 22 +REMOTE_USERNAME = 'pi' +REMOTE_PASSWORD = 'raspberry' + +REMOTE_SNIFFER_OUTPUT_PREFIX = '/tmp/' + +REMOTE_OT_PATH = '/home/pi/work/src/openthread-pr/' + +EDITCAP_PATH = r'C:\Program Files (x86)\Wireshark_Thread\editcap.exe' diff --git a/tools/harness-simulation/harness/Web/data/deviceInputFields.xml b/tools/harness-simulation/harness/Web/data/deviceInputFields.xml new file mode 100644 index 000000000..a9e09d424 --- /dev/null +++ b/tools/harness-simulation/harness/Web/data/deviceInputFields.xml @@ -0,0 +1,10 @@ + + + COM1 + + + diff --git a/tools/harness-simulation/harness/install.bat b/tools/harness-simulation/harness/install.bat new file mode 100644 index 000000000..0b4a898b1 --- /dev/null +++ b/tools/harness-simulation/harness/install.bat @@ -0,0 +1,2 @@ +xcopy /E /Y Thread_Harness %systemdrive%\GRL\Thread1.2\Thread_Harness +copy /Y ..\..\harness-thci\OpenThread.py %systemdrive%\GRL\Thread1.2\Thread_Harness\THCI diff --git a/tools/harness-simulation/posix/harness_dev_discovery.py b/tools/harness-simulation/posix/harness_dev_discovery.py new file mode 100644 index 000000000..1587c998e --- /dev/null +++ b/tools/harness-simulation/posix/harness_dev_discovery.py @@ -0,0 +1,169 @@ +#!/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 argparse +import ctypes +import ctypes.util +import json +import logging +import os +import socket +import struct + +GROUP = 'ff02::114' +PORT = 12345 +MAX_OT11_NUM = 33 +MAX_SNIFFER_NUM = 4 + + +def if_nametoindex(ifname: str) -> int: + libc = ctypes.CDLL(ctypes.util.find_library('c')) + ret = libc.if_nametoindex(ifname.encode('ascii')) + if not ret: + raise RuntimeError('Invalid interface name') + return ret + + +def get_ipaddr(ifname: str) -> str: + for line in os.popen(f'ip addr list dev {ifname} | grep inet | grep global'): + addr = line.strip().split()[1] + return addr.split('/')[0] + raise RuntimeError(f'No IP address on dev {ifname}') + + +def init_socket(ifname: str, group: str, port: int) -> socket.socket: + # Look up multicast group address in name server and find out IP version + addrinfo = socket.getaddrinfo(group, None)[0] + assert addrinfo[0] == socket.AF_INET6 + + # Create a socket + s = socket.socket(addrinfo[0], socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, (ifname + '\0').encode('ascii')) + + # Bind it to the port + s.bind((group, port)) + + group_bin = socket.inet_pton(addrinfo[0], addrinfo[4][0]) + # Join group + interface_index = if_nametoindex(ifname) + mreq = group_bin + struct.pack('@I', interface_index) + s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, mreq) + + return s + + +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): + info = { + 'ven': ven, + 'mod': f'{ven}_{i}', + 'ver': ver, + 'add': f'{i}@{add}', + 'por': por, + } + logging.info('Advertise: %r', info) + s.sendto(json.dumps(info).encode('utf-8'), dst) + + +def advertise_sniffer(s: socket.socket, dst, add: str, number: int): + for i in range(number): + info = 'Sniffer_%d@%s' % (i, add) + logging.info('Advertise: %r', info) + s.sendto(info.encode('utf-8'), dst) + + +def main(): + logging.basicConfig(level=logging.INFO) + + # Parse arguments + parser = argparse.ArgumentParser() + + # Determine the interface + parser.add_argument('-i', + '--interface', + dest='ifname', + type=str, + required=True, + help='the interface used for discovery') + + # Determine the number of OpenThread 1.1 FTD simulations to be "detected" and then initiated + parser.add_argument('--ot1.1', + dest='ot11_num', + type=int, + required=False, + default=0, + help=f'the number of OpenThread FTD simulations, no more than {MAX_OT11_NUM}') + + # Determine the number of sniffer simulations to be initiated and then detected + parser.add_argument('-s', + '--sniffer', + dest='sniffer_num', + type=int, + required=False, + default=0, + help=f'the number of sniffer simulations, no more than {MAX_SNIFFER_NUM}') + + 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 not 0 <= args.sniffer_num <= MAX_SNIFFER_NUM: + raise ValueError(f'The number of FTDs should be between 0 and {MAX_SNIFFER_NUM}') + + if args.ot11_num == args.sniffer_num == 0: + raise ValueError('At least one device is required') + + # Get the local IP address on the specified interface + addr = get_ipaddr(args.ifname) + + s = init_socket(args.ifname, GROUP, PORT) + + logging.info('Advertising on interface %s group %s ...', args.ifname, GROUP) + + # Loop, printing any data we receive + while True: + data, src = s.recvfrom(64) + + 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) + + elif data == b'Sniffer': + logging.info('Received sniffer simulation query, advertising') + advertise_sniffer(s, src, add=addr, number=args.sniffer_num) + + else: + logging.warning('Received %r, but ignored', data) + + +if __name__ == '__main__': + main() diff --git a/tools/harness-simulation/posix/sniffer_sim/pcap_codec.py b/tools/harness-simulation/posix/sniffer_sim/pcap_codec.py new file mode 100644 index 000000000..ff25356bb --- /dev/null +++ b/tools/harness-simulation/posix/sniffer_sim/pcap_codec.py @@ -0,0 +1,99 @@ +#!/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. +# +""" Module to provide codec utilities for .pcap formatters. """ + +import struct +import time + +# https://www.tcpdump.org/linktypes.html +DLT_IEEE802_15_4_WITHFCS = 195 + +PCAP_MAGIC_NUMBER = 0xA1B2C3D4 +PCAP_VERSION_MAJOR = 2 +PCAP_VERSION_MINOR = 4 + + +class PcapCodec(object): + """ Utility class for .pcap formatters. """ + + def __init__(self, filename, channel): + self._dlt = DLT_IEEE802_15_4_WITHFCS + if not filename.endswith('.pcap'): + raise ValueError('Filename should end with .pcap') + self._pcap_file = open(filename, 'wb') + self._pcap_file.write(self.encode_header()) + self._channel = channel + + def encode_header(self): + """ Return a pcap file header. """ + return struct.pack( + '