From 4927e0166cf77e6a8915a518d9275ccbf43cfd94 Mon Sep 17 00:00:00 2001 From: Philippe Teuwen Date: Mon, 9 Oct 2023 23:29:05 +0200 Subject: [PATCH] cli: one root CLITree, enhance dump_help --- CHANGELOG.md | 1 + software/script/chameleon_cli_main.py | 92 +++++-------------------- software/script/chameleon_cli_unit.py | 97 ++++++++++++++++++++++++--- software/script/chameleon_utils.py | 41 +++-------- 4 files changed, 115 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 200f1459..6763105c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to this project will be documented in this file. This project uses the changelog in accordance with [keepchangelog](http://keepachangelog.com/). Please use this to write notable changes, which is not the same as git commit log... ## [unreleased][unreleased] + - Changed massively CLI, cf https://github.com/RfidResearchGroup/ChameleonUltra/issues/164#issue-1930580576 (@doegox) - Changed CLI help: lists display and now all commands support `-h` (@doegox) - Added button action to show battery level (@doegox) - Added GUI Page docs (@GameTec-live) diff --git a/software/script/chameleon_cli_main.py b/software/script/chameleon_cli_main.py index 4461e1e7..6a668394 100755 --- a/software/script/chameleon_cli_main.py +++ b/software/script/chameleon_cli_main.py @@ -6,18 +6,18 @@ import colorama import chameleon_cli_unit import chameleon_utils -import os import pathlib import prompt_toolkit -from datetime import datetime from prompt_toolkit.formatted_text import ANSI from prompt_toolkit.history import FileHistory # Colorama shorthands CR = colorama.Fore.RED CG = colorama.Fore.GREEN +CB = colorama.Fore.BLUE CC = colorama.Fore.CYAN CY = colorama.Fore.YELLOW +CM = colorama.Fore.MAGENTA C0 = colorama.Style.RESET_ALL ULTRA = r""" @@ -43,45 +43,13 @@ """ -def dump_help(cmd_node, depth=0, dump_cmd_groups=False, dump_description=False): - visual_col1_width = 28 - col1_width = visual_col1_width + len(f"{CG}{C0}") - if cmd_node.cls: - cmd_title = f"{CG}{cmd_node.fullname}{C0}" - if dump_description: - print(f" {cmd_title}".ljust(col1_width) + f"{cmd_node.help_text}") - else: - print(f" {cmd_title}".ljust(col1_width), end="") - p = cmd_node.cls().args_parser() - assert p is not None - p.prog = " " * (visual_col1_width - len("usage: ") - 1) - usage = p.format_usage().removeprefix("usage: ").strip() - if usage != "[-h]": - usage = usage.removeprefix("[-h] ") - if dump_description: - print(f"{CG}{C0}".ljust(col1_width), end="") - print(f"{CY}{usage}{C0}") - else: - print("") - else: - if dump_cmd_groups: - cmd_title = f"{CY}{cmd_node.fullname}{C0}" - if dump_description: - print(f" {cmd_title}".ljust(col1_width) + f"{{ {cmd_node.help_text}... }}") - else: - print(f" {cmd_title}") - for child in cmd_node.children: - dump_help(child, depth + 1, dump_cmd_groups, dump_description) - - class ChameleonCLI: """ CLI for chameleon """ def __init__(self): - self.completer = chameleon_utils.CustomNestedCompleter.from_nested_dict( - chameleon_cli_unit.root_commands) + self.completer = chameleon_utils.CustomNestedCompleter.from_clitree(chameleon_cli_unit.root) self.session = prompt_toolkit.PromptSession(completer=self.completer, history=FileHistory(pathlib.Path.home() / ".chameleon_history")) @@ -152,59 +120,31 @@ def startCLI(self): except KeyboardInterrupt: closing = True - if closing or cmd_str in ["exit", "quit", "q", "e"]: - print("Bye, thank you. ^.^ ") - self.device_com.close() - sys.exit(996) - elif cmd_str == "clear": - os.system('clear' if os.name == 'posix' else 'cls') - continue - elif cmd_str == "dumphelp": - for _, cmd_node in chameleon_cli_unit.root_commands.items(): - dump_help(cmd_node) - continue - elif cmd_str == "": + # look for alternate exit + if closing or cmd_str in ["quit", "q", "e"]: + cmd_str = 'exit' + + # empty line + if cmd_str == "": continue + # look for alternate comments + if cmd_str[0] in ";#%": + cmd_str = 'rem ' + cmd_str[1:].lstrip() + # parse cmd argv = cmd_str.split() - root_cmd = argv[0] - # look for comments - if root_cmd == "rem" or root_cmd[0] in ";#%": - # precision: second - # iso_timestamp = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') - # precision: nanosecond (note that the comment will take some time too, ~75ns, check your system) - iso_timestamp = datetime.utcnow().isoformat() + 'Z' - if root_cmd[0] in ";#%": - comment = ' '.join([root_cmd[1:]]+argv[1:]).strip() - else: - comment = ' '.join(argv[1:]).strip() - print(f"{iso_timestamp} remark: {comment}") - continue - if root_cmd not in chameleon_cli_unit.root_commands: - # No matching command group - print("".ljust(18, "-") + "".ljust(10) + "".ljust(30, "-")) - for cmd_name, cmd_node in chameleon_cli_unit.root_commands.items(): - print(f" - {CG}{cmd_name}{C0}".ljust(37) + f"{{ {cmd_node.help_text}... }}") - print(f" - {CG}clear{C0}".ljust(37) + "Clear screen") - print(f" - {CG}exit{C0}".ljust(37) + "Exit program") - print(f" - {CG}rem ...{C0}".ljust(37) + "Display a comment with a timestamp") - continue - - tree_node, arg_list = self.get_cmd_node( - chameleon_cli_unit.root_commands[root_cmd], argv[1:]) + tree_node, arg_list = self.get_cmd_node(chameleon_cli_unit.root, argv) if not tree_node.cls: # Found tree node is a group without an implementation, print children print("".ljust(18, "-") + "".ljust(10) + "".ljust(30, "-")) for child in tree_node.children: cmd_title = f"{CG}{child.name}{C0}" if not child.cls: - help_line = (f" - {cmd_title}".ljust(37) - ) + f"{{ {child.help_text}... }}" + help_line = (f" - {cmd_title}".ljust(37)) + f"{{ {child.help_text}... }}" else: - help_line = (f" - {cmd_title}".ljust(37) - ) + f"{child.help_text}" + help_line = (f" - {cmd_title}".ljust(37)) + f"{child.help_text}" print(help_line) continue diff --git a/software/script/chameleon_cli_unit.py b/software/script/chameleon_cli_unit.py index 8d89ba33..5ccb6fbe 100644 --- a/software/script/chameleon_cli_unit.py +++ b/software/script/chameleon_cli_unit.py @@ -7,6 +7,7 @@ import timeit import sys import time +from datetime import datetime import serial.tools.list_ports import threading import struct @@ -22,8 +23,10 @@ # Colorama shorthands CR = colorama.Fore.RED CG = colorama.Fore.GREEN +CB = colorama.Fore.BLUE CC = colorama.Fore.CYAN CY = colorama.Fore.YELLOW +CM = colorama.Fore.MAGENTA C0 = colorama.Style.RESET_ALL # NXP IDs based on https://www.nxp.com/docs/en/application-note/AN10833.pdf @@ -79,7 +82,7 @@ def before_exec(self, args: argparse.Namespace): Call a function before exec cmd. :return: function references """ - raise NotImplementedError("Please implement this") + return True def on_exec(self, args: argparse.Namespace): """ @@ -368,21 +371,100 @@ def on_exec(self, args: argparse.Namespace): raise NotImplementedError() -hw = CLITree('hw', 'Hardware-related commands') +root = CLITree(root=True) +hw = root.subgroup('hw', 'Hardware-related commands') hw_slot = hw.subgroup('slot', 'Emulation slots commands') -hw_ble = hw.subgroup('ble', 'Bluetooth low energy commands') hw_settings = hw.subgroup('settings', 'Chameleon settings commands') -hf = CLITree('hf', 'High Frequency commands') +hf = root.subgroup('hf', 'High Frequency commands') hf_14a = hf.subgroup('14a', 'ISO14443-a commands') hf_mf = hf.subgroup('mf', 'MIFARE Classic commands') hf_mfu = hf.subgroup('mfu', 'MIFARE Ultralight / NTAG commands') -lf = CLITree('lf', 'Low Frequency commands') +lf = root.subgroup('lf', 'Low Frequency commands') lf_em = lf.subgroup('em', 'EM commands') lf_em_410x = lf_em.subgroup('410x', 'EM410x commands') -root_commands: dict[str, CLITree] = {'hw': hw, 'hf': hf, 'lf': lf} +@root.command('clear') +class RootClear(BaseCLIUnit): + def args_parser(self) -> ArgumentParserNoExit: + parser = ArgumentParserNoExit() + parser.description = 'Clear screen' + return parser + + def on_exec(self, args: argparse.Namespace): + os.system('clear' if os.name == 'posix' else 'cls') + + +@root.command('rem') +class RootRem(BaseCLIUnit): + def args_parser(self) -> ArgumentParserNoExit: + parser = ArgumentParserNoExit() + parser.description = 'Timestamped comment' + parser.add_argument('comment', nargs='*', help='Your comment') + return parser + + def on_exec(self, args: argparse.Namespace): + # precision: second + # iso_timestamp = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') + # precision: nanosecond (note that the comment will take some time too, ~75ns, check your system) + iso_timestamp = datetime.utcnow().isoformat() + 'Z' + comment = ' '.join(args.comment) + print(f"{iso_timestamp} remark: {comment}") + + +@root.command('exit') +class RootExit(BaseCLIUnit): + def args_parser(self) -> ArgumentParserNoExit: + parser = ArgumentParserNoExit() + parser.description = 'Exit client' + return parser + + def on_exec(self, args: argparse.Namespace): + print("Bye, thank you. ^.^ ") + self.device_com.close() + sys.exit(996) + + +@root.command('dump_help') +class RootDumpHelp(BaseCLIUnit): + def args_parser(self) -> ArgumentParserNoExit: + parser = ArgumentParserNoExit() + parser.description = 'Dump available commands' + parser.add_argument('-d', '--show-desc', action='store_true', help="Dump full command description") + parser.add_argument('-g', '--show-groups', action='store_true', help="Dump command groups as well") + return parser + + @staticmethod + def dump_help(cmd_node, depth=0, dump_cmd_groups=False, dump_description=False): + visual_col1_width = 28 + col1_width = visual_col1_width + len(f"{CG}{C0}") + if cmd_node.cls: + cmd_title = f"{CG}{cmd_node.fullname}{C0}" + print(f"{cmd_title}".ljust(col1_width), end="") + p = cmd_node.cls().args_parser() + assert p is not None + p.prog = " " * (visual_col1_width - len("usage: ") - 1) + usage = p.format_usage().removeprefix("usage: ").strip() + print(f"{CY}{usage}{C0}") + if dump_description: + help = p.format_help().splitlines() + # Remove usage as we already printed it + while (help[0] != ''): + help.pop(0) + print('\n'.join(help)) + print() + else: + if dump_cmd_groups and not cmd_node.root: + cmd_title = f"{CB}== {cmd_node.fullname} =={C0}" + print(f"{cmd_title}") + if dump_description: + print(f"\n{cmd_node.help_text}\n") + for child in cmd_node.children: + RootDumpHelp.dump_help(child, depth + 1, dump_cmd_groups, dump_description) + + def on_exec(self, args: argparse.Namespace): + self.dump_help(root, dump_cmd_groups=args.show_groups, dump_description=args.show_desc) @hw.command('connect') @@ -393,9 +475,6 @@ def args_parser(self) -> ArgumentParserNoExit: parser.add_argument('-p', '--port', type=str, required=False) return parser - def before_exec(self, args: argparse.Namespace): - return True - def on_exec(self, args: argparse.Namespace): try: if args.port is None: # Chameleon auto-detect if no port is supplied diff --git a/software/script/chameleon_utils.py b/software/script/chameleon_utils.py index 96d27c4c..5e0fa8e0 100644 --- a/software/script/chameleon_utils.py +++ b/software/script/chameleon_utils.py @@ -79,13 +79,14 @@ class CLITree: :param cls: A BaseCLIUnit instance handling the command """ - def __init__(self, name=None, help_text=None, fullname=None, children=None, cls=None) -> None: + def __init__(self, name=None, help_text=None, fullname=None, children=None, cls=None, root=False) -> None: self.name: str = name self.help_text: str = help_text self.fullname: str = fullname if fullname else name self.children: list[CLITree] = children if children else list() self.cls = cls - if self.help_text is None: + self.root = root + if self.help_text is None and not root: assert self.cls is not None parser = self.cls().args_parser() assert parser is not None @@ -99,7 +100,9 @@ def subgroup(self, name, help_text=None): :param help_text: Hint displayed for the group """ child = CLITree( - name=name, fullname=f'{self.fullname} {name}', help_text=help_text) + name=name, + fullname=f'{self.fullname} {name}' if not self.root else f'{name}', + help_text=help_text) self.children.append(child) return child @@ -110,8 +113,10 @@ def command(self, name): :param name: Name of the command """ def decorator(cls): - self.children.append( - CLITree(name=name, fullname=f'{self.fullname} {name}', cls=cls)) + self.children.append(CLITree( + name=name, + fullname=f'{self.fullname} {name}' if not self.root else f'{name}', + cls=cls)) return cls return decorator @@ -132,32 +137,6 @@ def __init__( def __repr__(self) -> str: return f"CustomNestedCompleter({self.options!r}, ignore_case={self.ignore_case!r})" - @classmethod - def from_nested_dict(cls, data): - options = {} - meta_dict = {} - for key, value in data.items(): - if isinstance(value, Completer): - options[key] = value - elif isinstance(value, dict): - options[key] = cls.from_nested_dict(value) - elif isinstance(value, set): - options[key] = cls.from_nested_dict( - {item: None for item in value}) - elif isinstance(value, CLITree): - if value.cls: - # CLITree is a standalone command - options[key] = ArgparseCompleter(value.cls().args_parser()) - else: - # CLITree is a command group - options[key] = cls.from_clitree(value) - meta_dict[key] = value.help_text - else: - assert value is None - options[key] = None - - return cls(options, meta_dict=meta_dict) - @classmethod def from_clitree(cls, node): options = {}