Files
ChameleonUltra/software/script/chameleon_utils.py
T
Philippe Teuwen 8499535aad Clarify protocol. Disruptive changes: see below
This huge commit tries to enhance several things related to the fw/cli protocol.
Generally, the idea is to be verbose, explicit and reuse conventions, in order to enhance code maintainability and understandability for the other contributors.

docs/protocol.md got heavily updated

Many commands have been renamed for consistency. you are invited to adapt your client for easier maintenance

Guidelines, also written in docs/protocol.md "New data payloads: guidelines for developers":
- Now protocol data exchanged over USB or BLE are defined in netdata.h as packed structs and values are stored in Network byte order (=Big Endian)
- Command-specific payloads are defined in their respective cmd_processor handler in app_cmd.c and chameleon_cmd.py
- Define C `struct` for cmd/resp data greater than a single byte, use and abuse of `struct.pack`/`struct.unpack` in Python. So one can understand the payload format at a simple glimpse.
- If single byte of data to return, still use a 1-byte `data`, not `status`.
- Use unambiguous types such as `uint16_t`, not `int` or `enum`. Cast explicitly `int` and `enum` to `uint_t` of proper size
- Use Network byte order for 16b and 32b integers
  - Macros `U16NTOHS`, `U32NTOHL` must be used on reception of a command payload.
  - Macros `U16HTONS`, `U32HTONL` must be used on creation of a response payload.
  - In Python, use the modifier `!` with all `struct.pack`/`struct.unpack`
- Concentrate payload parsing in the handlers, avoid further parsing in their callers. This is true for the firmware and the client.
- In cmd_processor handlers: don't reuse input `length`/`data` parameters for creating the response content
- Avoid hardcoding offsets, use `sizeof()`, `offsetof(struct, field)` in C and `struct.calcsize()` in Python
- Use the exact same command and fields names in firmware and in client, use function names matching the command names for their handlers unless there is a very good reason not to do so. This helps grepping around. Names must start with a letter, not a number, because some languages require it (e.g. `14a_scan` not possible in Python)
- Respect commands order in `m_data_cmd_map`, `data_cmd.h` and `chameleon_cmd.py` definitions
- Even if a command is not yet implemented in firmware or in client but a command number is allocated, add it to `data_cmd.h` and `chameleon_cmd.py` with some `FIXME: to be implemented` comment
- Validate data before using it, both when receiving command data in the firmware and when receiving response data in the client.
- Validate response status in client.

Disruptive changes:
- GET_DEVICE_CAPABILITIES: list of cmds in data are now really Big Endian
  Note: the initial attempt to use macros PP_HTONS were actually considering wrongly that the platform was Big Endian (BYTE_ORDER was actually undefined) while it is actually Little Endian.
- GET_APP_VERSION: response is now a tuple of bytes: major|minor (previously it was in reversed order as a single uint16_t in Little Endian)
- SET_SLOT_TAG_TYPE: tag_type now on 2 bytes, to prepare remapping of its enum
- SET_SLOT_DATA_DEFAULT: tag_type now on 2 bytes, to prepare remapping of its enum
- GET_SLOT_INFO: tag_type now on 2 bytes, to prepare remapping of its enum
- GET_DEVICE_CHIP_ID: now returns its 64b ID following Network byte order (previously, bytes were in the reverse order)
- GET_DEVICE_ADDRESS: now returns its 56b address following Network byte order (previously, bytes were in the reverse order). CLI does not reverse the response anymore so it displays the same value as before.
- MF1_GET_DETECTION_COUNT: now returns its 32b value following Network byte order (previously Little Endian)
- GET_GIT_VERSION response status is now STATUS_DEVICE_SUCCESS
- GET_DEVICE_MODEL response status is now STATUS_DEVICE_SUCCESS
- MF1_READ_EMU_BLOCK_DATA response status is now STATUS_DEVICE_SUCCESS
- GET_DEVICE_CAPABILITIES response status is now STATUS_DEVICE_SUCCESS
- HF14A_SCAN: entirely new response format, room for ATS and multiple tags
- MF1_DETECT_SUPPORT response status is now HF_TAG_OK and support is indicated as bool in 1 byte of data
- MF1_DETECT_PRNG response status is now HF_TAG_OK and prng_type is returned in 1 byte of data with a new enum mf1_prng_type_t == MifareClassicPrngType
- MF1_DETECT_DARKSIDE response status is now HF_TAG_OK and darkside_status is returned in 1 byte of data with a new enum mf1_darkside_status_t == MifareClassicDarksideStatus
- MF1_DARKSIDE_ACQUIRE response status is now HF_TAG_OK and darkside_status is returned in 1 byte of data. If OK, followed by 24 bytes as previously
- MF1_GET_ANTI_COLL_DATA: in case slot does not contain anticoll data, instead of STATUS_PAR_ERR, now it returns STATUS_DEVICE_SUCCESS with empty data
- MF1_SET_ANTI_COLL_DATA and MF1_GET_ANTI_COLL_DATA now use the same data format as HF14A_SCAN

