diff --git a/client/eepprog.py b/client/eepprog.py index 58837d0..2a9641f 100755 --- a/client/eepprog.py +++ b/client/eepprog.py @@ -1,25 +1,26 @@ #!/usr/bin/env python3 import click +import serial.tools.list_ports from helpers import CliContext, Logger, EepromProgrammer +from helpers.click import CategorizedGroup -@click.group() +@click.group(cls=CategorizedGroup) @click.option('--device', '-d', default='/dev/ttyUSB0', show_default=True, metavar='DEVICE', help="Set the serial device") @click.option('--baud', '-b', default=38400, show_default=True, metavar='BAUDRATE', help="Set the baud rate of the serial device") @click.option('--verbose', '-v', is_flag=True, help='Print debug output') @click.pass_context -def eepprog(ctx: click.Context, device: str, baud: int, verbose: bool): +def eepprog(ctx: click.Context, device: str, baud: int, verbose: bool) -> None: # Create dependencies logger = Logger(verbose=verbose) eeprom_programmer = EepromProgrammer(logger=logger, device=device, baudrate=baud) - logger.debug("Creating CLI context (device: {}, bauds: {})".format(device, baud)) - # Create CLI context + logger.debug("Creating CLI context") ctx.obj = CliContext( logger=logger, eeprom_programmer=eeprom_programmer, @@ -29,9 +30,9 @@ def eepprog(ctx: click.Context, device: str, baud: int, verbose: bool): ctx.call_on_close(eeprom_programmer.close) -@eepprog.command('hello', short_help='Say hello. :)') +@eepprog.command('hello', category='Test commands', short_help='Say hello. :)') @click.pass_obj -def hello(context: CliContext): +def hello(context: CliContext) -> None: """ Say hello. :) @@ -46,19 +47,36 @@ def hello(context: CliContext): context.logger.success('yay!') -@eepprog.command('test', short_help="Send INIT command to the programmer and read answer") +@eepprog.command('test', category='Test commands', short_help="Send INIT command to the programmer and read answer") @click.pass_obj -def test(context: CliContext): +def test(context: CliContext) -> None: """ Send an 'INIT' command to the programmer and read the answer. Test command for debugging. """ - context.eeprom_programmer.test() + context.eeprom_programmer.test_command() + + +@eepprog.command('list-devices', category='Helper commands', short_help="List available serial ports") +@click.pass_obj +def list_devices(context: CliContext) -> None: + """ + List serial ports that are available on the system. + + Internally uses 'serial.tools.list_ports' of pySerial. + """ + context.logger.debug("Getting list of serial ports") + ports = serial.tools.list_ports.comports() + + context.logger.debug() # Print empty line + click.echo("Found {} serial ports:".format(len(ports))) + for port in ports: + click.echo('' + str(port)) + -# TODO command: list-devices -> serial.tools.list_ports # TODO shell: Run an interactive shell if __name__ == '__main__': - eepprog() + eepprog(max_content_width=120) diff --git a/client/helpers/EepromProgrammer.py b/client/helpers/EepromProgrammer.py index d529ddc..19f55ec 100644 --- a/client/helpers/EepromProgrammer.py +++ b/client/helpers/EepromProgrammer.py @@ -1,5 +1,5 @@ from serial import Serial -from typing import Optional +from typing import Optional, Union from . import Logger @@ -20,33 +20,49 @@ class EepromProgrammer: self._device_file = device self._baudrate = baudrate - # TODO - def open(self): + def open(self) -> None: """ Open and setup serial port. """ assert self.serial is None, 'Serial port is already opened!' - self.logger.debug("Setting up serial device '{}' with baudrate {}".format(self._device_file, self._baudrate)) + self.logger.debug("Opening serial device '{}' with {} bauds".format(self._device_file, self._baudrate)) self.serial = Serial(self._device_file, self._baudrate) - def close(self): - if self.serial is None: - self.logger.debug("Serial port is already closed") - return + def close(self) -> None: + """ + Closes the serial port. + """ + if self.serial is not None: + self.logger.debug("Closing serial port") + self.serial.close() - self.logger.debug("Closing serial port") - self.serial.close() + def write(self, data: Union[bytes, bytearray]) -> int: + """ + Writes bytes to the serial port and writes debug log. + """ + self.logger.debug('Writing: {}'.format(str(data))) + return self.serial.write(data) # TODO - def test(self): + def test_command(self) -> None: # Open serial port if self.serial is None: self.open() + # TODO where to do this? in open() or when needed? + self.serial.timeout = 1 + # Write a test command - self.logger.info("Sending 'INIT' ...") - self.serial.write(b"INIT\n") + self.logger.info("Sending INIT command ...") + self.write(b'INIT BINARY\n') # Just read some stuff - self.logger.info("Read line: ", self.serial.readline(80)) + self.logger.info("Received line: ", self.serial.readline(80)) + + # Send a READ command + self.logger.info("Sending READ command ...") + self.write(b'READ 0000:0010\n') + + while True: + self.logger.info("Received line: ", self.serial.readline(80)) diff --git a/client/helpers/click.py b/client/helpers/click.py new file mode 100644 index 0000000..ff38a6a --- /dev/null +++ b/client/helpers/click.py @@ -0,0 +1,69 @@ +import click +import collections + + +class CategorizedGroup(click.Group): + """ + Click command group that categorizes subcommands in the help text by the additional 'category' attribute. + Commands that don't have a 'category' attribute are grouped under the default category "Commands" as usual. + + Example usage: + `@categorized_group.command('foobar', category='Debug commands')` + """ + default_category: str + + def __init__(self, name=None, commands=None, default_category: str = 'Commands', **attrs): + super().__init__(name, commands, **attrs) + self.commands = commands or collections.OrderedDict() + self.default_category = default_category + + def list_commands(self, ctx): + """ List commands in the order they were added to the group. """ + return self.commands + + def command(self, *args, **kwargs): + """ Extends the command decorator by setting a category attribute on the command. """ + category = kwargs.pop('category', None) + orig_decorator = super().command(*args, **kwargs) + + def decorator(f): + cmd = orig_decorator(f) + if category: + cmd.category = category + return cmd + + return decorator + + def format_commands(self, ctx, formatter): + """ + Prints commands in help messages. + This version groups them into categories and is based on the original Group.format_commands method. + """ + categorized_commands = {self.default_category: []} + + # Collect subcommands grouped by category + for subcommand in self.list_commands(ctx): + cmd = self.get_command(ctx, subcommand) + if cmd is None or cmd.hidden: + continue + + category = getattr(cmd, 'category', self.default_category) + if categorized_commands.get(category) is None: + categorized_commands[category] = [] + + categorized_commands[category].append((subcommand, cmd)) + + # Print help sections for each subcommand category + for category, subcommands in categorized_commands.items(): + if not len(subcommands): + continue + + limit = formatter.width - 6 - max(len(cmd[0]) for cmd in subcommands) + + rows = [] + for subcommand, cmd in subcommands: + help_str = cmd.get_short_help_str(limit) + rows.append((subcommand, help_str)) + + with formatter.section(category): + formatter.write_dl(rows)