1# Copyright 2021 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Console for interacting with devices using HDLC. 15 16To start the console, provide a serial port as the --device argument and paths 17or globs for .proto files that define the RPC services to support: 18 19 python -m pw_system.console --device /dev/ttyUSB0 --proto-globs pw_rpc/echo.proto 20 21This starts an IPython console for communicating with the connected device. A 22few variables are predefined in the interactive console. These include: 23 24 rpcs - used to invoke RPCs 25 device - the serial device used for communication 26 client - the pw_rpc.Client 27 protos - protocol buffer messages indexed by proto package 28 29An example echo RPC command: 30 31 rpcs.pw.rpc.EchoService.Echo(msg="hello!") 32""" # pylint: disable=line-too-long 33 34import argparse 35import datetime 36import glob 37from inspect import cleandoc 38import logging 39from pathlib import Path 40import sys 41from types import ModuleType 42from typing import ( 43 Any, 44 Collection, 45 Dict, 46 Iterable, 47 Iterator, 48 List, 49 Optional, 50 Union, 51) 52import socket 53 54import serial 55import IPython # type: ignore 56 57from pw_cli import log as pw_cli_log 58from pw_console.embed import PwConsoleEmbed 59from pw_console.log_store import LogStore 60from pw_console.plugins.bandwidth_toolbar import BandwidthToolbar 61from pw_console.pyserial_wrapper import SerialWithLogging 62from pw_console.python_logging import create_temp_log_file, JsonLogFormatter 63from pw_rpc.console_tools.console import flattened_rpc_completions 64from pw_system.device import Device 65from pw_tokenizer.detokenize import AutoUpdatingDetokenizer 66 67# Default proto imports: 68from pw_log.proto import log_pb2 69from pw_metric_proto import metric_service_pb2 70from pw_thread_protos import thread_snapshot_service_pb2 71from pw_unit_test_proto import unit_test_pb2 72 73_LOG = logging.getLogger('tools') 74_DEVICE_LOG = logging.getLogger('rpc_device') 75_SERIAL_DEBUG = logging.getLogger('pw_console.serial_debug_logger') 76_ROOT_LOG = logging.getLogger() 77 78PW_RPC_MAX_PACKET_SIZE = 256 79SOCKET_SERVER = 'localhost' 80SOCKET_PORT = 33000 81MKFIFO_MODE = 0o666 82 83 84def _parse_args(): 85 """Parses and returns the command line arguments.""" 86 parser = argparse.ArgumentParser( 87 prog="python -m pw_system.console", description=__doc__ 88 ) 89 group = parser.add_mutually_exclusive_group(required=True) 90 group.add_argument('-d', '--device', help='the serial port to use') 91 parser.add_argument( 92 '-b', 93 '--baudrate', 94 type=int, 95 default=115200, 96 help='the baud rate to use', 97 ) 98 parser.add_argument( 99 '--serial-debug', 100 action='store_true', 101 help=( 102 'Enable debug log tracing of all data passed through' 103 'pyserial read and write.' 104 ), 105 ) 106 parser.add_argument( 107 '-o', 108 '--output', 109 type=argparse.FileType('wb'), 110 default=sys.stdout.buffer, 111 help=( 112 'The file to which to write device output (HDLC channel 1); ' 113 'provide - or omit for stdout.' 114 ), 115 ) 116 117 # Log file options 118 parser.add_argument( 119 '--logfile', 120 default='pw_console-logs.txt', 121 help=( 122 'Default log file. This will contain host side ' 123 'log messages only unles the ' 124 '--merge-device-and-host-logs argument is used.' 125 ), 126 ) 127 128 parser.add_argument( 129 '--merge-device-and-host-logs', 130 action='store_true', 131 help=( 132 'Include device logs in the default --logfile.' 133 'These are normally shown in a separate device ' 134 'only log file.' 135 ), 136 ) 137 138 parser.add_argument( 139 '--host-logfile', 140 help=( 141 'Additional host only log file. Normally all logs in the ' 142 'default logfile are host only.' 143 ), 144 ) 145 146 parser.add_argument( 147 '--device-logfile', 148 default='pw_console-device-logs.txt', 149 help='Device only log file.', 150 ) 151 152 parser.add_argument( 153 '--json-logfile', help='Device only JSON formatted log file.' 154 ) 155 156 group.add_argument( 157 '-s', 158 '--socket-addr', 159 type=str, 160 help='use socket to connect to server, type default for\ 161 localhost:33000, or manually input the server address:port', 162 ) 163 parser.add_argument( 164 "--token-databases", 165 metavar='elf_or_token_database', 166 nargs="+", 167 type=Path, 168 help="Path to tokenizer database csv file(s).", 169 ) 170 parser.add_argument( 171 '--config-file', 172 type=Path, 173 help='Path to a pw_console yaml config file.', 174 ) 175 parser.add_argument( 176 '--proto-globs', 177 nargs='+', 178 default=[], 179 help='glob pattern for .proto files.', 180 ) 181 parser.add_argument( 182 '-v', 183 '--verbose', 184 action='store_true', 185 help='Enables debug logging when set.', 186 ) 187 parser.add_argument( 188 '--ipython', 189 action='store_true', 190 dest='use_ipython', 191 help='Use IPython instead of pw_console.', 192 ) 193 194 # TODO(b/248257406) Use argparse.BooleanOptionalAction when Python 3.8 is 195 # no longer supported. 196 parser.add_argument( 197 '--rpc-logging', 198 action='store_true', 199 default=True, 200 help='Use pw_rpc based logging.', 201 ) 202 203 parser.add_argument( 204 '--no-rpc-logging', 205 action='store_false', 206 dest='rpc_logging', 207 help="Don't use pw_rpc based logging.", 208 ) 209 210 return parser.parse_args() 211 212 213def _expand_globs(globs: Iterable[str]) -> Iterator[Path]: 214 for pattern in globs: 215 for file in glob.glob(pattern, recursive=True): 216 yield Path(file) 217 218 219def _start_python_terminal( # pylint: disable=too-many-arguments 220 device: Device, 221 device_log_store: LogStore, 222 root_log_store: LogStore, 223 serial_debug_log_store: LogStore, 224 log_file: str, 225 host_logfile: str, 226 device_logfile: str, 227 json_logfile: str, 228 serial_debug: bool = False, 229 config_file_path: Optional[Path] = None, 230 use_ipython: bool = False, 231) -> None: 232 """Starts an interactive Python terminal with preset variables.""" 233 local_variables = dict( 234 client=device.client, 235 device=device, 236 rpcs=device.rpcs, 237 protos=device.client.protos.packages, 238 # Include the active pane logger for creating logs in the repl. 239 DEVICE_LOG=_DEVICE_LOG, 240 LOG=logging.getLogger(), 241 ) 242 243 welcome_message = cleandoc( 244 """ 245 Welcome to the Pigweed Console! 246 247 Help: Press F1 or click the [Help] menu 248 To move focus: Press Shift-Tab or click on a window 249 250 Example Python commands: 251 252 device.rpcs.pw.rpc.EchoService.Echo(msg='hello!') 253 LOG.warning('Message appears in Host Logs window.') 254 DEVICE_LOG.warning('Message appears in Device Logs window.') 255 """ 256 ) 257 258 welcome_message += '\n\nLogs are being saved to:\n ' + log_file 259 if host_logfile: 260 welcome_message += '\nHost logs are being saved to:\n ' + host_logfile 261 if device_logfile: 262 welcome_message += ( 263 '\nDevice logs are being saved to:\n ' + device_logfile 264 ) 265 if json_logfile: 266 welcome_message += ( 267 '\nJSON device logs are being saved to:\n ' + json_logfile 268 ) 269 270 if use_ipython: 271 print(welcome_message) 272 IPython.terminal.embed.InteractiveShellEmbed().mainloop( 273 local_ns=local_variables, module=argparse.Namespace() 274 ) 275 return 276 277 client_info = device.info() 278 completions = flattened_rpc_completions([client_info]) 279 280 log_windows: Dict[str, Union[List[logging.Logger], LogStore]] = { 281 'Device Logs': device_log_store, 282 'Host Logs': root_log_store, 283 } 284 if serial_debug: 285 log_windows['Serial Debug'] = serial_debug_log_store 286 287 interactive_console = PwConsoleEmbed( 288 global_vars=local_variables, 289 local_vars=None, 290 loggers=log_windows, 291 repl_startup_message=welcome_message, 292 help_text=__doc__, 293 config_file_path=config_file_path, 294 ) 295 interactive_console.add_sentence_completer(completions) 296 if serial_debug: 297 interactive_console.add_bottom_toolbar(BandwidthToolbar()) 298 299 # Setup Python logger propagation 300 interactive_console.setup_python_logging( 301 # Send any unhandled log messages to the external file. 302 last_resort_filename=log_file, 303 # Don't change propagation for these loggers. 304 loggers_with_no_propagation=[_DEVICE_LOG], 305 ) 306 307 interactive_console.embed() 308 309 310class SocketClientImpl: 311 def __init__(self, config: str): 312 self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 313 socket_server = '' 314 socket_port = 0 315 316 if config == 'default': 317 socket_server = SOCKET_SERVER 318 socket_port = SOCKET_PORT 319 else: 320 socket_server, socket_port_str = config.split(':') 321 socket_port = int(socket_port_str) 322 self.socket.connect((socket_server, socket_port)) 323 324 def write(self, data: bytes): 325 self.socket.sendall(data) 326 327 def read(self, num_bytes: int = PW_RPC_MAX_PACKET_SIZE): 328 return self.socket.recv(num_bytes) 329 330 331# pylint: disable=too-many-arguments,too-many-locals 332def console( 333 device: str, 334 baudrate: int, 335 proto_globs: Collection[str], 336 token_databases: Collection[Path], 337 socket_addr: str, 338 logfile: str, 339 host_logfile: str, 340 device_logfile: str, 341 json_logfile: str, 342 output: Any, 343 serial_debug: bool = False, 344 config_file: Optional[Path] = None, 345 verbose: bool = False, 346 compiled_protos: Optional[List[ModuleType]] = None, 347 merge_device_and_host_logs: bool = False, 348 rpc_logging: bool = True, 349 use_ipython: bool = False, 350) -> int: 351 """Starts an interactive RPC console for HDLC.""" 352 # argparse.FileType doesn't correctly handle '-' for binary files. 353 if output is sys.stdout: 354 output = sys.stdout.buffer 355 356 # Don't send device logs to the root logger. 357 _DEVICE_LOG.propagate = False 358 # Create pw_console LogStore handlers. These are the data source for log 359 # messages to be displayed in the UI. 360 device_log_store = LogStore() 361 root_log_store = LogStore() 362 serial_debug_log_store = LogStore() 363 # Attach the LogStores as handlers for each log window we want to show. 364 # This should be done before device initialization to capture early 365 # messages. 366 _DEVICE_LOG.addHandler(device_log_store) 367 _ROOT_LOG.addHandler(root_log_store) 368 _SERIAL_DEBUG.addHandler(serial_debug_log_store) 369 370 if not logfile: 371 # Create a temp logfile to prevent logs from appearing over stdout. This 372 # would corrupt the prompt toolkit UI. 373 logfile = create_temp_log_file() 374 375 log_level = logging.DEBUG if verbose else logging.INFO 376 377 pw_cli_log.install( 378 level=log_level, use_color=False, hide_timestamp=False, log_file=logfile 379 ) 380 381 if device_logfile: 382 pw_cli_log.install( 383 level=log_level, 384 use_color=False, 385 hide_timestamp=False, 386 log_file=device_logfile, 387 logger=_DEVICE_LOG, 388 ) 389 if host_logfile: 390 pw_cli_log.install( 391 level=log_level, 392 use_color=False, 393 hide_timestamp=False, 394 log_file=host_logfile, 395 logger=_ROOT_LOG, 396 ) 397 398 if merge_device_and_host_logs: 399 # Add device logs to the default logfile. 400 pw_cli_log.install( 401 level=log_level, 402 use_color=False, 403 hide_timestamp=False, 404 log_file=logfile, 405 logger=_DEVICE_LOG, 406 ) 407 408 _LOG.setLevel(log_level) 409 _DEVICE_LOG.setLevel(log_level) 410 _ROOT_LOG.setLevel(log_level) 411 _SERIAL_DEBUG.setLevel(logging.DEBUG) 412 413 if json_logfile: 414 json_filehandler = logging.FileHandler(json_logfile, encoding='utf-8') 415 json_filehandler.setLevel(log_level) 416 json_filehandler.setFormatter(JsonLogFormatter()) 417 _DEVICE_LOG.addHandler(json_filehandler) 418 419 detokenizer = None 420 if token_databases: 421 detokenizer = AutoUpdatingDetokenizer(*token_databases) 422 detokenizer.show_errors = True 423 424 protos: List[Union[ModuleType, Path]] = list(_expand_globs(proto_globs)) 425 426 if compiled_protos is None: 427 compiled_protos = [] 428 429 # Append compiled log.proto library to avoid include errors when manually 430 # provided, and shadowing errors due to ordering when the default global 431 # search path is used. 432 if rpc_logging: 433 compiled_protos.append(log_pb2) 434 compiled_protos.append(unit_test_pb2) 435 protos.extend(compiled_protos) 436 protos.append(metric_service_pb2) 437 protos.append(thread_snapshot_service_pb2) 438 439 if not protos: 440 _LOG.critical( 441 'No .proto files were found with %s', ', '.join(proto_globs) 442 ) 443 _LOG.critical('At least one .proto file is required') 444 return 1 445 446 _LOG.debug( 447 'Found %d .proto files found with %s', 448 len(protos), 449 ', '.join(proto_globs), 450 ) 451 452 serial_impl = serial.Serial 453 if serial_debug: 454 serial_impl = SerialWithLogging 455 456 timestamp_decoder = None 457 if socket_addr is None: 458 serial_device = serial_impl( 459 device, 460 baudrate, 461 # Timeout in seconds. This should be a very small value. Setting to 462 # zero makes pyserial read() non-blocking which will cause the host 463 # machine to busy loop and 100% CPU usage. 464 # https://pythonhosted.org/pyserial/pyserial_api.html#serial.Serial 465 timeout=0.1, 466 ) 467 read = lambda: serial_device.read(8192) 468 write = serial_device.write 469 470 # Overwrite decoder for serial device. 471 def milliseconds_to_string(timestamp): 472 """Parses milliseconds since boot to a human-readable string.""" 473 return str(datetime.timedelta(seconds=timestamp / 1e3))[:-3] 474 475 timestamp_decoder = milliseconds_to_string 476 else: 477 try: 478 socket_device = SocketClientImpl(socket_addr) 479 read = socket_device.read 480 write = socket_device.write 481 except ValueError: 482 _LOG.exception('Failed to initialize socket at %s', socket_addr) 483 return 1 484 485 device_client = Device( 486 1, 487 read, 488 write, 489 protos, 490 detokenizer, 491 timestamp_decoder=timestamp_decoder, 492 rpc_timeout_s=5, 493 use_rpc_logging=rpc_logging, 494 ) 495 496 _start_python_terminal( 497 device=device_client, 498 device_log_store=device_log_store, 499 root_log_store=root_log_store, 500 serial_debug_log_store=serial_debug_log_store, 501 log_file=logfile, 502 host_logfile=host_logfile, 503 device_logfile=device_logfile, 504 json_logfile=json_logfile, 505 serial_debug=serial_debug, 506 config_file_path=config_file, 507 use_ipython=use_ipython, 508 ) 509 return 0 510 511 512def main() -> int: 513 return console(**vars(_parse_args())) 514 515 516def main_with_compiled_protos(compiled_protos): 517 return console(**vars(_parse_args()), compiled_protos=compiled_protos) 518 519 520if __name__ == '__main__': 521 sys.exit(main()) 522