Skip to content

Commit

Permalink
cli: one root CLITree, enhance dump_help
Browse files Browse the repository at this point in the history
  • Loading branch information
doegox committed Oct 9, 2023
1 parent 9106dea commit 4927e01
Show file tree
Hide file tree
Showing 4 changed files with 115 additions and 116 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
92 changes: 16 additions & 76 deletions software/script/chameleon_cli_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand All @@ -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"))

Expand Down Expand Up @@ -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

Expand Down
97 changes: 88 additions & 9 deletions software/script/chameleon_cli_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import timeit
import sys
import time
from datetime import datetime
import serial.tools.list_ports
import threading
import struct
Expand All @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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')
Expand All @@ -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
Expand Down
41 changes: 10 additions & 31 deletions software/script/chameleon_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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 = {}
Expand Down

0 comments on commit 4927e01

Please sign in to comment.