| # Copyright 2023 The Pigweed Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| # use this file except in compliance with the License. You may obtain a copy of |
| # the License at |
| # |
| # https://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations under |
| # the License. |
| """Write a binary over serial to provision the Gonk FPGA.""" |
| |
| import argparse |
| from itertools import islice |
| import logging |
| import operator |
| from pathlib import Path |
| import sys |
| from typing import Iterable |
| |
| import serial |
| from serial import Serial |
| from serial.tools.list_ports import comports |
| from serial.tools.miniterm import Miniterm, Transform |
| |
| from pw_cli import log as pw_cli_log |
| from pw_console import python_logging |
| import pw_cli.color |
| import pw_log.log_decoder |
| from pw_tokenizer import ( |
| database as pw_tokenizer_database, |
| detokenize, |
| tokens, |
| ) |
| |
| from gonk_tools.gonk_log_stream import GonkLogStream |
| from gonk_tools.firmware_files import ( |
| BUNDLED_ELF, |
| BUNDLED_FPGA_BINFILE, |
| bundled_elf_path, |
| bundled_fpga_binfile_path, |
| ) |
| |
| _ROOT_LOG = logging.getLogger() |
| _LOG = logging.getLogger('host') |
| _DEVICE_LOG = logging.getLogger('gonk') |
| _CSV_LOG = logging.getLogger('gonk.csv') |
| |
| _COLOR = pw_cli.color.colors() |
| |
| DEFAULT_HOST_LOGFILE = 'gonk-host-log.txt' |
| DEFAULT_DEVICE_LOGFILE = 'gonk-device-log.txt' |
| DEFAULT_CSV_LOGFILE = 'gonk-csv-log.txt' |
| |
| |
| def _parse_args(): |
| parser = argparse.ArgumentParser( |
| description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| ) |
| parser.add_argument( |
| '--databases', |
| metavar='elf_or_token_database', |
| action=pw_tokenizer_database.LoadTokenDatabases, |
| nargs='+', |
| help=( |
| 'ELF or token database files from which to read strings and ' |
| 'tokens. For ELF files, the tokenization domain to read from ' |
| 'may specified after the path as #domain_name (e.g. ' |
| 'foo.elf#TEST_DOMAIN). Unless specified, only the default ' |
| 'domain ("") is read from ELF files; .* reads all domains. ' |
| 'Globs are expanded to compatible database files.' |
| ), |
| ) |
| parser.add_argument( |
| '-p', |
| '--port', |
| type=str, |
| help='Serial port path.', |
| ) |
| parser.add_argument( |
| '-b', |
| '--baudrate', |
| type=int, |
| default=2000000, |
| help='Sets the baudrate for serial communication.', |
| ) |
| parser.add_argument( |
| '--product', |
| default='GENERIC_F730R8TX', |
| help='Use first serial port matching this product name.', |
| ) |
| parser.add_argument( |
| '--serial-number', |
| help='Use the first serial port matching this number.', |
| ) |
| parser.add_argument( |
| '--bitstream-file', |
| type=Path, |
| help=( |
| 'FPGA Bitstream file. Can be a filesystem path or "DEFAULT" to ' |
| 'use a Gonk Python tools bundled binary file.' |
| ), |
| ) |
| parser.add_argument( |
| '--logfile', |
| type=Path, |
| default=Path(DEFAULT_HOST_LOGFILE), |
| help=( |
| 'Default log file. This will contain host side ' |
| 'log messages only unles the ' |
| '--merge-device-and-host-logs argument is used.' |
| f'Default: "{DEFAULT_HOST_LOGFILE}"' |
| ), |
| ) |
| parser.add_argument( |
| '--merge-device-and-host-logs', |
| action='store_true', |
| help=( |
| 'Include device logs in the default --logfile.' |
| 'These are normally shown in a separate device ' |
| 'only log file.' |
| ), |
| ) |
| parser.add_argument( |
| '--log-colors', |
| action=argparse.BooleanOptionalAction, |
| help=('Colors in log messages.'), |
| ) |
| parser.add_argument( |
| '--log-to-stderr', |
| action='store_true', |
| help=('Log to STDERR'), |
| ) |
| parser.add_argument( |
| '--host-logfile', |
| type=Path, |
| help=( |
| 'Additional host only log file. Normally all logs in the ' |
| 'default logfile are host only.' |
| ), |
| ) |
| parser.add_argument( |
| '--device-logfile', |
| type=Path, |
| default=Path(DEFAULT_DEVICE_LOGFILE), |
| help=f'Device only log file. Default: "{DEFAULT_DEVICE_LOGFILE}"', |
| ) |
| parser.add_argument( |
| '--json-logfile', |
| type=Path, |
| help='Device only JSON formatted log file.', |
| ) |
| parser.add_argument( |
| '--csv-logfile', |
| type=Path, |
| default=Path(DEFAULT_CSV_LOGFILE), |
| help=( |
| 'Write ADC data to a CSV formatted log file. ' |
| f'Default: "{DEFAULT_CSV_LOGFILE}"' |
| ), |
| ) |
| parser.add_argument( |
| '--truncate-logfiles', |
| action='store_true', |
| help=('Truncate logfiles on before logging.'), |
| ) |
| |
| return parser.parse_args() |
| |
| |
| def get_serial_port( |
| product: str | None = None, |
| serial_number: str | None = None, |
| ) -> str: |
| """Return serial ports that match give serial numbers or product names.""" |
| |
| ports = sorted(comports(), key=operator.attrgetter('device')) |
| |
| # Return devices matching serial numbers first. |
| for port in ports: |
| if ( |
| serial_number is not None |
| and port.serial_number is not None |
| and serial_number in port.serial_number |
| ): |
| return port.device |
| # If no matching serial numbers, check for matching product names. |
| for port in ports: |
| if ( |
| product is not None |
| and port.product is not None |
| and product in port.product |
| ): |
| return port.device |
| |
| # No matches found. |
| return '' |
| |
| |
| FILE_LENGTH = 135100 |
| FILE_START_STR = 'FF 00 00 FF' |
| SYNC_START_STR = '7E AA 99 7E' |
| FILE_START_BYTES = bytes.fromhex(FILE_START_STR) |
| SYNC_START_BYTES = bytes.fromhex(SYNC_START_STR) |
| |
| |
| class UnknownSerialDevice(Exception): |
| """Exception raised when no device is specified.""" |
| |
| |
| class IncorrectBinaryFormat(Exception): |
| """Exception raised when FPGA bitstream file is in an unexpected format.""" |
| |
| |
| def batched(iterable, n): |
| """Batch data into tuples of length n. The last batch may be shorter. |
| |
| Example usage: |
| |
| .. code-block:: pycon |
| |
| >>> list(batched('ABCDEFG', 3)) |
| ['ABC', 'DEF', 'G'] |
| """ |
| if n < 1: |
| raise ValueError('n must be at least one') |
| it = iter(iterable) |
| while batch := tuple(islice(it, n)): |
| yield batch |
| |
| |
| class HandleBinaryData(Transform): |
| """Miniterm transform to handle incoming byte data.""" |
| |
| def __init__(self, gonk_log_stream: GonkLogStream) -> None: |
| self.gonk_log_stream = gonk_log_stream |
| |
| def rx(self, text: str, data: bytes | None = None) -> str: |
| """Text received from the serial port.""" |
| if data: |
| self.gonk_log_stream.write(data) |
| return '' |
| return text |
| |
| |
| class DebugSerialIO(Transform): |
| """Print sent and received data to stderr.""" |
| |
| def rx(self, text: str) -> str: |
| """Text received from the serial port.""" |
| sys.stderr.write('[Recv: {!r}] '.format(text)) |
| sys.stderr.flush() |
| return text |
| |
| def tx(self, text: str): |
| """Text to be sent to the serial port.""" |
| sys.stderr.write(' [Send: {!r}] '.format(text)) |
| sys.stderr.flush() |
| return text |
| |
| def echo(self, text: str): |
| """Text to be sent but displayed on console.""" |
| return text |
| |
| |
| class MinitermBinary(Miniterm): |
| def reader(self): |
| """loop and copy serial->console""" |
| # pylint: disable=too-many-nested-blocks |
| try: |
| while self.alive and self._reader_alive: |
| # Read all that is there or wait for one byte |
| data = self.serial.read(self.serial.in_waiting or 1) |
| if data: |
| if self.raw: |
| self.console.write_bytes(data) |
| else: |
| text = self.rx_decoder.decode(data) |
| for transformation in self.rx_transformations: |
| if isinstance(transformation, HandleBinaryData): |
| text = transformation.rx(text, data) |
| else: |
| text = transformation.rx(text) |
| self.console.write(text) |
| except serial.SerialException: |
| self.alive = False |
| self.console.cancel() |
| raise |
| |
| |
| def load_bitstream_file(bitstream_file: Path | None) -> bytes: |
| """Check for valid bitstream file and load it as bytes.""" |
| bitstream_bytes = b'' |
| |
| if not bitstream_file or str(bitstream_file) == 'DEFAULT': |
| if not BUNDLED_FPGA_BINFILE: |
| raise FileNotFoundError('No default bitstream file is available.') |
| |
| bitstream_path = bundled_fpga_binfile_path() |
| if not bitstream_path: |
| raise FileNotFoundError('No default bitstream file is available.') |
| |
| bitstream_bytes = bitstream_path.read_bytes() |
| else: |
| if not bitstream_file.is_file(): |
| raise FileNotFoundError(f'\nUnable to load "{bitstream_file}"') |
| bitstream_bytes = bitstream_file.read_bytes() |
| |
| if ( |
| len(bitstream_bytes) != 135100 |
| or bitstream_bytes[0:8] != FILE_START_BYTES + SYNC_START_BYTES |
| ): |
| raise IncorrectBinaryFormat( |
| f'the bitstream file must be:\n' |
| f' {FILE_LENGTH} bytes in length\n' |
| f' Start with "{FILE_START_STR} {SYNC_START_STR}"' |
| ) |
| |
| return bitstream_bytes |
| |
| |
| def write_bitstream_file( |
| bitstream_bytes: bytes, serial_instance: Serial |
| ) -> None: |
| """Write a series of bytes to serial.""" |
| |
| # Write out the bitstream in batches. |
| _LOG.info('Sending bitstream...') |
| written_bytes: int = 0 |
| for byte_batch in batched(bitstream_bytes, 8): |
| result = serial_instance.write(byte_batch) |
| if result: |
| written_bytes += result |
| serial_instance.flush() |
| _LOG.info('Done sending bitstream. Wrote %d', written_bytes) |
| |
| |
| class FpgaProvisioner: |
| """Handles the Gonk FPGA provision process.""" |
| |
| def __init__( |
| self, |
| bitstream_file: Path | None, |
| serial_instance: Serial, |
| ) -> None: |
| self.fpga_provisioned = False |
| self.bitstream_bytes = load_bitstream_file(bitstream_file) |
| self.serial_instance = serial_instance |
| |
| def log_handler(self, log: pw_log.log_decoder.Log) -> None: |
| """Listen to Gonk logs and start the provision process when needed.""" |
| if self.fpga_provisioned: |
| return |
| |
| if 'Waiting for bitstream' not in log.message: |
| return |
| |
| # Send the bitstream_file. |
| write_bitstream_file(self.bitstream_bytes, self.serial_instance) |
| self.fpga_provisioned = True |
| |
| |
| def gonk_main( |
| # pylint: disable=too-many-arguments,too-many-locals |
| baudrate: int, |
| databases: Iterable, |
| bitstream_file: Path | None, |
| port: str | None = None, |
| product: str | None = None, |
| serial_number: str | None = None, |
| logfile: Path | None = None, |
| host_logfile: Path | None = None, |
| device_logfile: Path | None = None, |
| json_logfile: Path | None = None, |
| csv_logfile: Path | None = None, |
| log_colors: bool | None = False, |
| merge_device_and_host_logs: bool = False, |
| verbose: bool = False, |
| log_to_stderr: bool = False, |
| truncate_logfiles: bool = False, |
| ) -> int: |
| """Write a bitstream file over serial while monitoring output.""" |
| |
| if not databases and BUNDLED_ELF: |
| databases = [] |
| elf_file = bundled_elf_path() |
| if elf_file: |
| databases.append( |
| pw_tokenizer_database.load_token_database(elf_file) |
| ) |
| |
| if not logfile: |
| # Create a temp logfile to prevent logs from appearing over stdout. This |
| # would corrupt the prompt toolkit UI. |
| logfile = Path(python_logging.create_temp_log_file()) |
| |
| if truncate_logfiles: |
| logfile.write_text('', encoding='utf-8') |
| |
| if log_colors is None: |
| log_colors = True |
| colors = pw_cli.color.colors(log_colors) |
| log_level = logging.DEBUG if verbose else logging.INFO |
| logger_name_format = colors.cyan('%(name)s') |
| logger_message_format = f'[{logger_name_format}] %(levelname)s %(message)s' |
| |
| pw_cli_log.install( |
| level=log_level, |
| use_color=log_colors, |
| hide_timestamp=False, |
| log_file=logfile, |
| message_format=logger_message_format, |
| ) |
| |
| if device_logfile: |
| if truncate_logfiles: |
| device_logfile.write_text('', encoding='utf-8') |
| pw_cli_log.install( |
| level=log_level, |
| use_color=log_colors, |
| hide_timestamp=False, |
| log_file=device_logfile, |
| logger=_DEVICE_LOG, |
| message_format=logger_message_format, |
| ) |
| if host_logfile: |
| if truncate_logfiles: |
| host_logfile.write_text('', encoding='utf-8') |
| pw_cli_log.install( |
| level=log_level, |
| use_color=log_colors, |
| hide_timestamp=False, |
| log_file=host_logfile, |
| logger=_ROOT_LOG, |
| message_format=logger_message_format, |
| ) |
| |
| # By default don't send device logs to the root logger. |
| _DEVICE_LOG.propagate = False |
| if merge_device_and_host_logs: |
| # Add device logs to the default logfile. |
| pw_cli_log.install( |
| level=log_level, |
| use_color=log_colors, |
| hide_timestamp=False, |
| log_file=logfile, |
| logger=_DEVICE_LOG, |
| message_format=logger_message_format, |
| ) |
| |
| if json_logfile: |
| if truncate_logfiles: |
| json_logfile.write_text('', encoding='utf-8') |
| json_filehandler = logging.FileHandler(json_logfile, encoding='utf-8') |
| json_filehandler.setLevel(log_level) |
| json_filehandler.setFormatter(python_logging.JsonLogFormatter()) |
| _DEVICE_LOG.addHandler(json_filehandler) |
| |
| # Don't send CSV lines to the root logger. |
| _CSV_LOG.propagate = False |
| if csv_logfile: |
| if truncate_logfiles: |
| csv_logfile.write_text('', encoding='utf-8') |
| csv_filehandler = logging.FileHandler(csv_logfile, encoding='utf-8') |
| csv_filehandler.setLevel(logging.NOTSET) |
| csv_filehandler.setFormatter(logging.Formatter(fmt='%(message)s')) |
| _CSV_LOG.addHandler(csv_filehandler) |
| |
| if log_to_stderr: |
| pw_cli_log.install( |
| level=log_level, |
| use_color=log_colors, |
| hide_timestamp=False, |
| message_format=logger_message_format, |
| ) |
| |
| _LOG.setLevel(log_level) |
| _DEVICE_LOG.setLevel(log_level) |
| _ROOT_LOG.setLevel(log_level) |
| |
| # Init serial port. |
| if port is None: |
| port = get_serial_port(product=product, serial_number=serial_number) |
| |
| if not port: |
| raise UnknownSerialDevice( |
| 'No --serial-number --product or --port path provided.' |
| ) |
| |
| serial_instance = Serial(port=port, baudrate=baudrate, timeout=0.1) |
| |
| detokenizer = detokenize.Detokenizer( |
| tokens.Database.merged(*databases), show_errors=True |
| ) |
| |
| # Use pyserial miniterm to monitor recieved data. |
| miniterm = MinitermBinary( |
| serial_instance, |
| echo=True, |
| eol='lf', |
| filters=(), |
| ) |
| |
| # Use Ctrl-C as the exit character. (Miniterm default is Ctrl-]) |
| miniterm.exit_character = chr(0x03) |
| miniterm.set_rx_encoding('utf-8', errors='backslashreplace') |
| miniterm.set_tx_encoding('utf-8') |
| |
| log_message_handlers = [] |
| fpga_provisioner = FpgaProvisioner(bitstream_file, serial_instance) |
| log_message_handlers.append(fpga_provisioner.log_handler) |
| |
| gonk_log_stream = GonkLogStream( |
| detokenizer, log_message_handlers=log_message_handlers |
| ) |
| miniterm.rx_transformations.append(HandleBinaryData(gonk_log_stream)) |
| miniterm.tx_transformations.append(DebugSerialIO()) |
| |
| # Start monitoring serial data. |
| miniterm.start() |
| |
| # Wait for ctrl-c, then shutdown miniterm. |
| try: |
| miniterm.join(True) |
| except KeyboardInterrupt: |
| pass |
| sys.stderr.write('\n--- exit ---\n') |
| miniterm.join() |
| miniterm.close() |
| |
| return 0 |
| |
| |
| def main(): |
| sys.exit(gonk_main(**vars(_parse_args()))) |
| |
| |
| if __name__ == '__main__': |
| main() |