• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
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#
29import logging
30import queue
31import re
32import threading
33import time
34from abc import abstractmethod
35from typing import Any, Callable, Optional, Union, List, Pattern
36
37from .connectors import OtCliHandler
38from .errors import ExpectLineTimeoutError, CommandError
39from .utils import match_line
40
41
42class OTCommandHandler:
43    """This abstract class defines interfaces of a OT Command Handler."""
44
45    @abstractmethod
46    def execute_command(self, cmd: str, timeout: float) -> List[str]:
47        """Method execute_command should execute the OT CLI command within a timeout (in seconds) and return the
48        command output as a list of lines.
49
50        Note: each line SHOULD NOT contain '\r\n' at the end. The last line of output should be 'Done' or
51        'Error <code>: <msg>' following OT CLI conventions.
52        """
53        pass
54
55    @abstractmethod
56    def close(self):
57        """Method close should close the OT Command Handler."""
58        pass
59
60    @abstractmethod
61    def wait(self, duration: float) -> List[str]:
62        """Method wait should wait for a given duration and return the OT CLI output during this period.
63
64        Normally, OT CLI does not output when it's not executing any command. But OT CLI can also output
65        asynchronously in some cases (e.g. `Join Success` when Joiner joins successfully).
66        """
67        pass
68
69    @abstractmethod
70    def set_line_read_callback(self, callback: Optional[Callable[[str], Any]]):
71        """Method set_line_read_callback should register a callback that will be called for every line
72        output by the OT CLI.
73
74        This is useful for handling asynchronous command output while still being able to execute
75        other commands.
76        """
77        pass
78
79    def shell(self, cmd: str, timeout: float) -> List[str]:
80        raise NotImplementedError("shell command is not supported on %s" % self.__class__.__name__)
81
82
83class OtCliCommandRunner(OTCommandHandler):
84    __PATTERN_COMMAND_DONE_OR_ERROR = re.compile(
85        r'(Done|Error|Error \d+:.*|.*: command not found)$')  # "Error" for spinel-cli.py
86
87    __PATTERN_LOG_LINE = re.compile(r'((\[(NONE|CRIT|WARN|NOTE|INFO|DEBG)\])'
88                                    r'|(-.*-+: )'  # e.g. -CLI-----:
89                                    r'|(\[[DINWC\-]\] (?=[\w\-]{14}:)\w+-*:)'  # e.g. [I] Mac-----------:
90                                    r')')
91    """regex used to filter logs"""
92
93    assert __PATTERN_LOG_LINE.match('[I] ChannelMonitor: debug log')
94    assert __PATTERN_LOG_LINE.match('[I] Mac-----------: info log')
95    assert __PATTERN_LOG_LINE.match('[N] MeshForwarder-: note    log')
96    assert __PATTERN_LOG_LINE.match('[W] Notifier------: warn log')
97    assert __PATTERN_LOG_LINE.match('[C] Mle-----------: critical log')
98    assert __PATTERN_LOG_LINE.match('[-] Settings------: none log')
99    assert not __PATTERN_LOG_LINE.match('[-] Settings-----: none log')  # not enough `-` after module name
100
101    __ASYNC_COMMANDS = {'scan', 'ping', 'discover'}
102
103    def __init__(self, otcli: OtCliHandler, is_spinel_cli=False):
104        self.__otcli: OtCliHandler = otcli
105        self.__is_spinel_cli = is_spinel_cli
106        self.__expect_command_echoback = not self.__is_spinel_cli
107        self.__line_read_callback = None
108
109        self.__pending_lines = queue.Queue()
110        self.__should_close = threading.Event()
111        self.__otcli_reader = threading.Thread(target=self.__otcli_read_routine)
112        self.__otcli_reader.setDaemon(True)
113        self.__otcli_reader.start()
114
115    def __repr__(self):
116        return repr(self.__otcli)
117
118    def execute_command(self, cmd, timeout=10) -> List[str]:
119        assert not self.__should_close.is_set(), "OT CLI is already closed."
120        self.__otcli.writeline(cmd)
121
122        if cmd in ('reset', 'factoryreset'):
123            self.wait(3)
124            self.__otcli.writeline('extaddr')
125            self.wait(1)
126            return []
127
128        if self.__expect_command_echoback:
129            self.__expect_line(timeout, cmd)
130
131        output = self.__expect_line(timeout,
132                                    OtCliCommandRunner.__PATTERN_COMMAND_DONE_OR_ERROR,
133                                    asynchronous=cmd.split()[0] in OtCliCommandRunner.__ASYNC_COMMANDS)
134        return output
135
136    def wait(self, duration: float) -> List[str]:
137        self.__otcli.wait(duration)
138
139        output = []
140        try:
141            while True:
142                line = self.__pending_lines.get_nowait()
143                output.append(line)
144
145        except queue.Empty:
146            pass
147
148        return output
149
150    def close(self):
151        self.__should_close.set()
152        self.__otcli.close()
153        self.__otcli_reader.join()
154
155    def set_line_read_callback(self, callback: Optional[Callable[[str], Any]]):
156        self.__line_read_callback = callback
157
158    #
159    # Private methods
160    #
161
162    def __expect_line(self, timeout: float, expect_line: Union[str, Pattern], asynchronous=False) -> List[str]:
163        output = []
164
165        if not asynchronous:
166            while True:
167                try:
168                    line = self.__pending_lines.get(timeout=timeout)
169                except queue.Empty:
170                    raise ExpectLineTimeoutError(expect_line)
171
172                output.append(line)
173
174                if match_line(line, expect_line):
175                    break
176        else:
177            done = False
178            while not done and timeout > 0:
179                lines = self.wait(1)
180                timeout -= 1
181
182                for line in lines:
183                    output.append(line)
184
185                    if match_line(line, expect_line):
186                        done = True
187                        break
188
189            if not done:
190                raise ExpectLineTimeoutError(expect_line)
191
192        return output
193
194    def __otcli_read_routine(self):
195        while not self.__should_close.is_set():
196            try:
197                line = self.__otcli.readline()
198            except Exception:
199                if self.__should_close.is_set():
200                    break
201                else:
202                    raise
203
204            logging.debug('%s: %r', self.__otcli, line)
205
206            if line is None:
207                break
208
209            if line.startswith('> '):
210                line = line[2:]
211
212            if self.__line_read_callback is not None:
213                self.__line_read_callback(line)
214
215            logging.debug('%s: %s', self.__otcli, line)
216
217            if not OtCliCommandRunner.__PATTERN_LOG_LINE.match(line):
218                self.__pending_lines.put(line)
219
220
221class OtbrSshCommandRunner(OTCommandHandler):
222
223    def __init__(self, host, port, username, password, sudo):
224        import paramiko
225
226        self.__host = host
227        self.__port = port
228        self.__sudo = sudo
229        self.__ssh = paramiko.SSHClient()
230        self.__ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
231
232        self.__line_read_callback = None
233
234        try:
235            self.__ssh.connect(host,
236                               port=port,
237                               username=username,
238                               password=password,
239                               allow_agent=False,
240                               look_for_keys=False)
241        except paramiko.ssh_exception.AuthenticationException:
242            if not password:
243                self.__ssh.get_transport().auth_none(username)
244            else:
245                raise
246
247    def __repr__(self):
248        return f'{self.__host}:{self.__port}'
249
250    def execute_command(self, cmd: str, timeout: float) -> List[str]:
251        sh_cmd = f'ot-ctl {cmd}'
252        if self.__sudo:
253            sh_cmd = 'sudo ' + sh_cmd
254
255        output = self.shell(sh_cmd, timeout=timeout)
256
257        if self.__line_read_callback is not None:
258            for line in output:
259                self.__line_read_callback(line)
260
261        if cmd in ('reset', 'factoryreset'):
262            self.wait(3)
263
264        return output
265
266    def shell(self, cmd: str, timeout: float) -> List[str]:
267        cmd_in, cmd_out, cmd_err = self.__ssh.exec_command(cmd, timeout=int(timeout), bufsize=1024)
268        errput = [l.rstrip('\r\n') for l in cmd_err.readlines()]
269        output = [l.rstrip('\r\n') for l in cmd_out.readlines()]
270
271        if errput:
272            raise CommandError(cmd, errput)
273
274        return output
275
276    def close(self):
277        self.__ssh.close()
278
279    def wait(self, duration: float) -> List[str]:
280        time.sleep(duration)
281        return []
282
283    def set_line_read_callback(self, callback: Optional[Callable[[str], Any]]):
284        self.__line_read_callback = callback
285