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