• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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