For clients to detect Ultra/Lite with older firmwares, one can issue the GET_APP_VERSION and urge the user to flash his device if needed.
On older firmwares, it will return a status=b'\x00' and data=b'\x00\x01' while up-to-date firmwares will return status=STATUS_DEVICE_SUCCESS and data greater or equal to b'\x01\x00' (v1.0).

Other changes: cf CHANGELOG, and probably a few small changes I forgot about..

TODO:
- remap `tag_specific_type_t` enum to allow future tags (e.g. LF tags) without reshuffling enum and affecting users stored cards
- TEST!
2023-09-18 00:53:39 +02:00

279 lines
9.4 KiB
Python

import argparse
from functools import wraps
from typing import Union
from prompt_toolkit.completion import Completer, NestedCompleter, WordCompleter
from prompt_toolkit.completion.base import Completion
from prompt_toolkit.document import Document
import chameleon_status
class ArgsParserError(Exception):
pass
class ParserExitIntercept(Exception):
pass
class UnexpectedResponseError(Exception):
"""
Unexpected response exception
"""
class ArgumentParserNoExit(argparse.ArgumentParser):
"""
If arg ArgumentParser parse error, we can't exit process,
we must raise exception to stop parse
"""
def __init__(self, **args):
super().__init__(*args)
self.add_help = False
self.description = "Please enter correct parameters"
def exit(self, status: int = ..., message: str or None = ...):
if message:
raise ParserExitIntercept(message)
def error(self, message: str):
args = {'prog': self.prog, 'message': message}
raise ArgsParserError('%(prog)s: error: %(message)s\n' % args)
def expect_response(accepted_responses: Union[int, list[int]]):
"""
Decorator for wrapping a Chameleon CMD function to check its response
for expected return codes and throwing an exception otherwise
"""
if isinstance(accepted_responses, int):
accepted_responses = [accepted_responses]
def decorator(func):
@wraps(func)
def error_throwing_func(*args, **kwargs):
ret = func(*args, **kwargs)
if ret.status not in accepted_responses:
if ret.status in chameleon_status.Device and ret.status in chameleon_status.message:
raise UnexpectedResponseError(
chameleon_status.message[ret.status])
else:
raise UnexpectedResponseError(
f"Unexpected response and unknown status {ret.status}")
return ret.data
return error_throwing_func
return decorator
class CLITree:
"""
Class holding a
:param name: Name of the command (e.g. "set")
:param help_text: Hint displayed for the command
:param fullname: Full name of the command that includes previous commands (e.g. "hw mode set")
:param cls: A BaseCLIUnit instance handling the command
"""
def __init__(self, name=None, help_text=None, fullname=None, children=None, cls=None) -> 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
def subgroup(self, name, help_text=None):
"""
Create a child command group
:param name: Name of the command group
:param help_text: Hint displayed for the group
"""
child = CLITree(
name=name, fullname=f'{self.fullname} {name}', help_text=help_text)
self.children.append(child)
return child
def command(self, name, help_text=None):
"""
Create a child command
:param name: Name of the command
:param help_text: Hint displayed for the command
"""
def decorator(cls):
self.children.append(
CLITree(name=name, fullname=f'{self.fullname} {name}', help_text=help_text, cls=cls))
return cls
return decorator
class CustomNestedCompleter(NestedCompleter):
"""
Copy of the NestedCompleter class that accepts a CLITree object and
supports meta_dict for descriptions
"""
def __init__(
self, options, ignore_case: bool = True, meta_dict: dict = {}
) -> None:
self.options = options
self.ignore_case = ignore_case
self.meta_dict = meta_dict
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 = {}
meta_dict = {}
for child_node in node.children:
if child_node.cls and child_node.cls().args_parser():
# CLITree is a standalone command with arguments
options[child_node.name] = ArgparseCompleter(
child_node.cls().args_parser())
else:
# CLITree is a command group
options[child_node.name] = cls.from_clitree(child_node)
meta_dict[child_node.name] = child_node.help_text
return cls(options, meta_dict=meta_dict)
def get_completions(self, document, complete_event):
# Split document.
text = document.text_before_cursor.lstrip()
stripped_len = len(document.text_before_cursor) - len(text)
# If there is a space, check for the first term, and use a sub_completer.
if " " in text:
first_term = text.split()[0]
completer = self.options.get(first_term)
# If we have a sub completer, use this for the completions.
if completer is not None:
remaining_text = text[len(first_term):].lstrip()
move_cursor = len(text) - len(remaining_text) + stripped_len
new_document = Document(
remaining_text,
cursor_position=document.cursor_position - move_cursor,
)
yield from completer.get_completions(new_document, complete_event)
# No space in the input: behave exactly like `WordCompleter`.
else:
completer = WordCompleter(
list(self.options.keys()), ignore_case=self.ignore_case, meta_dict=self.meta_dict
)
yield from completer.get_completions(document, complete_event)
class ArgparseCompleter(Completer):
"""
Completer instance for autocompletion of ArgumentParser arguments
:param parser: ArgumentParser instance
"""
def __init__(self, parser) -> None:
self.parser: ArgumentParserNoExit = parser
def check_tokens(self, parsed, unparsed):
suggestions = {}
def check_arg(tokens):
return tokens and tokens[0].startswith('-')
if not parsed and not unparsed:
# No tokens detected, just show all flags
for action in self.parser._actions:
for opt in action.option_strings:
suggestions[opt] = action.help
return [], [], suggestions
token = unparsed.pop(0)
for action in self.parser._actions:
if any(opt == token for opt in action.option_strings):
# Argument fully matches the token
parsed.append(token)
if action.choices:
# Autocomplete with choices
if unparsed:
# Autocomplete values
value = unparsed.pop(0)
for choice in action.choices:
if str(choice).startswith(value):
suggestions[str(choice)] = None
parsed.append(value)
if check_arg(unparsed):
parsed, unparsed, suggestions = self.check_tokens(
parsed, unparsed)
else:
# Show all possible values
for choice in action.choices:
suggestions[str(choice)] = None
break
else:
# No choices, process further arguments
if check_arg(unparsed):
parsed, unparsed, suggestions = self.check_tokens(
parsed, unparsed)
break
elif any(opt.startswith(token) for opt in action.option_strings):
for opt in action.option_strings:
if opt.startswith(token):
suggestions[opt] = action.help
if suggestions:
unparsed.insert(0, token)
return parsed, unparsed, suggestions
def get_completions(self, document, complete_event):
text = document.text_before_cursor
word_before_cursor = document.text_before_cursor.split(' ')[-1]
_, _, suggestions = self.check_tokens(list(), text.split())
for key, suggestion in suggestions.items():
yield Completion(key, -len(word_before_cursor), display=key, display_meta=suggestion)