• 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
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