From 95c5cb793a2fbcd7079acb9d5676588ac09b5ce0 Mon Sep 17 00:00:00 2001 From: Simon Lin Date: Fri, 11 Jun 2021 14:54:47 +0800 Subject: [PATCH] [thci] enhance THCI for 1.2 Certification (#6609) This commit enhances THCI for 1.2 Certification: - OpenThread.py: 1.2 non-BR - OpenThread_BR.py: 1.2 BR and Host (otbr-agent solution) This commit should also make THCI work for both TH1.1 and TH1.2. --- tools/harness-thci/OpenThread.py | 768 ++++++++++++++++++++++------ tools/harness-thci/OpenThread_BR.py | 314 +++++++++++- 2 files changed, 902 insertions(+), 180 deletions(-) diff --git a/tools/harness-thci/OpenThread.py b/tools/harness-thci/OpenThread.py index 0721c2ac3..b1298fe9f 100644 --- a/tools/harness-thci/OpenThread.py +++ b/tools/harness-thci/OpenThread.py @@ -32,15 +32,27 @@ """ import functools -import re -import traceback -from Queue import Queue -from abc import abstractmethod - +import ipaddress import logging -import serial +import random +import re import socket import time +from abc import abstractmethod + +import serial +from Queue import Queue + +import commissioner +from commissioner_impl import OTCommissioner + +TESTHARNESS_1_1 = '1.1' +TESTHARNESS_1_2 = '1.2' + +if 'Thread1.2' in __file__: + TESTHARNESS_VERSION = TESTHARNESS_1_2 +else: + TESTHARNESS_VERSION = TESTHARNESS_1_1 from GRLLibs.ThreadPacket.PlatformPackets import ( PlatformDiagnosticPacket, @@ -57,12 +69,41 @@ from GRLLibs.UtilityModules.enums import ( PlatformDiagnosticPacket_Direction, PlatformDiagnosticPacket_Type, ) + +if TESTHARNESS_VERSION == TESTHARNESS_1_2: + from GRLLibs.UtilityModules.enums import ( + DevCapb,) + from IThci import IThci LINESEPX = re.compile(r'\r\n|\n') """regex: used to split lines""" -logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(levelname)s - %(message)s") +LOGX = re.compile(r'((\[(NONE|CRIT|WARN|NOTE|INFO|DEBG)\])' + r'|(-(CLI|MLR|API|MLE|BBR|DUA|ARP|N-DATA|ICMP|IP6|MAC|MEM|NCP|MESH-CP|DIAG|PLAT|COAP|CORE|UTIL)-+: )' + r'|(-+$)' # e.x. ------------------------------------------------------------------------ + r'|(=+\[.*\]=+$)' # e.x. ==============================[TX len=108]=============================== + r'|(\|.+\|.+\|.+)' # e.x. | 61 DC D2 CE FA 04 00 00 | 00 00 0A 6E 16 01 00 00 | aRNz......n.... + r')') +"""regex used to filter logs""" + +assert LOGX.match('[NONE]') +assert LOGX.match('[CRIT]') +assert LOGX.match('[WARN]') +assert LOGX.match('[NOTE]') +assert LOGX.match('[INFO]') +assert LOGX.match('[DEBG]') +assert LOGX.match('-CLI-----: ') +assert LOGX.match('-N-DATA--: ') +assert LOGX.match('-MESH-CP-: ') +assert LOGX.match('------------------------------------------------------------------------') +assert LOGX.match('==============================[TX len=108]===============================') +assert LOGX.match('| 61 DC D2 CE FA 04 00 00 | 00 00 0A 6E 16 01 00 00 | aRNz......n....') + +# OT Errors +OT_ERROR_ALREADY = 24 + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") _callStackDepth = 0 @@ -78,12 +119,11 @@ def watched(func): _callStackDepth += 1 try: - self.log("%s starts ...", callstr) ret = func(self, *args, **kwargs) self.log("%s returns %r", callstr, ret) return ret except Exception as ex: - self.log("FUNC %s failed: %s\n%s", func_name, str(ex), traceback.format_exc()) + self.log("FUNC %s failed: %s", func_name, str(ex)) raise finally: _callStackDepth -= 1 @@ -129,12 +169,43 @@ def commissioning(func): return comm_func +class CommandError(Exception): + + def __init__(self, code, msg): + assert isinstance(code, int), code + self.code = code + self.msg = msg + + super(CommandError, self).__init__("Error %d: %s" % (code, msg)) + + class OpenThreadTHCI(object): LOWEST_POSSIBLE_PARTATION_ID = 0x1 LINK_QUALITY_CHANGE_TIME = 100 + DEFAULT_COMMAND_TIMEOUT = 10 firmwarePrefix = 'OPENTHREAD/' + DOMAIN_NAME = 'Thread' + MLR_TIMEOUT_MIN = 300 + + IsBorderRouter = False + IsBackboneRouter = False + IsHost = False + + externalCommissioner = None _update_router_status = False + if TESTHARNESS_VERSION == TESTHARNESS_1_2: + _ROLE_MODE_DICT = { + Thread_Device_Role.Leader: 'rdn', + Thread_Device_Role.Router: 'rdn', + Thread_Device_Role.SED: '-', + Thread_Device_Role.EndDevice: 'rn', + Thread_Device_Role.REED: 'rdn', + Thread_Device_Role.EndDevice_FED: 'rdn', + Thread_Device_Role.EndDevice_MED: 'rn', + Thread_Device_Role.SSED: '-', + } + def __init__(self, **kwargs): """initialize the serial port and default network parameters Args: @@ -185,9 +256,11 @@ class OpenThreadTHCI(object): if expectEcho: self.__expect(cmd, endswith=True) + _COMMAND_OUTPUT_ERROR_PATTERN = re.compile(r'Error (\d+): (.*)') + @retry(3) @watched - def __executeCommand(self, cmd, timeout=10): + def __executeCommand(self, cmd, timeout=DEFAULT_COMMAND_TIMEOUT): """send specific command to reference unit over serial port Args: @@ -210,7 +283,7 @@ class OpenThreadTHCI(object): t_end = time.time() + timeout while time.time() < t_end: - line = self._cliReadLine() + line = self.__readCliLine() if line is None: time.sleep(0.01) continue @@ -220,8 +293,12 @@ class OpenThreadTHCI(object): if line == 'Done': break - - if line != 'Done': + else: + m = OpenThreadTHCI._COMMAND_OUTPUT_ERROR_PATTERN.match(line) + if m is not None: + code, msg = m.groups() + raise CommandError(int(code), msg) + else: raise Exception('%s: failed to find end of response: %s' % (self, response)) return response @@ -237,7 +314,7 @@ class OpenThreadTHCI(object): deadline = time.time() + timeout while True: - line = self._cliReadLine() + line = self.__readCliLine() if line is not None: self.log("readline: %s", line) @@ -255,6 +332,15 @@ class OpenThreadTHCI(object): raise Exception('failed to find expected string[%s]' % expected) + def __readCliLine(self, ignoreLogs=True): + """Read the next line from OT CLI.d""" + line = self._cliReadLine() + if ignoreLogs: + while line is not None and LOGX.match(line): + line = self._cliReadLine() + + return line + @API def getVersionNumber(self): """get OpenThread stack firmware version number""" @@ -275,19 +361,30 @@ class OpenThreadTHCI(object): @API def intialize(self, params): """initialize the serial port with baudrate, timeout parameters""" + self.port = params.get('SerialPort', '') + assert isinstance(self.port, unicode), unicode self.log('%s intialize: %r', self.__class__.__name__, params) # 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.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') + try: + + ipaddress.ip_address(self.port) + # handle TestHarness Discovery Protocol + self.connectType = 'ip' + self.telnetIp = self.port + self.telnetPort = 22 + self.telnetUsername = 'pi' + self.telnetPassword = 'raspberry' + 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.port = params.get('SerialPort') self.UIStatusMsg = '' self.AutoDUTEnable = False @@ -310,7 +407,8 @@ class OpenThreadTHCI(object): # init serial port self._connect() - + if TESTHARNESS_VERSION == TESTHARNESS_1_2: + self.__discoverDeviceCapability() self.UIStatusMsg = self.getVersionNumber() if self.firmwarePrefix in self.UIStatusMsg: @@ -442,40 +540,55 @@ class OpenThreadTHCI(object): False: fail to start OpenThread stack """ print('call startOpenThread') - try: - if self.hasActiveDatasetToCommit: - if self.__executeCommand('dataset commit active')[0] != 'Done': - raise Exception('failed to commit active dataset') - else: - self.hasActiveDatasetToCommit = False - - # restore allowlist/denylist address filter mode if rejoin after - # reset - if self.isPowerDown: - if self._addressfilterMode == 'allowlist': - if self.__setAddressfilterMode('allowlist'): - for addr in self._addressfilterSet: - self.addAllowMAC(addr) - elif self._addressfilterMode == 'denylist': - if self.__setAddressfilterMode('denylist'): - for addr in self._addressfilterSet: - self.addBlockedMAC(addr) - - if self.deviceRole in [ - Thread_Device_Role.Leader, - Thread_Device_Role.Router, - Thread_Device_Role.REED, - ]: - self.__setRouterSelectionJitter(1) - - if self.__executeCommand('ifconfig up')[-1] == 'Done': - if self.__executeCommand('thread start')[-1] == 'Done': - self.isPowerDown = False - return True + if self.hasActiveDatasetToCommit: + if self.__executeCommand('dataset commit active')[0] != 'Done': + raise Exception('failed to commit active dataset') else: - return False - except Exception as e: - ModuleHelper.WriteIntoDebugLogger('startOpenThread() Error: ' + str(e)) + self.hasActiveDatasetToCommit = False + + # restore allowlist/denylist address filter mode if rejoin after + # reset + if self.isPowerDown: + if self._addressfilterMode == 'allowlist': + if self.__setAddressfilterMode('allowlist'): + for addr in self._addressfilterSet: + self.addAllowMAC(addr) + elif self._addressfilterMode == 'denylist': + if self.__setAddressfilterMode('denylist'): + for addr in self._addressfilterSet: + self.addBlockedMAC(addr) + + # Set routerselectionjitter to 1 for certain device roles + if self.deviceRole in [ + Thread_Device_Role.Leader, + Thread_Device_Role.Router, + Thread_Device_Role.REED, + ]: + self.__setRouterSelectionJitter(1) + elif TESTHARNESS_VERSION == TESTHARNESS_1_2 and self.deviceRole in [ + Thread_Device_Role.BR_1, Thread_Device_Role.BR_2 + ]: + self.IsBackboneRouter = True + self.__setRouterSelectionJitter(1) + + if self.IsBackboneRouter: + # Configure default BBR dataset + self.__configBbrDataset(SeqNum=self.bbrSeqNum, + MlrTimeout=self.bbrMlrTimeout, + ReRegDelay=self.bbrReRegDelay) + # Add default domain prefix is not configured otherwise + if self.__useDefaultDomainPrefix: + self.__addDefaultDomainPrefix() + + self._deviceBeforeThreadStart() + + self.__executeCommand('ifconfig up') + self.__executeCommand('thread start') + self.isPowerDown = False + return True + + def _deviceBeforeThreadStart(self): + pass def __stopOpenThread(self): """stop OpenThread stack @@ -484,7 +597,7 @@ class OpenThreadTHCI(object): True: successful to stop OpenThread stack and thread interface down False: fail to stop OpenThread stack """ - print('call stopOpenThread') + self.log('call stopOpenThread') try: if self.__executeCommand('thread stop')[-1] == 'Done': return self.__executeCommand('ifconfig down')[-1] == 'Done' @@ -493,6 +606,7 @@ class OpenThreadTHCI(object): except Exception as e: ModuleHelper.WriteIntoDebugLogger('stopOpenThread() Error: ' + str(e)) + @watched def __isOpenThreadRunning(self): """check whether or not OpenThread is running @@ -500,7 +614,7 @@ class OpenThreadTHCI(object): True: OpenThread is running False: OpenThread is not running """ - print('call isOpenThreadRunning') + self.log('call isOpenThreadRunning') return self.__executeCommand('state')[0] != 'disabled' # rloc16 might be hex string or integer, need to return actual allocated @@ -610,7 +724,7 @@ class OpenThreadTHCI(object): continue try: - line = self._cliReadLine() + line = self.__readCliLine(ignoreLogs=False) if line: self.log("commissioning log: %s", line) @@ -652,7 +766,7 @@ class OpenThreadTHCI(object): return maskSet def __setChannelMask(self, channelMask): - print('call _setChannelMask') + self.log('call _setChannelMask') try: cmd = 'dataset channelmask %s' % channelMask self.hasActiveDatasetToCommit = True @@ -700,7 +814,7 @@ class OpenThreadTHCI(object): return self.__executeCommand('commissioner sessionid')[0] # pylint: disable=no-self-use - def __escapeEscapable(self, string): + def _deviceEscapeEscapable(self, string): """Escape CLI escapable characters in the given string. Args: @@ -727,7 +841,7 @@ class OpenThreadTHCI(object): """ print('%s call setNetworkName' % self) print(networkName) - networkName = self.__escapeEscapable(networkName) + networkName = self._deviceEscapeEscapable(networkName) try: cmd = 'networkname %s' % networkName datasetCmd = 'dataset networkname %s' % networkName @@ -755,6 +869,7 @@ class OpenThreadTHCI(object): try: cmd = 'channel %s' % channel datasetCmd = 'dataset channel %s' % channel + self.hasSetChannel = True self.hasActiveDatasetToCommit = True return self.__executeCommand(cmd)[-1] == 'Done' and self.__executeCommand(datasetCmd)[-1] == 'Done' except Exception as e: @@ -817,6 +932,8 @@ class OpenThreadTHCI(object): macAddr64 = self.__executeCommand('eui64')[0] elif bType == MacType.HashMac: macAddr64 = self.__executeCommand('joiner id')[0] + elif TESTHARNESS_VERSION == TESTHARNESS_1_2 and bType == MacType.EthMac: + return self._deviceGetEtherMac() else: macAddr64 = self.__executeCommand('extaddr')[0] print(macAddr64) @@ -1061,7 +1178,7 @@ class OpenThreadTHCI(object): self.deviceRole = eRoleId mode = '-' try: - if ModuleHelper.LeaderDutChannelFound: + if ModuleHelper.LeaderDutChannelFound and not self.hasSetChannel: self.channel = ModuleHelper.Default_Channel # FIXME: when Harness call setNetworkDataRequirement()? @@ -1078,6 +1195,12 @@ class OpenThreadTHCI(object): if self.AutoDUTEnable is False: # set ROUTER_DOWNGRADE_THRESHOLD self.__setRouterDowngradeThreshold(33) + elif eRoleId in (Thread_Device_Role.BR_1, Thread_Device_Role.BR_2): + print('join as BBR') + mode = 'rdn' + if self.AutoDUTEnable is False: + # set ROUTER_DOWNGRADE_THRESHOLD + self.__setRouterDowngradeThreshold(33) elif eRoleId == Thread_Device_Role.SED: print('join as sleepy end device') mode = '-' @@ -1200,24 +1323,23 @@ class OpenThreadTHCI(object): ModuleHelper.WriteIntoDebugLogger('reboot() Error: ' + str(e)) @API - def ping(self, destination, length=20): - """ send ICMPv6 echo request with a given length to a unicast destination - address + def ping(self, strDestination, ilength=0, hop_limit=64, timeout=5): + """ send ICMPv6 echo request with a given length/hoplimit to a unicast + destination address + + TODO: add hop_limit support Args: - destination: the unicast destination address of ICMPv6 echo request - length: the size of ICMPv6 echo request payload + srcDestination: the unicast destination address of ICMPv6 echo request + ilength: the size of ICMPv6 echo request payload + hop_limit: hop limit + """ - print('%s call ping' % self) - print('destination: %s' % destination) - try: - cmd = 'ping %s %s' % (destination, str(length)) - print(cmd) - self.__sendCommand(cmd) - # wait echo reply - self.sleep(6) # increase delay temporally (+5s) to remedy TH's delay updates - except Exception as e: - ModuleHelper.WriteIntoDebugLogger('ping() Error: ' + str(e)) + print('%s call ping' % self.port) + print('destination: %s' % strDestination) + cmd = 'ping %s %s 1 1 %d %d' % (strDestination, str(ilength), hop_limit, timeout) + self.__executeCommand(cmd) + time.sleep(1) @API def multicast_Ping(self, destination, length=20): @@ -1269,10 +1391,22 @@ class OpenThreadTHCI(object): def reset(self): """factory reset""" print('%s call reset' % self) - self.__sendCommand('factoryreset', expectEcho=False) - self.sleep(0.5) - self._onReset() + self._deviceBeforeReset() + + self.__sendCommand('factoryreset', expectEcho=False) + start_time = time.time() + while time.time() < start_time + 10: + time.sleep(0.3) + try: + self.__executeCommand('state', timeout=0.1) + break + except Exception: + continue + + self.log('factoryreset finished in %dms', int(time.time() - start_time)) + + self._deviceAfterReset() @API def removeRouter(self, xRouterId): @@ -1315,7 +1449,7 @@ class OpenThreadTHCI(object): self.xpanId = ModuleHelper.Default_XpanId self.meshLocalPrefix = ModuleHelper.Default_MLPrefix # OT only accept hex format PSKc for now - self.pskc = '00000000000000000000000000000000' + self.pskc = '00000000000000000000000000000001' self.securityPolicySecs = ModuleHelper.Default_SecurityPolicy self.securityPolicyFlags = 'onrcb' self.activetimestamp = ModuleHelper.Default_ActiveTimestamp @@ -1336,6 +1470,19 @@ class OpenThreadTHCI(object): self._addressfilterSet = set() # cache filter entries # indicate if Thread device is an active commissioner self.isActiveCommissioner = False + # indicate that the channel has been set, in case the channel was set + # to default when joining network + self.hasSetChannel = False + # indicate whether the default domain prefix is used. + self.__useDefaultDomainPrefix = True + self.__isUdpOpened = False + self.IsBackboneRouter = False + self.IsHost = False + + # BBR dataset + self.bbrSeqNum = random.randint(0, 254) # random seqnum except 255, so that BBR-TC-02 never need re-run + self.bbrMlrTimeout = 3600 + self.bbrReRegDelay = 5 # initialize device configuration try: @@ -1525,7 +1672,7 @@ class OpenThreadTHCI(object): @API def configBorderRouter( self, - P_Prefix, + P_Prefix=None, P_stable=1, P_default=1, P_slaac_preferred=0, @@ -1533,6 +1680,7 @@ class OpenThreadTHCI(object): P_preference=0, P_on_mesh=1, P_nd_dns=0, + P_dp=0, ): """configure the border router with a given prefix entry parameters @@ -1551,51 +1699,72 @@ class OpenThreadTHCI(object): False: fail to configure the border router with a given prefix entry """ print('%s call configBorderRouter' % self) - prefix = self.__convertIp6PrefixStringToIp6Address(str(P_Prefix)) + assert TESTHARNESS_VERSION == TESTHARNESS_1_2 or P_dp == 0 + + # turn off default domain prefix if configBorderRouter is called before joining network + if TESTHARNESS_VERSION == TESTHARNESS_1_2 and P_dp == 0 and not self.__isOpenThreadRunning(): + self.__useDefaultDomainPrefix = False + + if TESTHARNESS_VERSION == TESTHARNESS_1_2: + # TestHarness 1.2 converts 0x2001000000000000 to "2001000000000000" + if P_Prefix is None: + P_Prefix = 0xfd007d037d037d03 + + P_Prefix = '%016x' % P_Prefix + else: + # TestHarness 1.1 converts 2001000000000000 to "2001000000000000" (it's wrong, but not fixed yet.) + P_Prefix = str(P_Prefix) + int(P_Prefix, 16) + + prefix = self.__convertIp6PrefixStringToIp6Address(P_Prefix) print(prefix) - try: - parameter = '' - prf = '' + parameter = '' + prf = '' - if P_slaac_preferred == 1: - parameter += 'p' - parameter += 'a' + if P_dp: + P_slaac_preferred = 1 - if P_stable == 1: - parameter += 's' + if P_slaac_preferred == 1: + parameter += 'p' + parameter += 'a' - if P_default == 1: - parameter += 'r' + if P_stable == 1: + parameter += 's' - if P_Dhcp == 1: - parameter += 'd' + if P_default == 1: + parameter += 'r' - if P_on_mesh == 1: - parameter += 'o' + if P_Dhcp == 1: + parameter += 'd' - if P_preference == 1: - prf = 'high' - elif P_preference == 0: - prf = 'med' - elif P_preference == -1: - prf = 'low' + if P_on_mesh == 1: + parameter += 'o' + + if P_dp == 1: + assert P_slaac_preferred and P_default and P_on_mesh and P_stable + parameter += 'D' + + if P_preference == 1: + prf = 'high' + elif P_preference == 0: + prf = 'med' + elif P_preference == -1: + prf = 'low' + else: + pass + + cmd = 'prefix add %s/64 %s %s' % (prefix, parameter, prf) + print(cmd) + if self.__executeCommand(cmd)[-1] == 'Done': + # if prefix configured before starting OpenThread stack + # do not send out server data ntf pro-actively + if not self.__isOpenThreadRunning(): + return True else: - pass - - cmd = 'prefix add %s/64 %s %s' % (prefix, parameter, prf) - print(cmd) - if self.__executeCommand(cmd)[-1] == 'Done': - # if prefix configured before starting OpenThread stack - # do not send out server data ntf pro-actively - if not self.__isOpenThreadRunning(): - return True - else: - # send server data ntf to leader - return self.__executeCommand('netdata register')[-1] == 'Done' - else: - return False - except Exception as e: - ModuleHelper.WriteIntoDebugLogger('configBorderRouter() Error: ' + str(e)) + # send server data ntf to leader + return self.__executeCommand('netdata register')[-1] == 'Done' + else: + return False @API def setNetworkIDTimeout(self, iNwkIDTimeOut): @@ -1932,7 +2101,7 @@ class OpenThreadTHCI(object): return self.__executeCommand(cmd)[-1] == 'Done' @API - def getGUA(self, filterByPrefix=None): + def getGUA(self, filterByPrefix=None, eth=False): """get expected global unicast IPv6 address of Thread device note: existing filterByPrefix are string of in lowercase. e.g. @@ -1944,24 +2113,19 @@ class OpenThreadTHCI(object): Returns: a global IPv6 address """ - print('%s call getGUA' % self) - print(filterByPrefix) - globalAddrs = [] - try: - # get global addrs set if multiple - globalAddrs = self.__getGlobal() + assert not eth + # get global addrs set if multiple + globalAddrs = self.__getGlobal() - if filterByPrefix is None: - return globalAddrs[0] - else: - for fullIp in globalAddrs: - if fullIp.startswith(filterByPrefix): - print('target global %s' % fullIp) - return fullIp - print('no global address matched') - return str(globalAddrs[0]) - except Exception as e: - ModuleHelper.WriteIntoDebugLogger('getGUA() Error: ' + str(e)) + if filterByPrefix is None: + return globalAddrs[0] + else: + for fullIp in globalAddrs: + if fullIp.startswith(filterByPrefix): + print('target global %s' % fullIp) + return fullIp + print('no global address matched') + return str(globalAddrs[0]) @API def getShortAddress(self): @@ -2085,7 +2249,47 @@ class OpenThreadTHCI(object): return self.__executeCommand(cmd)[-1] == 'Done' @API - def startCollapsedCommissioner(self): + def getBorderAgentPort(self): + return int(self.__executeCommand('ba port')[0]) + + @API + def startExternalCommissioner(self, baAddr, baPort): + """Start external commissioner + Args: + baAddr: A string represents the border agent address. + baPort: An integer represents the border agent port. + Returns: + A boolean indicates whether this function succeed. + """ + if self.externalCommissioner is None: + config = commissioner.Configuration() + config.isCcmMode = False + config.domainName = OpenThreadTHCI.DOMAIN_NAME + config.pskc = bytearray.fromhex(self.pskc) + + self.externalCommissioner = OTCommissioner(config, self) + + if not self.externalCommissioner.isActive(): + self.externalCommissioner.start(baAddr, baPort) + + if not self.externalCommissioner.isActive(): + raise commissioner.Error("external commissioner is not active") + + return True + + @API + def stopExternalCommissioner(self): + """Stop external commissioner + Returns: + A boolean indicates whether this function succeed. + """ + + if self.externalCommissioner is not None: + self.externalCommissioner.stop() + return not self.externalCommissioner.isActive() + + @API + def startCollapsedCommissioner(self, role=Thread_Device_Role.Leader): """start Collapsed Commissioner Returns: @@ -2129,8 +2333,10 @@ class OpenThreadTHCI(object): else: eui64 = xEUI + strPSKd = self.__normalizePSKd(strPSKd) + cmd = 'commissioner joiner add %s %s %s' % ( - eui64, + self._deviceEscapeEscapable(eui64), strPSKd, str(timeout), ) @@ -2143,6 +2349,10 @@ class OpenThreadTHCI(object): else: return False + @staticmethod + def __normalizePSKd(strPSKd): + return strPSKd.upper().replace('I', '1').replace('O', '0').replace('Q', '0').replace('Z', '2') + @API def setProvisioningUrl(self, strURL='grl.com'): """set provisioning Url @@ -2196,6 +2406,7 @@ class OpenThreadTHCI(object): """ self.log("joinCommissioned on channel %s", self.getChannel()) self.__executeCommand('ifconfig up') + strPSKd = self.__normalizePSKd(strPSKd) cmd = 'joiner start %s %s' % (strPSKd, self.provisioningUrl) print(cmd) if self.__executeCommand(cmd)[-1] == 'Done': @@ -2434,7 +2645,7 @@ class OpenThreadTHCI(object): if sNetworkName is not None: cmd += ' networkname ' - cmd += self.__escapeEscapable(str(sNetworkName)) + cmd += self._deviceEscapeEscapable(str(sNetworkName)) if xChannel is not None: cmd += ' channel ' @@ -2633,7 +2844,7 @@ class OpenThreadTHCI(object): if sNetworkName is not None: cmd += ' networkname ' - cmd += self.__escapeEscapable(str(sNetworkName)) + cmd += self._deviceEscapeEscapable(str(sNetworkName)) if xCommissionerSessionId is not None: cmd += ' -x ' @@ -2830,6 +3041,54 @@ class OpenThreadTHCI(object): print('%s call ValidateDeviceFirmware' % self) return 'OPENTHREAD' in self.UIStatusMsg + @API + def setBbrDataset(self, SeqNumInc=False, SeqNum=None, MlrTimeout=None, ReRegDelay=None): + """ set BBR Dataset + + Args: + SeqNumInc: Increase `SeqNum` by 1 if True. + SeqNum: Set `SeqNum` to a given value if not None. + MlrTimeout: Set `MlrTimeout` to a given value. + ReRegDelay: Set `ReRegDelay` to a given value. + + MUST NOT set SeqNumInc to True and SeqNum to non-None value at the same time. + + Returns: + True: successful to set BBR Dataset + False: fail to set BBR Dataset + """ + assert not (SeqNumInc and SeqNum is not None), "Must not specify both SeqNumInc and SeqNum" + if SeqNumInc: + SeqNum = (self.bbrSeqNum + 1) % 256 + + return self.__configBbrDataset(SeqNum=SeqNum, MlrTimeout=MlrTimeout, ReRegDelay=ReRegDelay) + + def __configBbrDataset(self, SeqNum=None, MlrTimeout=None, ReRegDelay=None): + if MlrTimeout is not None and ReRegDelay is None: + ReRegDelay = self.bbrReRegDelay + + cmd = 'bbr config' + if SeqNum is not None: + cmd += ' seqno %d' % SeqNum + if ReRegDelay is not None: + cmd += ' delay %d' % ReRegDelay + if MlrTimeout is not None: + cmd += ' timeout %d' % MlrTimeout + ret = self.__executeCommand(cmd)[-1] == 'Done' + + if SeqNum is not None: + self.bbrSeqNum = SeqNum + + if MlrTimeout is not None: + self.bbrMlrTimeout = MlrTimeout + + if ReRegDelay is not None: + self.bbrReRegDelay = ReRegDelay + + self.__executeCommand('netdata register') + + return ret + # Low power THCI @API def setCSLtout(self, tout=30): @@ -2936,7 +3195,7 @@ class OpenThreadTHCI(object): print(cmd) return self.__executeCommand(cmd)[-1] == 'Done' - #TODO: Series Id is not in this API. + # TODO: Series Id is not in this API. @API def LinkMetricsSendProbe(self, dst_addr, ack=True, size=0): self.log('call LinkMetricsSendProbe') @@ -2963,11 +3222,9 @@ class OpenThreadTHCI(object): def sendUdp(self, destination, port, payload='hello'): self.log('call sendUdp') assert payload is not None, 'payload should not be none' - cmd1 = 'udp open' - print(cmd1) - cmd2 = 'udp send %s %d %s' % (destination, port, payload) - print(cmd2) - return self.__executeCommand(cmd1)[-1] == 'Done' and self.__executeCommand(cmd2)[-1] == 'Done' + cmd = 'udp send %s %d %s' % (destination, port, payload) + print(cmd) + return self.__executeCommand(cmd)[-1] == 'Done' @API def send_udp(self, interface, destination, port, payload='12ABcd'): @@ -2975,11 +3232,17 @@ class OpenThreadTHCI(object): ''' self.log('call send_udp') assert payload is not None, 'payload should not be none' - cmd1 = 'udp open' - print(cmd1) - cmd2 = 'udp send %s %s -x %s' % (destination, port, payload) - print(cmd2) - return self.__executeCommand(cmd1)[-1] == 'Done' and self.__executeCommand(cmd2)[-1] == 'Done' + assert interface == 0, "non-BR must send UDP to Thread interface" + self.__udpOpen() + cmd = 'udp send %s %s -x %s' % (destination, port, payload) + print(cmd) + return self.__executeCommand(cmd)[-1] == 'Done' + + def __udpOpen(self): + if not self.__isUdpOpened: + cmd = 'udp open' + self.__executeCommand(cmd) + self.__isUdpOpened = True @API def sendMACcmd(self, enh=False): @@ -3003,6 +3266,200 @@ class OpenThreadTHCI(object): else: self.__setPollPeriod(self.__sedPollPeriod) + @API + def set_max_addrs_per_child(self, num): + cmd = 'childipmax %d' % int(num) + print(cmd) + self.__executeCommand(cmd) + + @API + def config_next_dua_status_rsp(self, mliid, status_code): + if status_code >= 400: + # map status_code to correct COAP response code + a, b = divmod(status_code, 100) + status_code = ((a & 0x7) << 5) + (b & 0x1f) + + cmd = 'bbr mgmt dua %d' % status_code + + if mliid is not None: + mliid = mliid.replace(':', '') + cmd += ' %s' % mliid + + self.__executeCommand(cmd) + + @API + def getDUA(self): + dua = self.getGUA('fd00:7d03') + return dua + + def __addDefaultDomainPrefix(self): + self.configBorderRouter(P_dp=1, P_slaac_preferred=1, P_stable=1, P_on_mesh=1, P_default=1) + + def __setDUA(self, sDua): + """specify the DUA before Thread Starts.""" + if isinstance(sDua, str): + sDua = sDua.decode('utf8') + iid = ipaddress.IPv6Address(sDua).packed[-8:] + cmd = 'dua iid %s' % ''.join('%02x' % ord(b) for b in iid) + return self.__executeCommand(cmd)[-1] == 'Done' + + def __getMlIid(self): + """get the Mesh Local IID.""" + print('%s call __getMlIid' % self.port) + # getULA64() would return the full string representation + mleid = ModuleHelper.GetFullIpv6Address(self.getULA64()).lower() + mliid = mleid[-19:].replace(':', '') + print('mliid: %s' % mliid) + return mliid + + def __setMlIid(self, sMlIid): + """Set the Mesh Local IID before Thread Starts.""" + assert ':' not in sMlIid + cmd = 'mliid %s' % sMlIid + self.__executeCommand(cmd) + + @API + def registerDUA(self, sAddr=''): + self.__setDUA(sAddr) + + @API + def config_next_mlr_status_rsp(self, status_code): + cmd = 'bbr mgmt mlr response %d' % status_code + return self.__executeCommand(cmd)[-1] == 'Done' + + @API + def setMLRtimeout(self, iMsecs): + """Setup BBR MLR Timeout to `iMsecs` seconds.""" + self.__configBbrDataset(MlrTimeout=iMsecs) + + @API + def stopListeningToAddr(self, sAddr): + print('%s call stopListeningToAddr' % self.port) + + # convert to list for single element, for possible extension + # requirements. + if not isinstance(sAddr, list): + sAddr = [sAddr] + + for addr in sAddr: + cmd = 'ipmaddr del ' + addr + try: + self.__executeCommand(cmd) + except CommandError as ex: + if ex.code == OT_ERROR_ALREADY: + pass + else: + raise + + return True + + @API + def registerMulticast(self, sAddr='ff04::1234:777a:1', timeout=MLR_TIMEOUT_MIN): + """subscribe to the given ipv6 address (sAddr) in interface and send MLR.req OTA + + Args: + sAddr : str : Multicast address to be subscribed and notified OTA. + """ + # convert to list for single element, for possible extension + # requirements. + if not isinstance(sAddr, list): + sAddr = [sAddr] + + if self.externalCommissioner is not None: + self.externalCommissioner.MLR(sAddr, timeout) + return True + + # subscribe address one by one + for addr in sAddr: + cmd = 'ipmaddr add ' + str(addr) + + try: + self.__executeCommand(cmd) + except CommandError as ex: + if ex.code == OT_ERROR_ALREADY: + pass + else: + raise + + def deregisterMulticast(self, sAddr): + """ + Unsubscribe to a given IPv6 address. + Only used by External Commissioner. + + Args: + sAddr : str : Multicast address to be unsubscribed. + """ + if not isinstance(sAddr, list): + sAddr = [sAddr] + self.externalCommissioner.MLR(sAddr, 0) + return True + + @API + def migrateNetwork(self, channel=None, net_name=None): + """migrate to another Thread Partition 'net_name' (could be None) + on specified 'channel'. Make sure same Mesh Local IID and DUA + after migration for DUA-TC-06/06b (DEV-1923) + """ + try: + if channel is None: + raise Exception('channel None') + + if channel not in range(11, 27): + raise Exception('channel %d not in [11, 26] Invalid' % channel) + + print('new partition %s on channel %d' % (net_name, channel)) + + mliid = self.__getMlIid() + dua = self.getDUA() + self.reset() + deviceRole = self.deviceRole + self.setDefaultValues() + self.setChannel(channel) + if net_name is not None: + self.setNetworkName(net_name) + self.__setMlIid(mliid) + self.__setDUA(dua) + return self.joinNetwork(deviceRole) + + except Exception as e: + ModuleHelper.WriteIntoDebugLogger('migrateNetwork() Error: ' + str(e)) + + @API + def setParentPrio(self, prio): + cmd = 'parentpriority %u' % prio + print(cmd) + return self.__executeCommand(cmd)[-1] == 'Done' + + @API + def role_transition(self, role): + assert TESTHARNESS_VERSION == TESTHARNESS_1_2 + try: + cmd = 'mode %s' % OpenThreadTHCI._ROLE_MODE_DICT[role] + return self.__executeCommand(cmd)[-1] == 'Done' + except Exception as e: + ModuleHelper.WriteIntoDebugLogger('role_transition() Error: ' + str(e)) + + @API + def setLeaderWeight(self, iWeight=72): + self.__executeCommand('leaderweight %d' % iWeight) + + def __discoverDeviceCapability(self): + """Discover device capability according to version""" + self.DeviceCapability = DevCapb.NotSpecified + + if self.IsBorderRouter: + self.DeviceCapability = DevCapb.C_BBR | DevCapb.C_Host | DevCapb.C_Comm + else: + # Get Thread stack version to distinguish device capability. + thver = self.__executeCommand('thread version')[0] + + if thver in ['1.2', '3']: + self.DeviceCapability = (DevCapb.C_FFD | DevCapb.C_RFD | DevCapb.L_AIO) + elif thver in ['1.1', '2']: + self.DeviceCapability = DevCapb.V1_1 + else: + assert False, thver + @staticmethod def __lstrip0x(s): """strip 0x at the beginning of a hex string if it exists @@ -3047,7 +3504,10 @@ class OpenThread(OpenThreadTHCI, IThci): self.__handle.close() self.__handle = None - def _onReset(self): + def _deviceBeforeReset(self): + pass + + def _deviceAfterReset(self): pass def __socRead(self, size=512): diff --git a/tools/harness-thci/OpenThread_BR.py b/tools/harness-thci/OpenThread_BR.py index 4a7a6f452..f38813502 100644 --- a/tools/harness-thci/OpenThread_BR.py +++ b/tools/harness-thci/OpenThread_BR.py @@ -30,15 +30,15 @@ >> Device : OpenThread_BR THCI >> Class : OpenThread_BR """ -import re - import logging -import serial +import re import sys import time +import serial +from GRLLibs.UtilityModules.ModuleHelper import ModuleHelper from IThci import IThci -from THCI.OpenThread import OpenThreadTHCI, watched +from THCI.OpenThread import OpenThreadTHCI, watched, API RPI_FULL_PROMPT = 'pi@raspberrypi:~$ ' RPI_USERNAME_PROMPT = 'raspberrypi login: ' @@ -60,35 +60,70 @@ assert OTBR_AGENT_SYSLOG_PATTERN.search( class SSHHandle(object): def __init__(self, ip, port, username, password): + self.ip = ip + self.port = int(port) + self.username = username + self.password = password + self.__handle = None + + self.__connect() + + def __connect(self): import paramiko - self.port = '%s:%d' % (ip, port) + + self.close() + self.__handle = paramiko.SSHClient() self.__handle.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - self.__handle.connect(ip, port=int(port), username=username, password=password) + self.__handle.connect(self.ip, port=self.port, username=self.username, password=self.password) def close(self): - self.__handle.close() + if self.__handle is not None: + self.__handle.close() + self.__handle = None def bash(self, cmd, timeout): - stdin, stdout, stderr = self.__handle.exec_command(cmd, timeout=timeout) + from paramiko import SSHException + retry = 3 + for i in range(retry): + try: + stdin, stdout, stderr = self.__handle.exec_command(cmd, timeout=timeout) - sys.stderr.write(stderr.read()) - output = [r.encode('utf8').rstrip('\r\n') for r in stdout.readlines()] - return output + sys.stderr.write(stderr.read()) + output = [r.encode('utf8').rstrip('\r\n') for r in stdout.readlines()] + return output + + except Exception: + if i < retry - 1: + print('SSH connection is lost, try reconnect after 1 second.') + time.sleep(1) + self.__connect() + else: + raise + + 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 -def SerialHandle(object): +class SerialHandle: def __init__(self, port, baudrate): self.port = port self.__handle = serial.Serial(port, baudrate, timeout=0) + self.__lines = [''] + assert len(self.__lines) >= 1, self.__lines + self.log("inputing username ...") self.__bashWriteLine('pi') deadline = time.time() + 20 loginOk = False while time.time() < deadline: - self.sleep(1) + time.sleep(1) lastLine = None while True: @@ -122,13 +157,13 @@ def SerialHandle(object): def close(self): self.__handle.close() - def bash(self, cmd, timeout): + def bash(self, cmd, timeout=10): """ Execute the command in bash. """ self.__bashClearLines() self.__bashWriteLine(cmd) - self.__bashExpect(cmd, endswith=True) + self.__bashExpect(cmd, timeout=timeout, endswith=True) response = [] @@ -136,7 +171,7 @@ def SerialHandle(object): while time.time() < deadline: line = self.__bashReadLine() if line is None: - self.sleep(0.01) + time.sleep(0.01) continue if line == RPI_FULL_PROMPT: @@ -148,14 +183,14 @@ def SerialHandle(object): self.__bashWrite('\x03') raise Exception('%s: failed to find end of response' % self.port) - def __bashExpect(self, expected, timeout=DEFAULT_COMMAND_TIMEOUT, endswith=False): + def __bashExpect(self, expected, timeout=20, endswith=False): print('[%s] Expecting [%r]' % (self.port, expected)) deadline = time.time() + timeout while time.time() < deadline: line = self.__bashReadLine() if line is None: - self.sleep(0.01) + time.sleep(0.01) continue print('[%s] Got line [%r]' % (self.port, line)) @@ -179,7 +214,7 @@ def SerialHandle(object): data = '' while True: piece = self.__handle.read() - data = data + piece + data = data + piece.decode('utf8') if piece: continue @@ -240,6 +275,8 @@ def SerialHandle(object): class OpenThread_BR(OpenThreadTHCI, IThci): DEFAULT_COMMAND_TIMEOUT = 20 + IsBorderRouter = True + def _connect(self): self.log("logining Raspberry Pi ...") self.__cli_output_lines = [] @@ -250,23 +287,194 @@ class OpenThread_BR(OpenThreadTHCI, IThci): self.__handle = SSHHandle(self.telnetIp, self.telnetPort, self.telnetUsername, self.telnetPassword) else: self.__handle = SerialHandle(self.port, 115200) - self.__lines = [''] - assert len(self.__lines) >= 1, self.__lines - self.__truncateSyslog() + self.__afterConnect() def _disconnect(self): if self.__handle: self.__handle.close() self.__handle = None - def _onReset(self): + def _deviceBeforeReset(self): + if self.IsHost: + self.__stopRadvdService() + self.bash('sudo ip -6 addr del 910b::1 dev eth0 || true') + self.bash('sudo ip -6 addr del fd00:7d03:7d03:7d03::1 dev eth0 || true') + + def _deviceAfterReset(self): self.__dumpSyslog() self.__truncateSyslog() + @API + def setupHost(self, setDua=False): + self.IsHost = True + + if not setDua: + cmd = 'sudo ip -6 addr add 910b::1 dev eth0' + else: + cmd = 'sudo ip -6 addr add fd00:7d03:7d03:7d03::1 dev eth0' + self.bash(cmd) + + self.__startRadvdService() + + def _deviceEscapeEscapable(self, string): + """Escape CLI escapable characters in the given string. + + Args: + string (str): UTF-8 input string. + + Returns: + [str]: The modified string with escaped characters. + """ + return '"' + string + '"' + + @watched def bash(self, cmd, timeout=DEFAULT_COMMAND_TIMEOUT): return self.__handle.bash(cmd, timeout=timeout) + def bash_unwatched(self, cmd, timeout=DEFAULT_COMMAND_TIMEOUT): + return self.__handle.bash(cmd, timeout=timeout) + + # Override send_udp + @API + def send_udp(self, interface, dst, port, payload): + if interface == 0: # Thread Interface + super(OpenThread_BR, self).send_udp(interface, dst, port, payload) + return + + if interface == 1: + ifname = 'eth0' + else: + print('invalid interface') + return + + cmd = 'sudo /home/pi/ot-br-posix/script/reference-device/send_udp.py %s %s %s %s' % (ifname, dst, port, + payload) + print(cmd) + self.bash(cmd) + + @API + def ip_neighbors_flush(self): + print('%s call clear_cache' % self.port) + # clear neigh cache on linux + cmd1 = 'sudo ip -6 neigh flush nud all nud failed nud noarp dev eth0' + cmd2 = 'sudo ip -6 neigh list nud all dev eth0 ' \ + '| cut -d " " -f1 ' \ + '| sudo xargs -I{} ip -6 neigh delete {} dev eth0' + cmd = '%s ; %s' % (cmd1, cmd2) + self.bash(cmd) + + @API + def ip_neighbors_add(self, addr, lladdr, nud='noarp'): + print('%s ip_neighbors_add' % self.port) + cmd1 = 'sudo ip -6 neigh delete %s dev eth0' % addr + cmd2 = 'sudo ip -6 neigh add %s dev eth0 lladdr %s nud %s' % (addr, lladdr, nud) + cmd = '%s ; %s' % (cmd1, cmd2) + self.bash(cmd) + + @API + def get_eth_ll(self): + print('%s get_eth_ll' % self.port) + cmd = "ip -6 addr list dev eth0 | grep 'inet6 fe80' | awk '{print $2}'" + ret = self.bash(cmd)[0].split('/')[0] + return ret + + @API + def ping(self, strDestination, ilength=0, hop_limit=5, timeout=5): + """ send ICMPv6 echo request with a given length to a unicast destination + address + + Args: + strDestination: the unicast destination address of ICMPv6 echo request + ilength: the size of ICMPv6 echo request payload + hop_limit: the hop limit + timeout: time before ping() stops + """ + if hop_limit is None: + hop_limit = 5 + + if self.IsHost or self.IsBackboneRouter: + ifName = 'eth0' + else: + ifName = 'wpan0' + + cmd = 'ping -6 -I %s %s -c 1 -s %d -W %d -t %d' % ( + ifName, + strDestination, + int(ilength), + int(timeout), + int(hop_limit), + ) + + self.bash(cmd) + time.sleep(1) + + def multicast_Ping(self, destination, length=20): + """send ICMPv6 echo request with a given length to a multicast destination + address + + Args: + destination: the multicast destination address of ICMPv6 echo request + length: the size of ICMPv6 echo request payload + """ + print('%s call multicast_Ping' % self.port) + print('destination: %s' % destination) + hop_limit = 5 + + if self.IsHost or self.IsBackboneRouter: + ifName = 'eth0' + else: + ifName = 'wpan0' + + cmd = 'ping -6 -I %s %s -c 1 -s %d -t %d' % (ifName, destination, str(length), hop_limit) + + self.bash(cmd) + + @API + def getGUA(self, filterByPrefix=None, eth=False): + """get expected global unicast IPv6 address of Thread device + + note: existing filterByPrefix are string of in lowercase. e.g. + '2001' or '2001:0db8:0001:0000". + + Args: + filterByPrefix: a given expected global IPv6 prefix to be matched + + Returns: + a global IPv6 address + """ + # get global addrs set if multiple + if eth: + return self.__getEthGUA(filterByPrefix=filterByPrefix) + else: + return super(OpenThread_BR, self).getGUA(filterByPrefix=filterByPrefix) + + def __getEthGUA(self, filterByPrefix=None): + globalAddrs = [] + + cmd = 'ip -6 addr list dev eth0 | grep inet6' + output = self.bash(cmd) + for line in output: + # example: inet6 2401:fa00:41:23:274a:1329:3ab9:d953/64 scope global dynamic noprefixroute + line = line.strip().split() + + if len(line) < 4 or line[2] != 'scope': + continue + + if line[3] != 'global': + continue + + addr = line[1].split('/')[0] + addr = ModuleHelper.GetFullIpv6Address(addr).lower() + globalAddrs.append(addr) + + if not filterByPrefix: + return globalAddrs[0] + else: + for fullIp in globalAddrs: + if fullIp.startswith(filterByPrefix): + return fullIp + def _cliReadLine(self): # read commissioning log if it's commissioning if not self.__cli_output_lines: @@ -277,6 +485,12 @@ class OpenThread_BR(OpenThreadTHCI, IThci): return None + @watched + def _deviceGetEtherMac(self): + # Harness wants it in string. Because wireshark filter for eth + # cannot be applies in hex + return self.bash('ip addr list dev eth0 | grep ether')[0].strip().split()[1] + @watched def _onCommissionStart(self): assert self.__syslog_skip_lines is None @@ -288,6 +502,46 @@ class OpenThread_BR(OpenThreadTHCI, IThci): assert self.__syslog_skip_lines is not None self.__syslog_skip_lines = None + def _deviceBeforeThreadStart(self): + self.bash('sudo sysctl net.ipv6.conf.eth0.accept_ra=2') + + @watched + def __startRadvdService(self): + assert self.IsHost, "radvd service runs on Host only" + + self.bash("""sudo sh -c "cat >/etc/radvd.conf <