• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
2#
3#  Copyright (c) 2021, The OpenThread Authors.
4#  All rights reserved.
5#
6#  Redistribution and use in source and binary forms, with or without
7#  modification, are permitted provided that the following conditions are met:
8#  1. Redistributions of source code must retain the above copyright
9#     notice, this list of conditions and the following disclaimer.
10#  2. Redistributions in binary form must reproduce the above copyright
11#     notice, this list of conditions and the following disclaimer in the
12#     documentation and/or other materials provided with the distribution.
13#  3. Neither the name of the copyright holder nor the
14#     names of its contributors may be used to endorse or promote products
15#     derived from this software without specific prior written permission.
16#
17#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
21#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
22#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
23#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27#  POSSIBILITY OF SUCH DAMAGE.
28#
29
30import sys
31import os
32import time
33import re
34import random
35import string
36import subprocess
37import pexpect
38import pexpect.popen_spawn
39import signal
40import inspect
41import weakref
42
43# ----------------------------------------------------------------------------------------------------------------------
44# Constants
45
46JOIN_TYPE_ROUTER = 'router'
47JOIN_TYPE_END_DEVICE = 'ed'
48JOIN_TYPE_SLEEPY_END_DEVICE = 'sed'
49
50# ----------------------------------------------------------------------------------------------------------------------
51
52
53def _log(text, new_line=True, flush=True):
54    sys.stdout.write(text)
55    if new_line:
56        sys.stdout.write('\n')
57    if flush:
58        sys.stdout.flush()
59
60
61# ----------------------------------------------------------------------------------------------------------------------
62# CliError class
63
64
65class CliError(Exception):
66
67    def __init__(self, error_code, message):
68        self._error_code = error_code
69        self._message = message
70
71    @property
72    def error_code(self):
73        return self._error_code
74
75    @property
76    def message(self):
77        return self._message
78
79
80# ----------------------------------------------------------------------------------------------------------------------
81# Node class
82
83
84class Node(object):
85    """ An OT CLI instance """
86
87    # defines the default verbosity setting (can be changed per `Node`)
88    _VERBOSE = os.getenv('TORANJ_VERBOSE', 'no').lower() in ['true', '1', 't', 'y', 'yes', 'on']
89
90    _SPEED_UP_FACTOR = 1  # defines the default time speed up factor
91
92    # Determine whether to save logs in a file.
93    _SAVE_LOGS = True
94
95    # name of  log file (if _SAVE_LOGS is `True`)
96    _LOG_FNAME = 'ot-logs'
97
98    _OT_BUILDDIR = os.getenv('top_builddir', '../../..')
99
100    _OT_CLI_FTD = '%s/examples/apps/cli/ot-cli-ftd' % _OT_BUILDDIR
101
102    _WAIT_TIME = 10
103
104    _START_INDEX = 1
105    _cur_index = _START_INDEX
106
107    _all_nodes = weakref.WeakSet()
108
109    def __init__(self, verbose=_VERBOSE):
110        """Creates a new `Node` instance"""
111
112        index = Node._cur_index
113        Node._cur_index += 1
114
115        self._index = index
116        self._verbose = verbose
117
118        if Node._SAVE_LOGS:
119            self._log_file = open(self._LOG_FNAME + str(index) + '.log', 'wb')
120        else:
121            self._log_file = None
122
123        cmd = f'{self._OT_CLI_FTD} --time-speed={self._SPEED_UP_FACTOR} {self._index}'
124
125        if self._verbose:
126            _log(f'$ Node{index}.__init__() cmd: `{cmd}`')
127
128        self._cli_process = pexpect.popen_spawn.PopenSpawn(cmd, logfile=self._log_file)
129        Node._all_nodes.add(self)
130
131    def __del__(self):
132        self._finalize()
133
134    def __repr__(self):
135        return f'Node(index={self._index})'
136
137    @property
138    def index(self):
139        return self._index
140
141    # ------------------------------------------------------------------------------------------------------------------
142    # Executing a `cli` command
143
144    def cli(self, *args):
145        """ Issues a CLI command on the given node and returns the resulting output.
146
147            The returned result is a list of strings (with `\r\n` removed) as outputted by the CLI.
148            If executing the command fails, `CliError` is raised with error code and error message.
149        """
150
151        cmd = ' '.join([f'{arg}' for arg in args if arg is not None]).strip()
152
153        if self._verbose:
154            _log(f'$ Node{self._index}.cli(\'{cmd}\')', new_line=False)
155
156        self._cli_process.send(cmd + '\n')
157        index = self._cli_process.expect(['(.*)Done\r\n', '.*Error (\d+):(.*)\r\n'])
158
159        if index == 0:
160            result = [
161                line for line in self._cli_process.match.group(1).decode().splitlines()
162                if not self._is_ot_logg_line(line) if not line.strip().endswith(cmd)
163            ]
164
165            if self._verbose:
166                if len(result) > 1:
167                    _log(':')
168                    for line in result:
169                        _log('     ' + line)
170                elif len(result) == 1:
171                    _log(f' -> {result[0]}')
172                else:
173                    _log('')
174
175            return result
176        else:
177            match = self._cli_process.match
178            e = CliError(int(match.group(1).decode()), match.group(2).decode().strip())
179            if self._verbose:
180                _log(f': Error {e.message} ({e.error_code})')
181            raise e
182
183    def _is_ot_logg_line(self, line):
184        return any(level in line for level in [' [D] ', ' [I] ', ' [N] ', ' [W] ', ' [C] ', ' [-] '])
185
186    def _cli_no_output(self, cmd, *args):
187        outputs = self.cli(cmd, *args)
188        verify(len(outputs) == 0)
189
190    def _cli_single_output(self, cmd, expected_outputs=None):
191        outputs = self.cli(cmd)
192        verify(len(outputs) == 1)
193        verify((expected_outputs is None) or (outputs[0] in expected_outputs))
194        return outputs[0]
195
196    def _finalize(self):
197        if self._cli_process.proc.poll() is None:
198            if self._verbose:
199                _log(f'$ Node{self.index} terminating')
200            self._cli_process.send('exit\n')
201            self._cli_process.wait()
202
203    # ------------------------------------------------------------------------------------------------------------------
204    # cli commands
205
206    def get_state(self):
207        return self._cli_single_output('state', ['detached', 'child', 'router', 'leader', 'disabled'])
208
209    def get_channel(self):
210        return self._cli_single_output('channel')
211
212    def set_channel(self, channel):
213        self._cli_no_output('channel', channel)
214
215    def get_ext_addr(self):
216        return self._cli_single_output('extaddr')
217
218    def set_ext_addr(self, ext_addr):
219        self._cli_no_output('extaddr', ext_addr)
220
221    def get_ext_panid(self):
222        return self._cli_single_output('extpanid')
223
224    def set_ext_panid(self, ext_panid):
225        self._cli_no_output('extpanid', ext_panid)
226
227    def get_mode(self):
228        return self._cli_single_output('mode')
229
230    def set_mode(self, mode):
231        self._cli_no_output('mode', mode)
232
233    def get_network_key(self):
234        return self._cli_single_output('networkkey')
235
236    def set_network_key(self, networkkey):
237        self._cli_no_output('networkkey', networkkey)
238
239    def get_network_name(self):
240        return self._cli_single_output('networkname')
241
242    def set_network_name(self, network_name):
243        self._cli_no_output('networkname', network_name)
244
245    def get_panid(self):
246        return self._cli_single_output('panid')
247
248    def set_panid(self, panid):
249        self._cli_no_output('panid', panid)
250
251    def get_router_upgrade_threshold(self):
252        return self._cli_single_output('routerupgradethreshold')
253
254    def set_router_upgrade_threshold(self, threshold):
255        self._cli_no_output('routerupgradethreshold', threshold)
256
257    def get_router_selection_jitter(self):
258        return self._cli_single_output('routerselectionjitter')
259
260    def set_router_selection_jitter(self, jitter):
261        self._cli_no_output('routerselectionjitter', jitter)
262
263    def interface_up(self):
264        self._cli_no_output('ifconfig up')
265
266    def interface_down(self):
267        self._cli_no_output('ifconfig down')
268
269    def get_interface_state(self):
270        return self._cli_single_output('ifconfig')
271
272    def thread_start(self):
273        self._cli_no_output('thread start')
274
275    def thread_stop(self):
276        self._cli_no_output('thread stop')
277
278    def get_ip_addrs(self):
279        return self.cli('ipaddr')
280
281    def get_mleid_ip_addr(self):
282        return self._cli_single_output('ipaddr mleid')
283
284    def get_linklocal_ip_addr(self):
285        return self._cli_single_output('ipaddr linklocal')
286
287    def get_rloc_ip_addr(self):
288        return self._cli_single_output('ipaddr rloc')
289
290    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
291    # SRP client
292
293    def srp_client_start(self, server_address, server_port):
294        self._cli_no_output('srp client start', server_address, server_port)
295
296    def srp_client_stop(self):
297        self._cli_no_output('srp client stop')
298
299    def srp_client_get_state(self):
300        return self._cli_single_output('srp client state', ['Enabled', 'Disabled'])
301
302    def srp_client_get_auto_start_mode(self):
303        return self._cli_single_output('srp client autostart', ['Enabled', 'Disabled'])
304
305    def srp_client_enable_auto_start_mode(self):
306        self._cli_no_output('srp client autostart enable')
307
308    def srp_client_disable_auto_start_mode(self):
309        self._cli_no_output('srp client autostart disable')
310
311    def srp_client_get_server_address(self):
312        return self._cli_single_output('srp client server address')
313
314    def srp_client_get_server_port(self):
315        return self._cli_single_output('srp client server port')
316
317    def srp_client_get_host_state(self):
318        return self._cli_single_output('srp client host state')
319
320    def srp_client_set_host_name(self, name):
321        self._cli_no_output('srp client host name', name)
322
323    def srp_client_get_host_name(self):
324        return self._cli_single_output('srp client host name')
325
326    def srp_client_remove_host(self, remove_key=False, send_unreg_to_server=False):
327        self._cli_no_output('srp client host remove', int(remove_key), int(send_unreg_to_server))
328
329    def srp_client_clear_host(self):
330        self._cli_no_output('srp client host clear')
331
332    def srp_client_enable_auto_host_address(self):
333        self._cli_no_output('srp client host address auto')
334
335    def srp_client_set_host_address(self, *addrs):
336        self._cli_no_output('srp client host address', *addrs)
337
338    def srp_client_get_host_address(self):
339        return self.cli('srp client host address')
340
341    def srp_client_add_service(self, instance_name, service_name, port, priority=0, weight=0, txt_entries=[]):
342        txt_record = "".join(self._encode_txt_entry(entry) for entry in txt_entries)
343        self._cli_no_output('srp client service add', instance_name, service_name, port, priority, weight, txt_record)
344
345    def srp_client_remove_service(self, instance_name, service_name):
346        self._cli_no_output('srp client service remove', instance_name, service_name)
347
348    def srp_client_clear_service(self, instance_name, service_name):
349        self._cli_no_output('srp client service clear', instance_name, service_name)
350
351    def srp_client_get_services(self):
352        outputs = self.cli('srp client service')
353        return [self._parse_srp_client_service(line) for line in outputs]
354
355    def _encode_txt_entry(self, entry):
356        """Encodes the TXT entry to the DNS-SD TXT record format as a HEX string.
357
358           Example usage:
359           self._encode_txt_entries(['abc'])     -> '03616263'
360           self._encode_txt_entries(['def='])    -> '046465663d'
361           self._encode_txt_entries(['xyz=XYZ']) -> '0778797a3d58595a'
362        """
363        return '{:02x}'.format(len(entry)) + "".join("{:02x}".format(ord(c)) for c in entry)
364
365    def _parse_srp_client_service(self, line):
366        """Parse one line of srp service list into a dictionary which
367           maps string keys to string values.
368
369           Example output for input
370           'instance:\"%s\", name:\"%s\", state:%s, port:%d, priority:%d, weight:%d"'
371           {
372               'instance': 'my-service',
373               'name': '_ipps._udp',
374               'state': 'ToAdd',
375               'port': '12345',
376               'priority': '0',
377               'weight': '0'
378           }
379
380           Note that value of 'port', 'priority' and 'weight' are represented
381           as strings but not integers.
382        """
383        key_values = [word.strip().split(':') for word in line.split(', ')]
384        return {key_value[0].strip(): key_value[1].strip('"') for key_value in key_values}
385
386    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
387    # SRP server
388
389    def srp_server_get_state(self):
390        return self._cli_single_output('srp server state', ['disabled', 'running', 'stopped'])
391
392    def srp_server_get_addr_mode(self):
393        return self._cli_single_output('srp server addrmode', ['unicast', 'anycast'])
394
395    def srp_server_set_addr_mode(self, mode):
396        self._cli_no_output('srp server addrmode', mode)
397
398    def srp_server_get_anycast_seq_num(self):
399        return self._cli_single_output('srp server seqnum')
400
401    def srp_server_set_anycast_seq_num(self, seqnum):
402        self._cli_no_output('srp server seqnum', seqnum)
403
404    def srp_server_enable(self):
405        self._cli_no_output('srp server enable')
406
407    def srp_server_disable(self):
408        self._cli_no_output('srp server disable')
409
410    def srp_server_set_lease(self, min_lease, max_lease, min_key_lease, max_key_lease):
411        self._cli_no_output('srp server lease', min_lease, max_lease, min_key_lease, max_key_lease)
412
413    def srp_server_get_hosts(self):
414        """Returns the host list on the SRP server as a list of property
415           dictionary.
416
417           Example output:
418           [{
419               'fullname': 'my-host.default.service.arpa.',
420               'name': 'my-host',
421               'deleted': 'false',
422               'addresses': ['2001::1', '2001::2']
423           }]
424        """
425        outputs = self.cli('srp server host')
426        host_list = []
427        while outputs:
428            host = {}
429            host['fullname'] = outputs.pop(0).strip()
430            host['name'] = host['fullname'].split('.')[0]
431            host['deleted'] = outputs.pop(0).strip().split(':')[1].strip()
432            if host['deleted'] == 'true':
433                host_list.append(host)
434                continue
435            addresses = outputs.pop(0).strip().split('[')[1].strip(' ]').split(',')
436            map(str.strip, addresses)
437            host['addresses'] = [addr for addr in addresses if addr]
438            host_list.append(host)
439        return host_list
440
441    def srp_server_get_host(self, host_name):
442        """Returns host on the SRP server that matches given host name.
443
444           Example usage:
445           self.srp_server_get_host("my-host")
446        """
447        for host in self.srp_server_get_hosts():
448            if host_name == host['name']:
449                return host
450
451    def srp_server_get_services(self):
452        """Returns the service list on the SRP server as a list of property
453           dictionary.
454
455           Example output:
456           [{
457               'fullname': 'my-service._ipps._tcp.default.service.arpa.',
458               'instance': 'my-service',
459               'name': '_ipps._tcp',
460               'deleted': 'false',
461               'port': '12345',
462               'priority': '0',
463               'weight': '0',
464               'ttl': '7200',
465               'TXT': ['abc=010203'],
466               'host_fullname': 'my-host.default.service.arpa.',
467               'host': 'my-host',
468               'addresses': ['2001::1', '2001::2']
469           }]
470
471           Note that the TXT data is output as a HEX string.
472        """
473        outputs = self.cli('srp server service')
474        service_list = []
475        while outputs:
476            service = {}
477            service['fullname'] = outputs.pop(0).strip()
478            name_labels = service['fullname'].split('.')
479            service['instance'] = name_labels[0]
480            service['name'] = '.'.join(name_labels[1:3])
481            service['deleted'] = outputs.pop(0).strip().split(':')[1].strip()
482            if service['deleted'] == 'true':
483                service_list.append(service)
484                continue
485            # 'subtypes', port', 'priority', 'weight', 'ttl'
486            for i in range(0, 5):
487                key_value = outputs.pop(0).strip().split(':')
488                service[key_value[0].strip()] = key_value[1].strip()
489            txt_entries = outputs.pop(0).strip().split('[')[1].strip(' ]').split(',')
490            txt_entries = map(str.strip, txt_entries)
491            service['TXT'] = [txt for txt in txt_entries if txt]
492            service['host_fullname'] = outputs.pop(0).strip().split(':')[1].strip()
493            service['host'] = service['host_fullname'].split('.')[0]
494            addresses = outputs.pop(0).strip().split('[')[1].strip(' ]').split(',')
495            addresses = map(str.strip, addresses)
496            service['addresses'] = [addr for addr in addresses if addr]
497            service_list.append(service)
498        return service_list
499
500    def srp_server_get_service(self, instance_name, service_name):
501        """Returns service on the SRP server that matches given instance
502           name and service name.
503
504           Example usage:
505           self.srp_server_get_service("my-service", "_ipps._tcp")
506        """
507        for service in self.srp_server_get_services():
508            if (instance_name == service['instance'] and service_name == service['name']):
509                return service
510
511    # ------------------------------------------------------------------------------------------------------------------
512    # Helper methods
513
514    def form(self, network_name=None, network_key=None, channel=None, panid=0x1234, xpanid=None):
515        if network_name is not None:
516            self.set_network_name(network_name)
517        if network_key is not None:
518            self.set_network_key(network_key)
519        if channel is not None:
520            self.set_channel(channel)
521        if xpanid is not None:
522            self.set_ext_panid(xpanid)
523        self.set_panid(panid)
524        self.interface_up()
525        self.thread_start()
526        verify_within(_check_node_is_leader, self._WAIT_TIME, arg=self)
527
528    def join(self, node, type=JOIN_TYPE_ROUTER):
529        self.set_network_name(node.get_network_name())
530        self.set_network_key(node.get_network_key())
531        self.set_channel(node.get_channel())
532        self.set_panid(node.get_panid())
533        if type == JOIN_TYPE_END_DEVICE:
534            self.set_mode('rn')
535        elif type == JOIN_TYPE_SLEEPY_END_DEVICE:
536            self.set_mode('-')
537        else:
538            self.set_mode('rdn')
539            self.set_router_selection_jitter(1)
540        self.interface_up()
541        self.thread_start()
542        if type == JOIN_TYPE_ROUTER:
543            verify_within(_check_node_is_router, self._WAIT_TIME, arg=self)
544        else:
545            verify_within(_check_node_is_child, self._WAIT_TIME, arg=self)
546
547    def allowlist_node(self, node):
548        """Adds a given node to the allowlist of `self` and enables allowlisting on `self`"""
549        self._cli_no_output('macfilter addr add', node.get_ext_addr())
550        self._cli_no_output('macfilter addr allowlist')
551
552    def un_allowlist_node(self, node):
553        """Removes a given node (of node `Node) from the allowlist"""
554        self._cli_no_output('macfilter addr remove', node.get_ext_addr())
555
556    # ------------------------------------------------------------------------------------------------------------------
557    # class methods
558
559    @classmethod
560    def finalize_all_nodes(cls):
561        """Finalizes all previously created `Node` instances (stops the CLI process)"""
562        for node in Node._all_nodes:
563            node._finalize()
564
565    @classmethod
566    def set_time_speedup_factor(cls, factor):
567        """Sets up the time speed up factor - should be set before creating any `Node` objects"""
568        if len(Node._all_nodes) != 0:
569            raise Node._NodeError('set_time_speedup_factor() cannot be called after creating a `Node`')
570        Node._SPEED_UP_FACTOR = factor
571
572
573def _check_node_is_leader(node):
574    verify(node.get_state() == 'leader')
575
576
577def _check_node_is_router(node):
578    verify(node.get_state() == 'router')
579
580
581def _check_node_is_child(node):
582    verify(node.get_state() == 'child')
583
584
585# ----------------------------------------------------------------------------------------------------------------------
586
587
588class VerifyError(Exception):
589    pass
590
591
592_is_in_verify_within = False
593
594
595def verify(condition):
596    """Verifies that a `condition` is true, otherwise raises a VerifyError"""
597    global _is_in_verify_within
598    if not condition:
599        calling_frame = inspect.currentframe().f_back
600        error_message = 'verify() failed at line {} in "{}"'.format(calling_frame.f_lineno,
601                                                                    calling_frame.f_code.co_filename)
602        if not _is_in_verify_within:
603            print(error_message)
604        raise VerifyError(error_message)
605
606
607def verify_within(condition_checker_func, wait_time, arg=None, delay_time=0.1):
608    """Verifies that a given function `condition_checker_func` passes successfully within a given wait timeout.
609       `wait_time` is maximum time waiting for condition_checker to pass (in seconds).
610       `arg` is optional parameter and if it s not None, will be passed to `condition_checker_func()`
611       `delay_time` specifies a delay interval added between failed attempts (in seconds).
612    """
613    global _is_in_verify_within
614    start_time = time.time()
615    old_is_in_verify_within = _is_in_verify_within
616    _is_in_verify_within = True
617    while True:
618        try:
619            if arg is None:
620                condition_checker_func()
621            else:
622                condition_checker_func(arg)
623        except VerifyError as e:
624            if time.time() - start_time > wait_time:
625                print('Took too long to pass the condition ({}>{} sec)'.format(time.time() - start_time, wait_time))
626                print(e.message)
627                raise e
628        except BaseException:
629            raise
630        else:
631            break
632        if delay_time != 0:
633            time.sleep(delay_time)
634    _is_in_verify_within = old_is_in_verify_within
635