• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python
2#
3# Copyright (c) 2020, 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>> Thread Host Controller Interface
30>> Device : OpenThread_BR THCI
31>> Class : OpenThread_BR
32"""
33import logging
34import re
35import sys
36import time
37import ipaddress
38
39import serial
40from IThci import IThci
41from THCI.OpenThread import OpenThreadTHCI, watched, API
42
43RPI_FULL_PROMPT = 'pi@raspberrypi:~$ '
44RPI_USERNAME_PROMPT = 'raspberrypi login: '
45RPI_PASSWORD_PROMPT = 'Password: '
46"""regex: used to split lines"""
47LINESEPX = re.compile(r'\r\n|\n')
48
49LOGX = re.compile(r'.*Under-voltage detected!')
50"""regex: used to filter logging"""
51
52assert LOGX.match('[57522.618196] Under-voltage detected! (0x00050005)')
53
54OTBR_AGENT_SYSLOG_PATTERN = re.compile(r'raspberrypi otbr-agent\[\d+\]: (.*)')
55assert OTBR_AGENT_SYSLOG_PATTERN.search(
56    'Jun 23 05:21:22 raspberrypi otbr-agent[323]: =========[[THCI] direction=send | type=JOIN_FIN.req | len=039]==========]'
57).group(1) == '=========[[THCI] direction=send | type=JOIN_FIN.req | len=039]==========]'
58
59logging.getLogger('paramiko').setLevel(logging.WARNING)
60
61
62class SSHHandle(object):
63
64    def __init__(self, ip, port, username, password):
65        self.ip = ip
66        self.port = int(port)
67        self.username = username
68        self.password = password
69        self.__handle = None
70
71        self.__connect()
72
73    def __connect(self):
74        import paramiko
75
76        self.close()
77
78        self.__handle = paramiko.SSHClient()
79        self.__handle.set_missing_host_key_policy(paramiko.AutoAddPolicy())
80        try:
81            self.__handle.connect(self.ip, port=self.port, username=self.username, password=self.password)
82        except paramiko.ssh_exception.AuthenticationException:
83            if not self.password:
84                self.__handle.get_transport().auth_none(self.username)
85            else:
86                raise
87
88    def close(self):
89        if self.__handle is not None:
90            self.__handle.close()
91            self.__handle = None
92
93    def bash(self, cmd, timeout):
94        from paramiko import SSHException
95        retry = 3
96        for i in range(retry):
97            try:
98                stdin, stdout, stderr = self.__handle.exec_command(cmd, timeout=timeout)
99
100                sys.stderr.write(stderr.read())
101                output = [r.encode('utf8').rstrip('\r\n') for r in stdout.readlines()]
102                return output
103
104            except Exception:
105                if i < retry - 1:
106                    print('SSH connection is lost, try reconnect after 1 second.')
107                    time.sleep(1)
108                    self.__connect()
109                else:
110                    raise
111
112    def log(self, fmt, *args):
113        try:
114            msg = fmt % args
115            print('%s - %s - %s' % (self.port, time.strftime('%b %d %H:%M:%S'), msg))
116        except Exception:
117            pass
118
119
120class SerialHandle:
121
122    def __init__(self, port, baudrate):
123        self.port = port
124        self.__handle = serial.Serial(port, baudrate, timeout=0)
125
126        self.__lines = ['']
127        assert len(self.__lines) >= 1, self.__lines
128
129        self.log("inputing username ...")
130        self.__bashWriteLine('pi')
131        deadline = time.time() + 20
132        loginOk = False
133        while time.time() < deadline:
134            time.sleep(1)
135
136            lastLine = None
137            while True:
138                line = self.__bashReadLine(timeout=1)
139
140                if not line:
141                    break
142
143                lastLine = line
144
145            if lastLine == RPI_FULL_PROMPT:
146                self.log("prompt found, login success!")
147                loginOk = True
148                break
149
150            if lastLine == RPI_PASSWORD_PROMPT:
151                self.log("inputing password ...")
152                self.__bashWriteLine('raspberry')
153            elif lastLine == RPI_USERNAME_PROMPT:
154                self.log("inputing username ...")
155                self.__bashWriteLine('pi')
156            elif not lastLine:
157                self.log("inputing username ...")
158                self.__bashWriteLine('pi')
159
160        if not loginOk:
161            raise Exception('login fail')
162
163        self.bash('stty cols 256')
164
165    def log(self, fmt, *args):
166        try:
167            msg = fmt % args
168            print('%s - %s - %s' % (self.port, time.strftime('%b %d %H:%M:%S'), msg))
169        except Exception:
170            pass
171
172    def close(self):
173        self.__handle.close()
174
175    def bash(self, cmd, timeout=10):
176        """
177        Execute the command in bash.
178        """
179        self.__bashClearLines()
180        self.__bashWriteLine(cmd)
181        self.__bashExpect(cmd, timeout=timeout, endswith=True)
182
183        response = []
184
185        deadline = time.time() + timeout
186        while time.time() < deadline:
187            line = self.__bashReadLine()
188            if line is None:
189                time.sleep(0.01)
190                continue
191
192            if line == RPI_FULL_PROMPT:
193                # return response lines without prompt
194                return response
195
196            response.append(line)
197
198        self.__bashWrite('\x03')
199        raise Exception('%s: failed to find end of response' % self.port)
200
201    def __bashExpect(self, expected, timeout=20, endswith=False):
202        self.log('Expecting [%r]' % (expected))
203
204        deadline = time.time() + timeout
205        while time.time() < deadline:
206            line = self.__bashReadLine()
207            if line is None:
208                time.sleep(0.01)
209                continue
210
211            print('[%s] Got line [%r]' % (self.port, line))
212
213            if endswith:
214                matched = line.endswith(expected)
215            else:
216                matched = line == expected
217
218            if matched:
219                print('[%s] Expected [%r]' % (self.port, expected))
220                return
221
222        # failed to find the expected string
223        # send Ctrl+C to terminal
224        self.__bashWrite('\x03')
225        raise Exception('failed to find expected string[%s]' % expected)
226
227    def __bashRead(self, timeout=1):
228        deadline = time.time() + timeout
229        data = ''
230        while True:
231            piece = self.__handle.read()
232            data = data + piece.decode('utf8')
233            if piece:
234                continue
235
236            if data or time.time() >= deadline:
237                break
238
239        if data:
240            self.log('>>> %r', data)
241
242        return data
243
244    def __bashReadLine(self, timeout=1):
245        line = self.__bashGetNextLine()
246        if line is not None:
247            return line
248
249        assert len(self.__lines) == 1, self.__lines
250        tail = self.__lines.pop()
251
252        try:
253            tail += self.__bashRead(timeout=timeout)
254            tail = tail.replace(RPI_FULL_PROMPT, RPI_FULL_PROMPT + '\r\n')
255            tail = tail.replace(RPI_USERNAME_PROMPT, RPI_USERNAME_PROMPT + '\r\n')
256            tail = tail.replace(RPI_PASSWORD_PROMPT, RPI_PASSWORD_PROMPT + '\r\n')
257        finally:
258            self.__lines += [l.rstrip('\r') for l in LINESEPX.split(tail)]
259            assert len(self.__lines) >= 1, self.__lines
260
261        return self.__bashGetNextLine()
262
263    def __bashGetNextLine(self):
264        assert len(self.__lines) >= 1, self.__lines
265        while len(self.__lines) > 1:
266            line = self.__lines.pop(0)
267            assert len(self.__lines) >= 1, self.__lines
268            if LOGX.match(line):
269                logging.info('LOG: %s', line)
270                continue
271            else:
272                return line
273        assert len(self.__lines) >= 1, self.__lines
274        return None
275
276    def __bashWrite(self, data):
277        self.__handle.write(data)
278        self.log("<<< %r", data)
279
280    def __bashClearLines(self):
281        assert len(self.__lines) >= 1, self.__lines
282        while self.__bashReadLine(timeout=0) is not None:
283            pass
284        assert len(self.__lines) >= 1, self.__lines
285
286    def __bashWriteLine(self, line):
287        self.__bashWrite(line + '\n')
288
289
290class OpenThread_BR(OpenThreadTHCI, IThci):
291    DEFAULT_COMMAND_TIMEOUT = 20
292
293    IsBorderRouter = True
294    __is_root = False
295
296    def _connect(self):
297        self.log("logging in to Raspberry Pi ...")
298        self.__cli_output_lines = []
299        self.__syslog_skip_lines = None
300        self.__syslog_last_read_ts = 0
301
302        if self.connectType == 'ip':
303            self.__handle = SSHHandle(self.telnetIp, self.telnetPort, self.telnetUsername, self.telnetPassword)
304            self.__is_root = self.telnetUsername == 'root'
305        else:
306            self.__handle = SerialHandle(self.port, 115200)
307
308    def _disconnect(self):
309        if self.__handle:
310            self.__handle.close()
311            self.__handle = None
312
313    def _deviceBeforeReset(self):
314        if self.isPowerDown:
315            self.log('Powering up the device')
316            self.powerUp()
317        if self.IsHost:
318            self.__stopRadvdService()
319            self.bash('ip -6 addr del 910b::1 dev %s || true' % self.backboneNetif)
320            self.bash('ip -6 addr del fd00:7d03:7d03:7d03::1 dev %s || true' % self.backboneNetif)
321
322        self.stopListeningToAddrAll()
323
324    def _deviceAfterReset(self):
325        self.__dumpSyslog()
326        self.__truncateSyslog()
327        self.__enableAcceptRa()
328        if not self.IsHost:
329            self.__restartAgentService()
330            time.sleep(2)
331
332    def __enableAcceptRa(self):
333        self.bash('sysctl net.ipv6.conf.%s.accept_ra=2' % self.backboneNetif)
334
335    def _beforeRegisterMulticast(self, sAddr='ff04::1234:777a:1', timeout=300):
336        """subscribe to the given ipv6 address (sAddr) in interface and send MLR.req OTA
337
338        Args:
339            sAddr   : str : Multicast address to be subscribed and notified OTA.
340        """
341
342        if self.externalCommissioner is not None:
343            self.externalCommissioner.MLR([sAddr], timeout)
344            return True
345
346        cmd = 'nohup ~/repo/openthread/tests/scripts/thread-cert/mcast6.py wpan0 %s' % sAddr
347        cmd = cmd + ' > /dev/null 2>&1 &'
348        self.bash(cmd)
349
350    @API
351    def setupHost(self, setDp=False, setDua=False):
352        self.IsHost = True
353
354        self.bash('ip -6 addr add 910b::1 dev %s' % self.backboneNetif)
355
356        if setDua:
357            self.bash('ip -6 addr add fd00:7d03:7d03:7d03::1 dev %s' % self.backboneNetif)
358
359        self.__startRadvdService(setDp)
360
361    def _deviceEscapeEscapable(self, string):
362        """Escape CLI escapable characters in the given string.
363
364        Args:
365            string (str): UTF-8 input string.
366
367        Returns:
368            [str]: The modified string with escaped characters.
369        """
370        return '"' + string + '"'
371
372    @watched
373    def bash(self, cmd, timeout=DEFAULT_COMMAND_TIMEOUT, sudo=True):
374        return self.bash_unwatched(cmd, timeout=timeout, sudo=sudo)
375
376    def bash_unwatched(self, cmd, timeout=DEFAULT_COMMAND_TIMEOUT, sudo=True):
377        if sudo and not self.__is_root:
378            cmd = 'sudo ' + cmd
379
380        return self.__handle.bash(cmd, timeout=timeout)
381
382    # Override send_udp
383    @API
384    def send_udp(self, interface, dst, port, payload):
385        if interface == 0:  # Thread Interface
386            super(OpenThread_BR, self).send_udp(interface, dst, port, payload)
387            return
388
389        if interface == 1:
390            ifname = self.backboneNetif
391        else:
392            raise AssertionError('Invalid interface set to send UDP: {} '
393                                 'Available interface options: 0 - Thread; 1 - Ethernet'.format(interface))
394        cmd = '/home/pi/reference-device/send_udp.py %s %s %s %s' % (ifname, dst, port, payload)
395        self.bash(cmd)
396
397    @API
398    def mldv2_query(self):
399        ifname = self.backboneNetif
400        dst = 'ff02::1'
401
402        cmd = '/home/pi/reference-device/send_mld_query.py %s %s' % (ifname, dst)
403        self.bash(cmd)
404
405    @API
406    def ip_neighbors_flush(self):
407        # clear neigh cache on linux
408        cmd1 = 'sudo ip -6 neigh flush nud all nud failed nud noarp dev %s' % self.backboneNetif
409        cmd2 = ('sudo ip -6 neigh list nud all dev %s '
410                '| cut -d " " -f1 '
411                '| sudo xargs -I{} ip -6 neigh delete {} dev %s') % (self.backboneNetif, self.backboneNetif)
412        cmd = '%s ; %s' % (cmd1, cmd2)
413        self.bash(cmd, sudo=False)
414
415    @API
416    def ip_neighbors_add(self, addr, lladdr, nud='noarp'):
417        cmd1 = 'sudo ip -6 neigh delete %s dev %s' % (addr, self.backboneNetif)
418        cmd2 = 'sudo ip -6 neigh add %s dev %s lladdr %s nud %s' % (addr, self.backboneNetif, lladdr, nud)
419        cmd = '%s ; %s' % (cmd1, cmd2)
420        self.bash(cmd, sudo=False)
421
422    @API
423    def get_eth_ll(self):
424        cmd = "ip -6 addr list dev %s | grep 'inet6 fe80' | awk '{print $2}'" % self.backboneNetif
425        ret = self.bash(cmd)[0].split('/')[0]
426        return ret
427
428    @API
429    def ping(self, strDestination, ilength=0, hop_limit=5, timeout=5):
430        """ send ICMPv6 echo request with a given length to a unicast destination
431            address
432
433        Args:
434            strDestination: the unicast destination address of ICMPv6 echo request
435            ilength: the size of ICMPv6 echo request payload
436            hop_limit: the hop limit
437            timeout: time before ping() stops
438        """
439        if hop_limit is None:
440            hop_limit = 5
441
442        if self.IsHost or self.IsBorderRouter:
443            ifName = self.backboneNetif
444        else:
445            ifName = 'wpan0'
446
447        cmd = 'ping -6 -I %s %s -c 1 -s %d -W %d -t %d' % (
448            ifName,
449            strDestination,
450            int(ilength),
451            int(timeout),
452            int(hop_limit),
453        )
454
455        self.bash(cmd, sudo=False)
456        time.sleep(timeout)
457
458    def multicast_Ping(self, destination, length=20):
459        """send ICMPv6 echo request with a given length to a multicast destination
460           address
461
462        Args:
463            destination: the multicast destination address of ICMPv6 echo request
464            length: the size of ICMPv6 echo request payload
465        """
466        hop_limit = 5
467
468        if self.IsHost or self.IsBorderRouter:
469            ifName = self.backboneNetif
470        else:
471            ifName = 'wpan0'
472
473        cmd = 'ping -6 -I %s %s -c 1 -s %d -t %d' % (ifName, destination, str(length), hop_limit)
474
475        self.bash(cmd, sudo=False)
476
477    @API
478    def getGUA(self, filterByPrefix=None, eth=False):
479        """get expected global unicast IPv6 address of Thread device
480
481        note: existing filterByPrefix are string of in lowercase. e.g.
482        '2001' or '2001:0db8:0001:0000".
483
484        Args:
485            filterByPrefix: a given expected global IPv6 prefix to be matched
486
487        Returns:
488            a global IPv6 address
489        """
490        # get global addrs set if multiple
491        if eth:
492            return self.__getEthGUA(filterByPrefix=filterByPrefix)
493        else:
494            return super(OpenThread_BR, self).getGUA(filterByPrefix=filterByPrefix)
495
496    def __getEthGUA(self, filterByPrefix=None):
497        globalAddrs = []
498
499        cmd = 'ip -6 addr list dev %s | grep inet6' % self.backboneNetif
500        output = self.bash(cmd, sudo=False)
501        for line in output:
502            # example: inet6 2401:fa00:41:23:274a:1329:3ab9:d953/64 scope global dynamic noprefixroute
503            line = line.strip().split()
504
505            if len(line) < 4 or line[2] != 'scope':
506                continue
507
508            if line[3] != 'global':
509                continue
510
511            addr = line[1].split('/')[0]
512            addr = str(ipaddress.IPv6Address(addr.decode()).exploded)
513            globalAddrs.append(addr)
514
515        if not filterByPrefix:
516            return globalAddrs[0]
517        else:
518            if filterByPrefix[-2:] != '::':
519                filterByPrefix = '%s::' % filterByPrefix
520            prefix = ipaddress.IPv6Network((filterByPrefix + '/64').decode())
521            for fullIp in globalAddrs:
522                address = ipaddress.IPv6Address(fullIp.decode())
523                if address in prefix:
524                    return fullIp
525
526    def _cliReadLine(self):
527        # read commissioning log if it's commissioning
528        if not self.__cli_output_lines:
529            self.__readSyslogToCli()
530
531        if self.__cli_output_lines:
532            return self.__cli_output_lines.pop(0)
533
534        return None
535
536    @watched
537    def _deviceGetEtherMac(self):
538        # Harness wants it in string. Because wireshark filter for eth
539        # cannot be applies in hex
540        return self.bash('ip addr list dev %s | grep ether' % self.backboneNetif, sudo=False)[0].strip().split()[1]
541
542    @watched
543    def _onCommissionStart(self):
544        assert self.__syslog_skip_lines is None
545        self.__syslog_skip_lines = int(self.bash('wc -l /var/log/syslog', sudo=False)[0].split()[0])
546        self.__syslog_last_read_ts = 0
547
548    @watched
549    def _onCommissionStop(self):
550        assert self.__syslog_skip_lines is not None
551        self.__syslog_skip_lines = None
552
553    @watched
554    def __startRadvdService(self, setDp=False):
555        assert self.IsHost, "radvd service runs on Host only"
556
557        conf = "EOF"
558        conf += "\ninterface %s" % self.backboneNetif
559        conf += "\n{"
560        conf += "\n    AdvSendAdvert on;"
561        conf += "\n"
562        conf += "\n    MinRtrAdvInterval 3;"
563        conf += "\n    MaxRtrAdvInterval 30;"
564        conf += "\n    AdvDefaultPreference low;"
565        conf += "\n"
566        conf += "\n    prefix 910b::/64"
567        conf += "\n    {"
568        conf += "\n        AdvOnLink on;"
569        conf += "\n        AdvAutonomous on;"
570        conf += "\n        AdvRouterAddr on;"
571        conf += "\n    };"
572        if setDp:
573            conf += "\n"
574            conf += "\n    prefix fd00:7d03:7d03:7d03::/64"
575            conf += "\n    {"
576            conf += "\n        AdvOnLink on;"
577            conf += "\n        AdvAutonomous off;"
578            conf += "\n        AdvRouterAddr off;"
579            conf += "\n    };"
580        conf += "\n};"
581        conf += "\nEOF"
582        cmd = 'sh -c "cat >/etc/radvd.conf <<%s"' % conf
583
584        self.bash(cmd)
585        self.bash('service radvd restart')
586        self.bash('service radvd status')
587
588    @watched
589    def __stopRadvdService(self):
590        assert self.IsHost, "radvd service runs on Host only"
591        self.bash('service radvd stop')
592
593    def __readSyslogToCli(self):
594        if self.__syslog_skip_lines is None:
595            return 0
596
597        # read syslog once per second
598        if time.time() < self.__syslog_last_read_ts + 1:
599            return 0
600
601        self.__syslog_last_read_ts = time.time()
602
603        lines = self.bash_unwatched('tail +%d /var/log/syslog' % self.__syslog_skip_lines, sudo=False)
604        for line in lines:
605            m = OTBR_AGENT_SYSLOG_PATTERN.search(line)
606            if not m:
607                continue
608
609            self.__cli_output_lines.append(m.group(1))
610
611        self.__syslog_skip_lines += len(lines)
612        return len(lines)
613
614    def _cliWriteLine(self, line):
615        cmd = 'ot-ctl -- %s' % line
616        output = self.bash(cmd)
617        # fake the line echo back
618        self.__cli_output_lines.append(line)
619        for line in output:
620            self.__cli_output_lines.append(line)
621
622    def __restartAgentService(self):
623        restart_cmd = self.extraParams.get('cmd-restart-otbr-agent', 'systemctl restart otbr-agent')
624        self.bash(restart_cmd)
625
626    def __truncateSyslog(self):
627        self.bash('truncate -s 0 /var/log/syslog')
628
629    def __dumpSyslog(self):
630        output = self.bash_unwatched('grep "otbr-agent" /var/log/syslog')
631        for line in output:
632            self.log('%s', line)
633
634    @API
635    def mdns_query(self, dst='ff02::fb', service='_meshcop._udp.local', addrs_blacklist=[]):
636        # For BBR-TC-03 or DH test cases (empty arguments) just send a query
637        if dst == 'ff02::fb' and not addrs_blacklist:
638            self.bash('dig -p 5353 @%s %s ptr' % (dst, service), sudo=False)
639            return
640
641        # For MATN-TC-17 and MATN-TC-18 use Zeroconf to get the BBR address and border agent port
642        cmd = 'python3 ~/repo/openthread/tests/scripts/thread-cert/find_border_agents.py'
643        output = self.bash(cmd)
644        for line in output:
645            print(line)
646            alias, addr, port, thread_status = eval(line)
647            if thread_status == 2 and addr:
648                if (dst and addr in dst) or (addr not in addrs_blacklist):
649                    if ipaddress.IPv6Address(addr.decode()).is_link_local:
650                        addr = '%s%%%s' % (addr, self.backboneNetif)
651                    return addr, port
652
653        raise Exception('No active Border Agents found')
654
655    # Override powerDown
656    @API
657    def powerDown(self):
658        self.log('Powering down BBR')
659        super(OpenThread_BR, self).powerDown()
660        stop_cmd = self.extraParams.get('cmd-stop-otbr-agent', 'systemctl stop otbr-agent')
661        self.bash(stop_cmd)
662
663    # Override powerUp
664    @API
665    def powerUp(self):
666        self.log('Powering up BBR')
667        start_cmd = self.extraParams.get('cmd-start-otbr-agent', 'systemctl start otbr-agent')
668        self.bash(start_cmd)
669        super(OpenThread_BR, self).powerUp()
670
671    # Override forceSetSlaac
672    @API
673    def forceSetSlaac(self, slaacAddress):
674        self.bash('ip -6 addr add %s/64 dev wpan0' % slaacAddress)
675
676    # Override stopListeningToAddr
677    @API
678    def stopListeningToAddr(self, sAddr):
679        """
680        Unsubscribe to a given IPv6 address which was subscribed earlier wiht `registerMulticast`.
681
682        Args:
683            sAddr   : str : Multicast address to be unsubscribed. Use an empty string to unsubscribe
684                            all the active multicast addresses.
685        """
686        cmd = 'pkill -f mcast6.*%s' % sAddr
687        self.bash(cmd)
688
689    def stopListeningToAddrAll(self):
690        return self.stopListeningToAddr('')
691
692    @API
693    def deregisterMulticast(self, sAddr):
694        """
695        Unsubscribe to a given IPv6 address.
696        Only used by External Commissioner.
697
698        Args:
699            sAddr   : str : Multicast address to be unsubscribed.
700        """
701        self.externalCommissioner.MLR([sAddr], 0)
702        return True
703
704    @watched
705    def _waitBorderRoutingStabilize(self):
706        """
707        Wait for Network Data to stabilize if BORDER_ROUTING is enabled.
708        """
709        if not self.isBorderRoutingEnabled():
710            return
711
712        MAX_TIMEOUT = 30
713        MIN_TIMEOUT = 15
714        CHECK_INTERVAL = 3
715
716        time.sleep(MIN_TIMEOUT)
717
718        lastNetData = self.getNetworkData()
719        for i in range((MAX_TIMEOUT - MIN_TIMEOUT) // CHECK_INTERVAL):
720            time.sleep(CHECK_INTERVAL)
721            curNetData = self.getNetworkData()
722
723            # Wait until the Network Data is not changing, and there is OMR Prefix and External Routes available
724            if curNetData == lastNetData and len(curNetData['Prefixes']) > 0 and len(curNetData['Routes']) > 0:
725                break
726
727            lastNetData = curNetData
728
729        return lastNetData
730