1# Copyright 2020 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 pw_rpc over 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_hdlc.rpc_console --device /dev/ttyUSB0 sample.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""" 33 34import argparse 35import glob 36from inspect import cleandoc 37import logging 38from pathlib import Path 39import sys 40from types import ModuleType 41from typing import ( 42 Any, 43 BinaryIO, 44 Collection, 45 Iterable, 46 Iterator, 47 List, 48 Optional, 49 Union, 50) 51import socket 52 53import serial # type: ignore 54 55import pw_cli.log 56import pw_console.python_logging 57from pw_console import PwConsoleEmbed 58from pw_console.pyserial_wrapper import SerialWithLogging 59from pw_console.plugins.bandwidth_toolbar import BandwidthToolbar 60 61from pw_log.proto import log_pb2 62from pw_rpc.console_tools.console import ClientInfo, flattened_rpc_completions 63from pw_rpc import callback_client 64from pw_tokenizer.database import LoadTokenDatabases 65from pw_tokenizer.detokenize import Detokenizer, detokenize_base64 66from pw_tokenizer import tokens 67 68from pw_hdlc.rpc import HdlcRpcClient, default_channels 69 70_LOG = logging.getLogger(__name__) 71_DEVICE_LOG = logging.getLogger('rpc_device') 72 73PW_RPC_MAX_PACKET_SIZE = 256 74SOCKET_SERVER = 'localhost' 75SOCKET_PORT = 33000 76MKFIFO_MODE = 0o666 77 78 79def _parse_args(): 80 """Parses and returns the command line arguments.""" 81 parser = argparse.ArgumentParser(description=__doc__) 82 group = parser.add_mutually_exclusive_group(required=True) 83 group.add_argument('-d', '--device', help='the serial port to use') 84 parser.add_argument('-b', 85 '--baudrate', 86 type=int, 87 default=115200, 88 help='the baud rate to use') 89 parser.add_argument( 90 '--serial-debug', 91 action='store_true', 92 help=('Enable debug log tracing of all data passed through' 93 'pyserial read and write.')) 94 parser.add_argument( 95 '-o', 96 '--output', 97 type=argparse.FileType('wb'), 98 default=sys.stdout.buffer, 99 help=('The file to which to write device output (HDLC channel 1); ' 100 'provide - or omit for stdout.')) 101 parser.add_argument('--logfile', help='Console debug log file.') 102 group.add_argument('-s', 103 '--socket-addr', 104 type=str, 105 help='use socket to connect to server, type default for\ 106 localhost:33000, or manually input the server address:port') 107 parser.add_argument("--token-databases", 108 metavar='elf_or_token_database', 109 nargs="+", 110 action=LoadTokenDatabases, 111 help="Path to tokenizer database csv file(s).") 112 parser.add_argument('--config-file', 113 type=Path, 114 help='Path to a pw_console yaml config file.') 115 parser.add_argument('--proto-globs', 116 nargs='+', 117 help='glob pattern for .proto files') 118 return parser.parse_args() 119 120 121def _expand_globs(globs: Iterable[str]) -> Iterator[Path]: 122 for pattern in globs: 123 for file in glob.glob(pattern, recursive=True): 124 yield Path(file) 125 126 127def _start_ipython_terminal(client: HdlcRpcClient, 128 serial_debug: bool = False, 129 config_file_path: Optional[Path] = None) -> None: 130 """Starts an interactive IPython terminal with preset variables.""" 131 local_variables = dict( 132 client=client, 133 device=client.client.channel(1), 134 rpcs=client.client.channel(1).rpcs, 135 protos=client.protos.packages, 136 # Include the active pane logger for creating logs in the repl. 137 DEVICE_LOG=_DEVICE_LOG, 138 LOG=logging.getLogger(), 139 ) 140 141 welcome_message = cleandoc(""" 142 Welcome to the Pigweed Console! 143 144 Help: Press F1 or click the [Help] menu 145 To move focus: Press Shift-Tab or click on a window 146 147 Example Python commands: 148 149 device.rpcs.pw.rpc.EchoService.Echo(msg='hello!') 150 LOG.warning('Message appears in Host Logs window.') 151 DEVICE_LOG.warning('Message appears in Device Logs window.') 152 """) 153 154 client_info = ClientInfo('device', 155 client.client.channel(1).rpcs, client.client) 156 completions = flattened_rpc_completions([client_info]) 157 158 log_windows = { 159 'Device Logs': [_DEVICE_LOG], 160 'Host Logs': [logging.getLogger()], 161 } 162 if serial_debug: 163 log_windows['Serial Debug'] = [ 164 logging.getLogger('pw_console.serial_debug_logger') 165 ] 166 167 interactive_console = PwConsoleEmbed( 168 global_vars=local_variables, 169 local_vars=None, 170 loggers=log_windows, 171 repl_startup_message=welcome_message, 172 help_text=__doc__, 173 config_file_path=config_file_path, 174 ) 175 interactive_console.hide_windows('Host Logs') 176 interactive_console.add_sentence_completer(completions) 177 if serial_debug: 178 interactive_console.add_bottom_toolbar(BandwidthToolbar()) 179 180 # Setup Python logger propagation 181 interactive_console.setup_python_logging() 182 183 # Don't send device logs to the root logger. 184 _DEVICE_LOG.propagate = False 185 186 interactive_console.embed() 187 188 189class SocketClientImpl: 190 def __init__(self, config: str): 191 self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 192 socket_server = '' 193 socket_port = 0 194 195 if config == 'default': 196 socket_server = SOCKET_SERVER 197 socket_port = SOCKET_PORT 198 else: 199 socket_server, socket_port_str = config.split(':') 200 socket_port = int(socket_port_str) 201 self.socket.connect((socket_server, socket_port)) 202 203 def write(self, data: bytes): 204 self.socket.sendall(data) 205 206 def read(self, num_bytes: int = PW_RPC_MAX_PACKET_SIZE): 207 return self.socket.recv(num_bytes) 208 209 210def console(device: str, 211 baudrate: int, 212 proto_globs: Collection[str], 213 token_databases: Collection[tokens.Database], 214 socket_addr: str, 215 logfile: str, 216 output: Any, 217 serial_debug: bool = False, 218 config_file: Optional[Path] = None) -> int: 219 """Starts an interactive RPC console for HDLC.""" 220 # argparse.FileType doesn't correctly handle '-' for binary files. 221 if output is sys.stdout: 222 output = sys.stdout.buffer 223 224 if not logfile: 225 # Create a temp logfile to prevent logs from appearing over stdout. This 226 # would corrupt the prompt toolkit UI. 227 logfile = pw_console.python_logging.create_temp_log_file() 228 pw_cli.log.install(logging.INFO, True, False, logfile) 229 230 detokenizer = None 231 if token_databases: 232 detokenizer = Detokenizer(tokens.Database.merged(*token_databases), 233 show_errors=False) 234 235 if not proto_globs: 236 proto_globs = ['**/*.proto'] 237 238 protos: List[Union[ModuleType, Path]] = list(_expand_globs(proto_globs)) 239 240 # Append compiled log.proto library to avoid include errors when manually 241 # provided, and shadowing errors due to ordering when the default global 242 # search path is used. 243 protos.append(log_pb2) 244 245 if not protos: 246 _LOG.critical('No .proto files were found with %s', 247 ', '.join(proto_globs)) 248 _LOG.critical('At least one .proto file is required') 249 return 1 250 251 _LOG.debug('Found %d .proto files found with %s', len(protos), 252 ', '.join(proto_globs)) 253 254 serial_impl = serial.Serial 255 if serial_debug: 256 serial_impl = SerialWithLogging 257 258 if socket_addr is None: 259 serial_device = serial_impl( 260 device, 261 baudrate, 262 timeout=0, # Non-blocking mode 263 ) 264 read = lambda: serial_device.read(8192) 265 write = serial_device.write 266 else: 267 try: 268 socket_device = SocketClientImpl(socket_addr) 269 read = socket_device.read 270 write = socket_device.write 271 except ValueError: 272 _LOG.exception('Failed to initialize socket at %s', socket_addr) 273 return 1 274 275 callback_client_impl = callback_client.Impl( 276 default_unary_timeout_s=5.0, 277 default_stream_timeout_s=None, 278 ) 279 _start_ipython_terminal( 280 HdlcRpcClient(read, 281 protos, 282 default_channels(write), 283 lambda data: detokenize_and_write_to_output( 284 data, output, detokenizer), 285 client_impl=callback_client_impl), serial_debug, 286 config_file) 287 return 0 288 289 290def detokenize_and_write_to_output(data: bytes, 291 unused_output: BinaryIO = sys.stdout.buffer, 292 detokenizer=None): 293 log_line = data 294 if detokenizer: 295 log_line = detokenize_base64(detokenizer, data) 296 297 for line in log_line.decode(errors="surrogateescape").splitlines(): 298 _DEVICE_LOG.info(line) 299 300 301def main() -> int: 302 return console(**vars(_parse_args())) 303 304 305if __name__ == '__main__': 306 sys.exit(main()) 307