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 41import time 42from types import ModuleType 43from typing import ( 44 Any, 45 Collection, 46 Iterable, 47 Iterator, 48) 49 50import serial 51import IPython # type: ignore 52 53from pw_cli import log as pw_cli_log 54from pw_console import embed 55from pw_console import log_store 56from pw_console.plugins import bandwidth_toolbar 57from pw_console import pyserial_wrapper 58from pw_console import python_logging 59from pw_console import socket_client 60from pw_hdlc import rpc 61from pw_rpc.console_tools.console import flattened_rpc_completions 62from pw_system import device as pw_device 63from pw_system import device_tracing 64from pw_tokenizer import detokenize 65 66# Default proto imports: 67from pw_log.proto import log_pb2 68from pw_metric_proto import metric_service_pb2 69from pw_thread_protos import thread_snapshot_service_pb2 70from pw_unit_test_proto import unit_test_pb2 71from pw_file import file_pb2 72from pw_trace_protos import trace_service_pb2 73from pw_transfer import transfer_pb2 74 75_LOG = logging.getLogger('tools') 76_DEVICE_LOG = logging.getLogger('rpc_device') 77_SERIAL_DEBUG = logging.getLogger('pw_console.serial_debug_logger') 78_ROOT_LOG = logging.getLogger() 79 80MKFIFO_MODE = 0o666 81 82 83def get_parser() -> argparse.ArgumentParser: 84 """Gets argument parser with console arguments.""" 85 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=( 161 'Socket address used to connect to server. Type "default" to use ' 162 'localhost:33000, pass the server address and port as ' 163 'address:port, or prefix the path to a forwarded socket with ' 164 f'"{socket_client.SocketClient.FILE_SOCKET_SERVER}:" as ' 165 f'{socket_client.SocketClient.FILE_SOCKET_SERVER}:path_to_file.' 166 ), 167 ) 168 parser.add_argument( 169 "--token-databases", 170 metavar='elf_or_token_database', 171 nargs="+", 172 type=Path, 173 help="Path to tokenizer database csv file(s).", 174 ) 175 parser.add_argument( 176 '--config-file', 177 type=Path, 178 help='Path to a pw_console yaml config file.', 179 ) 180 parser.add_argument( 181 '--proto-globs', 182 nargs='+', 183 default=[], 184 help='glob pattern for .proto files.', 185 ) 186 parser.add_argument( 187 '-f', 188 '--ticks_per_second', 189 type=int, 190 dest='ticks_per_second', 191 help=('The clock rate of the trace events.'), 192 ) 193 parser.add_argument( 194 '-v', 195 '--verbose', 196 action='store_true', 197 help='Enables debug logging when set.', 198 ) 199 parser.add_argument( 200 '--ipython', 201 action='store_true', 202 dest='use_ipython', 203 help='Use IPython instead of pw_console.', 204 ) 205 206 parser.add_argument( 207 '--rpc-logging', 208 action=argparse.BooleanOptionalAction, 209 default=True, 210 help='Use pw_rpc based logging.', 211 ) 212 213 parser.add_argument( 214 '--hdlc-encoding', 215 action=argparse.BooleanOptionalAction, 216 default=True, 217 help='Use HDLC encoding on transfer interfaces.', 218 ) 219 220 parser.add_argument( 221 '--channel-id', 222 type=int, 223 default=rpc.DEFAULT_CHANNEL_ID, 224 help="Channel ID used in RPC communications.", 225 ) 226 227 return parser 228 229 230def _parse_args(args: argparse.Namespace | None = None): 231 """Parses and returns the command line arguments.""" 232 if args is not None: 233 return args 234 235 parser = get_parser() 236 return parser.parse_args() 237 238 239def _expand_globs(globs: Iterable[str]) -> Iterator[Path]: 240 for pattern in globs: 241 for file in glob.glob(pattern, recursive=True): 242 yield Path(file) 243 244 245def _start_python_terminal( # pylint: disable=too-many-arguments 246 device: pw_device.Device, 247 device_log_store: log_store.LogStore, 248 root_log_store: log_store.LogStore, 249 serial_debug_log_store: log_store.LogStore, 250 log_file: str, 251 host_logfile: str, 252 device_logfile: str, 253 json_logfile: str, 254 serial_debug: bool = False, 255 config_file_path: Path | None = None, 256 use_ipython: bool = False, 257) -> None: 258 """Starts an interactive Python terminal with preset variables.""" 259 local_variables = dict( 260 client=device.client, 261 device=device, 262 rpcs=device.rpcs, 263 protos=device.client.protos.packages, 264 # Include the active pane logger for creating logs in the repl. 265 DEVICE_LOG=_DEVICE_LOG, 266 LOG=logging.getLogger(), 267 ) 268 269 welcome_message = cleandoc( 270 """ 271 Welcome to the Pigweed Console! 272 273 Help: Press F1 or click the [Help] menu 274 To move focus: Press Shift-Tab or click on a window 275 276 Example Python commands: 277 278 device.rpcs.pw.rpc.EchoService.Echo(msg='hello!') 279 LOG.warning('Message appears in Host Logs window.') 280 DEVICE_LOG.warning('Message appears in Device Logs window.') 281 """ 282 ) 283 284 welcome_message += '\n\nLogs are being saved to:\n ' + log_file 285 if host_logfile: 286 welcome_message += '\nHost logs are being saved to:\n ' + host_logfile 287 if device_logfile: 288 welcome_message += ( 289 '\nDevice logs are being saved to:\n ' + device_logfile 290 ) 291 if json_logfile: 292 welcome_message += ( 293 '\nJSON device logs are being saved to:\n ' + json_logfile 294 ) 295 296 if use_ipython: 297 print(welcome_message) 298 IPython.start_ipython( 299 argv=[], 300 display_banner=False, 301 user_ns=local_variables, 302 ) 303 return 304 305 client_info = device.info() 306 completions = flattened_rpc_completions([client_info]) 307 308 log_windows: dict[str, list[logging.Logger] | log_store.LogStore] = { 309 'Device Logs': device_log_store, 310 'Host Logs': root_log_store, 311 } 312 if serial_debug: 313 log_windows['Serial Debug'] = serial_debug_log_store 314 315 interactive_console = embed.PwConsoleEmbed( 316 global_vars=local_variables, 317 local_vars=None, 318 loggers=log_windows, 319 repl_startup_message=welcome_message, 320 help_text=__doc__, 321 config_file_path=config_file_path, 322 ) 323 interactive_console.add_sentence_completer(completions) 324 if serial_debug: 325 interactive_console.add_bottom_toolbar( 326 bandwidth_toolbar.BandwidthToolbar() 327 ) 328 329 # Setup Python logger propagation 330 interactive_console.setup_python_logging( 331 # Send any unhandled log messages to the external file. 332 last_resort_filename=log_file, 333 # Don't change propagation for these loggers. 334 loggers_with_no_propagation=[_DEVICE_LOG], 335 ) 336 337 interactive_console.embed() 338 339 340# pylint: disable=too-many-arguments,too-many-locals 341def console( 342 device: str, 343 baudrate: int, 344 proto_globs: Collection[str], 345 ticks_per_second: int | None, 346 token_databases: Collection[Path], 347 socket_addr: str, 348 logfile: str, 349 host_logfile: str, 350 device_logfile: str, 351 json_logfile: str, 352 output: Any, 353 serial_debug: bool = False, 354 config_file: Path | None = None, 355 verbose: bool = False, 356 compiled_protos: list[ModuleType] | None = None, 357 merge_device_and_host_logs: bool = False, 358 rpc_logging: bool = True, 359 use_ipython: bool = False, 360 channel_id: int = rpc.DEFAULT_CHANNEL_ID, 361 hdlc_encoding: bool = True, 362) -> int: 363 """Starts an interactive RPC console for HDLC.""" 364 # argparse.FileType doesn't correctly handle '-' for binary files. 365 if output is sys.stdout: 366 output = sys.stdout.buffer 367 368 # Don't send device logs to the root logger. 369 _DEVICE_LOG.propagate = False 370 # Create pw_console log_store.LogStore handlers. These are the data source 371 # for log messages to be displayed in the UI. 372 device_log_store = log_store.LogStore() 373 root_log_store = log_store.LogStore() 374 serial_debug_log_store = log_store.LogStore() 375 # Attach the log_store.LogStores as handlers for each log window we want to 376 # show. This should be done before device initialization to capture early 377 # messages. 378 _DEVICE_LOG.addHandler(device_log_store) 379 _ROOT_LOG.addHandler(root_log_store) 380 _SERIAL_DEBUG.addHandler(serial_debug_log_store) 381 382 if not logfile: 383 # Create a temp logfile to prevent logs from appearing over stdout. This 384 # would corrupt the prompt toolkit UI. 385 logfile = python_logging.create_temp_log_file() 386 387 log_level = logging.DEBUG if verbose else logging.INFO 388 389 pw_cli_log.install( 390 level=log_level, use_color=False, hide_timestamp=False, log_file=logfile 391 ) 392 393 if device_logfile: 394 pw_cli_log.install( 395 level=log_level, 396 use_color=False, 397 hide_timestamp=False, 398 log_file=device_logfile, 399 logger=_DEVICE_LOG, 400 ) 401 if host_logfile: 402 pw_cli_log.install( 403 level=log_level, 404 use_color=False, 405 hide_timestamp=False, 406 log_file=host_logfile, 407 logger=_ROOT_LOG, 408 ) 409 410 if merge_device_and_host_logs: 411 # Add device logs to the default logfile. 412 pw_cli_log.install( 413 level=log_level, 414 use_color=False, 415 hide_timestamp=False, 416 log_file=logfile, 417 logger=_DEVICE_LOG, 418 ) 419 420 _LOG.setLevel(log_level) 421 _DEVICE_LOG.setLevel(log_level) 422 _ROOT_LOG.setLevel(log_level) 423 _SERIAL_DEBUG.setLevel(logging.DEBUG) 424 425 if json_logfile: 426 json_filehandler = logging.FileHandler(json_logfile, encoding='utf-8') 427 json_filehandler.setLevel(log_level) 428 json_filehandler.setFormatter(python_logging.JsonLogFormatter()) 429 _DEVICE_LOG.addHandler(json_filehandler) 430 431 detokenizer = None 432 if token_databases: 433 token_databases_with_domains = [] * len(token_databases) 434 for token_database in token_databases: 435 # Load all domains from token database. 436 token_databases_with_domains.append(str(token_database) + "#.*") 437 438 detokenizer = detokenize.AutoUpdatingDetokenizer( 439 *token_databases_with_domains 440 ) 441 detokenizer.show_errors = True 442 443 protos: list[ModuleType | Path] = list(_expand_globs(proto_globs)) 444 445 if compiled_protos is None: 446 compiled_protos = [] 447 448 # Append compiled log.proto library to avoid include errors when manually 449 # provided, and shadowing errors due to ordering when the default global 450 # search path is used. 451 if rpc_logging: 452 compiled_protos.append(log_pb2) 453 compiled_protos.append(unit_test_pb2) 454 protos.extend(compiled_protos) 455 protos.append(metric_service_pb2) 456 protos.append(thread_snapshot_service_pb2) 457 protos.append(file_pb2) 458 protos.append(trace_service_pb2) 459 protos.append(transfer_pb2) 460 461 if not protos: 462 _LOG.critical( 463 'No .proto files were found with %s', ', '.join(proto_globs) 464 ) 465 _LOG.critical('At least one .proto file is required') 466 return 1 467 468 _LOG.debug( 469 'Found %d .proto files found with %s', 470 len(protos), 471 ', '.join(proto_globs), 472 ) 473 474 timestamp_decoder = None 475 if socket_addr is None: 476 serial_impl = ( 477 pyserial_wrapper.SerialWithLogging 478 if serial_debug 479 else serial.Serial 480 ) 481 serial_device = serial_impl( 482 device, 483 baudrate, 484 # Timeout in seconds. This should be a very small value. Setting to 485 # zero makes pyserial read() non-blocking which will cause the host 486 # machine to busy loop and 100% CPU usage. 487 # https://pythonhosted.org/pyserial/pyserial_api.html#serial.Serial 488 timeout=0.1, 489 ) 490 reader = rpc.SerialReader(serial_device, 8192) 491 write = serial_device.write 492 493 # Overwrite decoder for serial device. 494 def milliseconds_to_string(timestamp): 495 """Parses milliseconds since boot to a human-readable string.""" 496 return str(datetime.timedelta(seconds=timestamp / 1e3))[:-3] 497 498 timestamp_decoder = milliseconds_to_string 499 else: 500 socket_impl = ( 501 socket_client.SocketClientWithLogging 502 if serial_debug 503 else socket_client.SocketClient 504 ) 505 506 def disconnect_handler( 507 socket_device: socket_client.SocketClient, 508 ) -> None: 509 """Attempts to reconnect on disconnected socket.""" 510 _LOG.error('Socket disconnected. Will retry to connect.') 511 while True: 512 try: 513 socket_device.connect() 514 break 515 except: # pylint: disable=bare-except 516 # Ignore errors and retry to reconnect. 517 time.sleep(1) 518 _LOG.info('Successfully reconnected') 519 520 try: 521 socket_device = socket_impl( 522 socket_addr, on_disconnect=disconnect_handler 523 ) 524 reader = rpc.SelectableReader(socket_device) 525 write = socket_device.write 526 except ValueError: 527 _LOG.exception('Failed to initialize socket at %s', socket_addr) 528 return 1 529 530 with reader: 531 device_client = device_tracing.DeviceWithTracing( 532 channel_id, 533 reader, 534 write, 535 proto_library=protos, 536 detokenizer=detokenizer, 537 timestamp_decoder=timestamp_decoder, 538 rpc_timeout_s=5, 539 use_rpc_logging=rpc_logging, 540 use_hdlc_encoding=hdlc_encoding, 541 ticks_per_second=ticks_per_second, 542 ) 543 with device_client: 544 _start_python_terminal( 545 device=device_client, 546 device_log_store=device_log_store, 547 root_log_store=root_log_store, 548 serial_debug_log_store=serial_debug_log_store, 549 log_file=logfile, 550 host_logfile=host_logfile, 551 device_logfile=device_logfile, 552 json_logfile=json_logfile, 553 serial_debug=serial_debug, 554 config_file_path=config_file, 555 use_ipython=use_ipython, 556 ) 557 return 0 558 559 560def main(args: argparse.Namespace | None = None) -> int: 561 return console(**vars(_parse_args(args))) 562 563 564def main_with_compiled_protos( 565 compiled_protos, args: argparse.Namespace | None = None 566): 567 return console(**vars(_parse_args(args)), compiled_protos=compiled_protos) 568 569 570if __name__ == '__main__': 571 sys.exit(main()) 572