From a887f603ea5c4e5751f99093d03f88d18492efc1 Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Tue, 1 Jul 2025 19:00:40 +0200 Subject: [PATCH 01/10] feat(cli): initial version --- api/cli.py | 150 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 + 2 files changed, 153 insertions(+) create mode 100644 api/cli.py diff --git a/api/cli.py b/api/cli.py new file mode 100644 index 0000000..296e596 --- /dev/null +++ b/api/cli.py @@ -0,0 +1,150 @@ +import argparse +import serial.tools.list_ports + +from scaffold import Scaffold + + +class CLI: + DIGITAL_IO = ["d0", "d1", "d2", "d3", "d4", "d5"] + + def __init__(self): + self.scaffold = None + self.parser = self.parse() + + def parse(self): + parser = argparse.ArgumentParser(prog="scaffold") + parser.add_argument("--dev", help="Select scaffold device (optional)", default=None, required=False) + subparsers = parser.add_subparsers(dest="command", required=True) + + # scaffold list + subparsers.add_parser("list", help="List available board") + + # scaffold version + subparsers.add_parser("version", help="Show version information") + + # scaffold power dut/platform/all on/off + power_parser = subparsers.add_parser("power", help="Control power") + power_parser.add_argument("target", choices=["dut", "platform", "all"], help="Power target") + power_parser.add_argument("state", choices=["on", "off"], help="Power state") + power_parser.add_argument( + "--trigger", + help="Optional trigger for power control", + choices=self.DIGITAL_IO, + default=None, + required=False + ) + + # scaffold d0/d1/d2/d3/d4/d5 on/off + d_parser = subparsers.add_parser("io", help="Control I/Os") + d_parser.add_argument("line", choices=self.DIGITAL_IO, help="I/O line") + d_parser.add_argument("state", choices=["on", "off"], help="Line state") + + # scaffold uart + uart_parser = subparsers.add_parser("uart", help="UART interactive shell") + uart_parser.add_argument("rx", choices=self.DIGITAL_IO, help="RX I/O line") + uart_parser.add_argument("tx", choices=self.DIGITAL_IO, help="TX I/O line") + uart_parser.add_argument("--baudrate", type=int, default=9600, help="UART baudrate (default: 9600)") + + # scaffold apdu + iso7816_parser = subparsers.add_parser("iso7816", help="ISO 7816 interface") + iso7816_subparsers = iso7816_parser.add_subparsers(dest="iso7816_command", required=True) + + # iso7816 apdu + apdu_parser = iso7816_subparsers.add_parser("apdu", help="Send APDU command") + apdu_parser.add_argument("hexstr", help="APDU command as hex string") + + # iso7816 reset + iso7816_subparsers.add_parser("reset", help="Reset ISO 7816 interface") + + return parser + + def handle_power(self, args): + if args.trigger: + getattr(self.scaffold, args.trigger) << self.scaffold.power.dut_trigger + + value = 1 if args.state == "on" else 0 + if args.target in ["dut", "platform"]: + setattr(self.scaffold.power, args.target, value) + elif args.target == "all": + self.scaffold.power.all = 0b11 if value else 0b00 + print(f"Power {args.target} set to {args.state}{' with trigger on' if args.trigger else ''}") + + def handle_io(self, args): + value = 1 if args.state == "on" else 0 + # line is sanitized by argparse choice + getattr(self.scaffold, args.line) << value + print(f"{args.line} set to {args.state}") + + def handle_version(self): + print(f"Scaffold version: {self.scaffold.version}") + + def handle_list(self, args): + for port in serial.tools.list_ports.comports(): + if port.product is not None and port.product.lower() == "scaffold": + print(f"{port.device} - {port.description} ({port.hwid})") + + def handle_uart(self, args): + print(f"Starting UART shell on RX: {args.rx}, TX: {args.tx}, Baudrate: {args.baudrate}") + uart = self.scaffold.uart0 + uart.baudrate = args.baudrate + + # rx/tx are sanitized by argparse choice + getattr(self.scaffold, args.tx) << uart.tx + uart.rx << getattr(self.scaffold, args.rx) + + uart.flush() + print("Press Ctrl+C to leave the UART shell.") + try: + while True: + data = input(">> ") + if data: + uart.transmit(data.encode()) + response = uart.receive() + if response: + print(f"Received: {response.decode(errors='replace')}") + except KeyboardInterrupt: + pass + + def handle_iso7816(self, args): + iso7816 = Smartcard(self.scaffold) + + if args.iso7816_command == "apdu": + print(f"Sending APDU: {args.hexstr}") + apdu_bytes = bytes.fromhex(args.hexstr) + response = iso7816.transmit(apdu_bytes) + print(f"Response: {response.hex()}") + elif args.iso7816_command == "reset": + print("Resetting card interface and retrieving ATR...") + atr = iso7816.reset() + print(f"ATR: {atr.hex() if atr else 'No ATR received'}") + + def run(self): + args = self.parser.parse_args() + + # List does not require a device + if args.command == "list": + self.handle_list(args) + return + + # Instantiate Scaffold + if args.dev is None: + self.scaffold = Scaffold() + else: + self.scaffold = Scaffold(dev=args.dev) + + if args.command == "power": + self.handle_power(args) + elif args.command == "io": + self.handle_io(args) + elif args.command == "version": + self.handle_version() + elif args.command == "uart": + self.handle_uart(args) + elif args.command == "apdu": + self.handle_apdu(args) + +def main() -> None: + CLI().run() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c39d2dc..71e66c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,9 @@ optional = true sphinx = "*" sphinx_rtd_theme = "*" +[tool.poetry.scripts] +scaffold = "cli:main" + [build-system] requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] build-backend = "poetry_dynamic_versioning.backend" From 7939dacb64f2c2aac53491fcbf4cc2debcfd8449 Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Wed, 2 Jul 2025 18:00:26 +0200 Subject: [PATCH 02/10] feat(cli): support uart --- .gitignore | 1 + api/cli.py | 300 +++++++++++++++++++++++++++++++++++++++---------- pyproject.toml | 1 + 3 files changed, 243 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index c18dd8d..92afa22 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ __pycache__/ +venv/ diff --git a/api/cli.py b/api/cli.py index 296e596..54c37a8 100644 --- a/api/cli.py +++ b/api/cli.py @@ -1,19 +1,60 @@ import argparse +import signal + import serial.tools.list_ports +from rich.console import Console +from rich.prompt import Prompt from scaffold import Scaffold +from scaffold.iso7816 import Smartcard +from scaffold.bus import TimeoutError + +console = Console() + + +# Context manager from https://stackoverflow.com/a/21919644 +class DelayedKeyboardInterrupt: + """Context manager to delay KeyboardInterrupt handling. + This is used to define a critical section when interacting with scaffold. + """ + + def __enter__(self): + self.signal_received = False + self.old_handler = signal.signal(signal.SIGINT, self.handler) + + def handler(self, sig, frame): + self.signal_received = (sig, frame) + + def __exit__(self, type, value, traceback): + signal.signal(signal.SIGINT, self.old_handler) + if self.signal_received: + self.old_handler(*self.signal_received) + + +class UARTPrompt(Prompt): + """Custom prompt for UART communication.""" + + prompt_suffix = "" class CLI: + """Command Line Interface for interacting with the Scaffold.""" + DIGITAL_IO = ["d0", "d1", "d2", "d3", "d4", "d5"] def __init__(self): self.scaffold = None - self.parser = self.parse() + self.parser = self.create_parser() - def parse(self): + def create_parser(self): + """Create the argument parser for the CLI.""" parser = argparse.ArgumentParser(prog="scaffold") - parser.add_argument("--dev", help="Select scaffold device (optional)", default=None, required=False) + parser.add_argument( + "--dev", + help="Select scaffold device (optional)", + default=None, + required=False, + ) subparsers = parser.add_subparsers(dest="command", required=True) # scaffold list @@ -24,14 +65,16 @@ def parse(self): # scaffold power dut/platform/all on/off power_parser = subparsers.add_parser("power", help="Control power") - power_parser.add_argument("target", choices=["dut", "platform", "all"], help="Power target") + power_parser.add_argument( + "target", choices=["dut", "platform", "all"], help="Power target" + ) power_parser.add_argument("state", choices=["on", "off"], help="Power state") power_parser.add_argument( "--trigger", help="Optional trigger for power control", choices=self.DIGITAL_IO, default=None, - required=False + required=False, ) # scaffold d0/d1/d2/d3/d4/d5 on/off @@ -41,24 +84,63 @@ def parse(self): # scaffold uart uart_parser = subparsers.add_parser("uart", help="UART interactive shell") - uart_parser.add_argument("rx", choices=self.DIGITAL_IO, help="RX I/O line") - uart_parser.add_argument("tx", choices=self.DIGITAL_IO, help="TX I/O line") - uart_parser.add_argument("--baudrate", type=int, default=9600, help="UART baudrate (default: 9600)") + uart_parser.add_argument( + "rx", choices=self.DIGITAL_IO, help="RX I/O line (required)" + ) + uart_parser.add_argument( + "tx", choices=self.DIGITAL_IO, help="TX I/O line (required)" + ) + uart_parser.add_argument( + "--baudrate", type=int, default=9600, help="UART baudrate (default: 9600)" + ) + uart_parser.add_argument( + "--mode", + choices=["log", "repl"], + default="log", + help="UART mode (default: log)", + ) + uart_parser.add_argument( + "--timeout", + type=int, + default=1, + help="UART timeout in seconds (default: 1)", + ) + uart_parser.add_argument( + "--buffer", + type=int, + default=1, + help="UART buffer size in bytes (default: 1)", + ) - # scaffold apdu + # scaffold iso7816 iso7816_parser = subparsers.add_parser("iso7816", help="ISO 7816 interface") - iso7816_subparsers = iso7816_parser.add_subparsers(dest="iso7816_command", required=True) + iso7816_subparsers = iso7816_parser.add_subparsers( + dest="iso7816_command", required=True + ) # iso7816 apdu apdu_parser = iso7816_subparsers.add_parser("apdu", help="Send APDU command") apdu_parser.add_argument("hexstr", help="APDU command as hex string") + apdu_parser.add_argument( + "--trigger", + help="Optional trigger for power control", + choices=["d4", "d5"], + default=None, + required=False, + ) # iso7816 reset iso7816_subparsers.add_parser("reset", help="Reset ISO 7816 interface") return parser - def handle_power(self, args): + def handle_power(self, args: argparse.Namespace) -> None: + """ + Handle the 'power' command to control power to DUT, platform, or all. + + :param args: Parsed command-line arguments. + :type args: argparse.Namespace + """ if args.trigger: getattr(self.scaffold, args.trigger) << self.scaffold.power.dut_trigger @@ -67,71 +149,169 @@ def handle_power(self, args): setattr(self.scaffold.power, args.target, value) elif args.target == "all": self.scaffold.power.all = 0b11 if value else 0b00 - print(f"Power {args.target} set to {args.state}{' with trigger on' if args.trigger else ''}") + console.print( + f"[green]Power [/green][bold yellow]{args.target}[/bold yellow][green] set to [/green]" + f"[bold yellow]{args.state}[/bold yellow]" + f"{f'[green] with trigger on [/green][bold yellow]{args.trigger}[/bold yellow]' if args.trigger else ''}" + ) - def handle_io(self, args): + def handle_io(self, args: argparse.Namespace) -> None: + """ + Handle the 'io' command to control digital I/O lines. + + :param args: Parsed command-line arguments. + :type args: argparse.Namespace + """ value = 1 if args.state == "on" else 0 - # line is sanitized by argparse choice getattr(self.scaffold, args.line) << value - print(f"{args.line} set to {args.state}") + console.print( + f"[bold yellow]{args.line}[/bold yellow][green] set to [/green][bold yellow]{args.state}[/bold yellow]" + ) - def handle_version(self): - print(f"Scaffold version: {self.scaffold.version}") + def handle_version(self) -> None: + """ + Handle the 'version' command to display the Scaffold version. + """ + console.print( + f"[green]Scaffold version: [/green][bold yellow]{self.scaffold.version}[/bold yellow]" + ) - def handle_list(self, args): + def handle_list(self) -> None: + """ + Handle the 'list' command to list available Scaffold devices. + """ + found = False for port in serial.tools.list_ports.comports(): if port.product is not None and port.product.lower() == "scaffold": - print(f"{port.device} - {port.description} ({port.hwid})") + console.print( + f"[green]Found device: [/green][bold yellow]{port.device}[/bold yellow]" + f"[green] - {port.description} ({port.hwid})[/green]" + ) + found = True + if not found: + console.print("[red]No scaffold devices found.[/red]") + + def handle_uart(self, args: argparse.Namespace) -> None: + """ + Handle the 'uart' command to start an interactive UART shell or retrieve UART logs. + + :param args: Parsed command-line arguments. + :type args: argparse.Namespace + """ + if args.rx == args.tx: + console.print("[red]Error: RX and TX lines cannot be the same.[/red]") + return + + console.print( + f"[green]Initializing UART on RX: [/green][bold yellow]{args.rx}[/bold yellow]" + f"[green], TX: [/green][bold yellow]{args.tx}[/bold yellow]" + f"[green], Baudrate: [/green][bold yellow]{args.baudrate}[/bold yellow]" + f"[green], Timeout: [/green][bold yellow]{args.timeout}[/bold yellow]" + ) + self.scaffold.timeout = args.timeout - def handle_uart(self, args): - print(f"Starting UART shell on RX: {args.rx}, TX: {args.tx}, Baudrate: {args.baudrate}") uart = self.scaffold.uart0 uart.baudrate = args.baudrate - - # rx/tx are sanitized by argparse choice - getattr(self.scaffold, args.tx) << uart.tx uart.rx << getattr(self.scaffold, args.rx) + getattr(self.scaffold, args.tx) << uart.tx uart.flush() - print("Press Ctrl+C to leave the UART shell.") - try: - while True: - data = input(">> ") - if data: - uart.transmit(data.encode()) - response = uart.receive() - if response: - print(f"Received: {response.decode(errors='replace')}") - except KeyboardInterrupt: - pass - - def handle_iso7816(self, args): - iso7816 = Smartcard(self.scaffold) + + if args.mode == "log": + console.print("[blue]Entering UART log mode. Press Ctrl+C to exit.[/blue]") + try: + response = b"" + while True: + try: + with DelayedKeyboardInterrupt(): + response += uart.receive(args.buffer) + except TimeoutError: + console.print(f"{response.decode(errors='replace')}") + console.print( + "[blue]UART log mode exited (reason: timeout).[/blue]" + ) + break + if b"\n" in response: + console.print(f"{response.decode(errors='replace')}") + response = b"" + except KeyboardInterrupt: + console.print("[blue]UART log mode exited (reason: user).[/blue]") + elif args.mode == "repl": + console.print( + "[blue]Entering UART REPL mode. Press Ctrl+C to leave the UART shell.[/blue]" + ) + try: + while True: + data = UARTPrompt.ask("[green]uart> [/green]") + if data: + uart.transmit(data.encode()) + response = b"" + try: + while True: + with DelayedKeyboardInterrupt(): + response += uart.receive(args.buffer) + except TimeoutError: + pass + if response: + console.print(f"{response.decode(errors='replace')}") + except KeyboardInterrupt: + console.print("[blue]UART shell exited (reason: user).[/blue]") + + def handle_iso7816(self, args: argparse.Namespace) -> None: + """ + Handle the 'iso7816' command to interact with ISO 7816 smartcards. + + :param args: Parsed command-line arguments. + :type args: argparse.Namespace + """ + sm = Smartcard(self.scaffold) + + # Always reset the card interface before sending commands (so reset command does nothing more) + console.print("[green]Resetting card interface and retrieving ATR...[/green]") + atr = sm.reset() + console.print( + f"[green]ATR: [/green][bold yellow]{atr.hex() if atr else 'No ATR received'}[/bold yellow]" + ) if args.iso7816_command == "apdu": - print(f"Sending APDU: {args.hexstr}") - apdu_bytes = bytes.fromhex(args.hexstr) - response = iso7816.transmit(apdu_bytes) - print(f"Response: {response.hex()}") - elif args.iso7816_command == "reset": - print("Resetting card interface and retrieving ATR...") - atr = iso7816.reset() - print(f"ATR: {atr.hex() if atr else 'No ATR received'}") - - def run(self): + if args.trigger: + getattr(self.scaffold, args.trigger) << sm.iso7816.trigger + console.print( + f"[green]Sending APDU [/green][bold yellow]{args.hexstr}[/bold yellow]" + f"{f'[green] with [/green][bold yellow]ab[/bold yellow][green] trigger on [/green][bold yellow]{args.trigger}[/bold yellow]' if args.trigger else ''}" + ) + response = sm.apdu(args.hexstr, trigger="ab" if args.trigger else "") + console.print( + f"[green]Response: [/green][bold yellow]{response.hex()}[/bold yellow]" + ) + + def run(self) -> None: + """ + Parse command-line arguments, instantiate a scaffold object if needed and dispatch to the appropriate handler. + """ args = self.parser.parse_args() - # List does not require a device if args.command == "list": - self.handle_list(args) + self.handle_list() + return + + try: + if args.dev is None: + console.print( + "[yellow]No device specified, using default Scaffold device.[/yellow]" + ) + self.scaffold = Scaffold() + else: + console.print( + f"[yellow]Using Scaffold device: [/yellow][bold yellow]{args.dev}[/bold yellow]" + ) + self.scaffold = Scaffold(dev=args.dev) + except (RuntimeError, serial.serialutil.SerialException): + console.print( + "[red]Error: Unable to connect to the specified Scaffold device.[/red]" + ) return - # Instantiate Scaffold - if args.dev is None: - self.scaffold = Scaffold() - else: - self.scaffold = Scaffold(dev=args.dev) - if args.command == "power": self.handle_power(args) elif args.command == "io": @@ -140,11 +320,13 @@ def run(self): self.handle_version() elif args.command == "uart": self.handle_uart(args) - elif args.command == "apdu": - self.handle_apdu(args) + elif args.command == "iso7816": + self.handle_iso7816(args) + def main() -> None: CLI().run() + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/pyproject.toml b/pyproject.toml index 71e66c5..cc2fcc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ pyserial = "*" crcmod = "*" requests = "*" packaging = "*" +rich = "*" [tool.poetry.group.test.dependencies] pytest = "^6.0.0" From e4a08bb4e58b76b74ffe5160774a7e361d2dff13 Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Wed, 2 Jul 2025 18:14:38 +0200 Subject: [PATCH 03/10] chore(cli): fix coding style --- api/cli.py | 66 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/api/cli.py b/api/cli.py index 54c37a8..8a34301 100644 --- a/api/cli.py +++ b/api/cli.py @@ -149,10 +149,17 @@ def handle_power(self, args: argparse.Namespace) -> None: setattr(self.scaffold.power, args.target, value) elif args.target == "all": self.scaffold.power.all = 0b11 if value else 0b00 + + trigger_msg = "" + if args.trigger: + trigger_msg += "[green] with trigger on [/green]" + trigger_msg += f"[bold yellow]{args.trigger}[/bold yellow]" console.print( - f"[green]Power [/green][bold yellow]{args.target}[/bold yellow][green] set to [/green]" + "[green]Power [/green]" + f"[bold yellow]{args.target}[/bold yellow]" + "[green] set to [/green]" f"[bold yellow]{args.state}[/bold yellow]" - f"{f'[green] with trigger on [/green][bold yellow]{args.trigger}[/bold yellow]' if args.trigger else ''}" + f"{trigger_msg}" ) def handle_io(self, args: argparse.Namespace) -> None: @@ -165,7 +172,9 @@ def handle_io(self, args: argparse.Namespace) -> None: value = 1 if args.state == "on" else 0 getattr(self.scaffold, args.line) << value console.print( - f"[bold yellow]{args.line}[/bold yellow][green] set to [/green][bold yellow]{args.state}[/bold yellow]" + f"[bold yellow]{args.line}[/bold yellow]" + "[green] set to [/green]" + f"[bold yellow]{args.state}[/bold yellow]" ) def handle_version(self) -> None: @@ -173,7 +182,8 @@ def handle_version(self) -> None: Handle the 'version' command to display the Scaffold version. """ console.print( - f"[green]Scaffold version: [/green][bold yellow]{self.scaffold.version}[/bold yellow]" + "[green]Scaffold version: [/green]" + f"[bold yellow]{self.scaffold.version}[/bold yellow]" ) def handle_list(self) -> None: @@ -184,7 +194,8 @@ def handle_list(self) -> None: for port in serial.tools.list_ports.comports(): if port.product is not None and port.product.lower() == "scaffold": console.print( - f"[green]Found device: [/green][bold yellow]{port.device}[/bold yellow]" + "[green]Found device: [/green]" + f"[bold yellow]{port.device}[/bold yellow]" f"[green] - {port.description} ({port.hwid})[/green]" ) found = True @@ -193,7 +204,8 @@ def handle_list(self) -> None: def handle_uart(self, args: argparse.Namespace) -> None: """ - Handle the 'uart' command to start an interactive UART shell or retrieve UART logs. + Handle the 'uart' command to start an interactive UART shell + or retrieve UART logs. :param args: Parsed command-line arguments. :type args: argparse.Namespace @@ -203,10 +215,14 @@ def handle_uart(self, args: argparse.Namespace) -> None: return console.print( - f"[green]Initializing UART on RX: [/green][bold yellow]{args.rx}[/bold yellow]" - f"[green], TX: [/green][bold yellow]{args.tx}[/bold yellow]" - f"[green], Baudrate: [/green][bold yellow]{args.baudrate}[/bold yellow]" - f"[green], Timeout: [/green][bold yellow]{args.timeout}[/bold yellow]" + "[green]Initializing UART with RX: [/green]" + f"[bold yellow]{args.rx}[/bold yellow]" + "[green], TX: [/green]" + f"[bold yellow]{args.tx}[/bold yellow]" + "[green], Baudrate: [/green]" + f"[bold yellow]{args.baudrate}[/bold yellow]" + "[green], Timeout: [/green]" + f"[bold yellow]{args.timeout}[/bold yellow]" ) self.scaffold.timeout = args.timeout @@ -238,7 +254,8 @@ def handle_uart(self, args: argparse.Namespace) -> None: console.print("[blue]UART log mode exited (reason: user).[/blue]") elif args.mode == "repl": console.print( - "[blue]Entering UART REPL mode. Press Ctrl+C to leave the UART shell.[/blue]" + "[blue]Entering UART REPL mode. " + "Press Ctrl+C to leave the UART shell.[/blue]" ) try: while True: @@ -266,19 +283,29 @@ def handle_iso7816(self, args: argparse.Namespace) -> None: """ sm = Smartcard(self.scaffold) - # Always reset the card interface before sending commands (so reset command does nothing more) + # Always reset the card interface before sending + # commands (so reset command does nothing more) console.print("[green]Resetting card interface and retrieving ATR...[/green]") atr = sm.reset() console.print( - f"[green]ATR: [/green][bold yellow]{atr.hex() if atr else 'No ATR received'}[/bold yellow]" + "[green]ATR: [/green]" + f"[bold yellow]{atr.hex() if atr else 'No ATR received'}[/bold yellow]" ) if args.iso7816_command == "apdu": if args.trigger: getattr(self.scaffold, args.trigger) << sm.iso7816.trigger + + trigger_msg = "" + if args.trigger: + trigger_msg = "[green] with [/green]" + trigger_msg += "[bold yellow]ab[/bold yellow]" + trigger_msg += "[green] trigger on [/green]" + trigger_msg += f"[bold yellow]{args.trigger}[/bold yellow]" console.print( - f"[green]Sending APDU [/green][bold yellow]{args.hexstr}[/bold yellow]" - f"{f'[green] with [/green][bold yellow]ab[/bold yellow][green] trigger on [/green][bold yellow]{args.trigger}[/bold yellow]' if args.trigger else ''}" + "[green]Sending APDU [/green]" + f"[bold yellow]{args.hexstr}[/bold yellow]" + f"{trigger_msg}" ) response = sm.apdu(args.hexstr, trigger="ab" if args.trigger else "") console.print( @@ -287,7 +314,8 @@ def handle_iso7816(self, args: argparse.Namespace) -> None: def run(self) -> None: """ - Parse command-line arguments, instantiate a scaffold object if needed and dispatch to the appropriate handler. + Parse command-line arguments, instantiate a scaffold object if needed + and dispatch to the appropriate handler. """ args = self.parser.parse_args() @@ -298,12 +326,14 @@ def run(self) -> None: try: if args.dev is None: console.print( - "[yellow]No device specified, using default Scaffold device.[/yellow]" + "[yellow]No device specified, " + "using default Scaffold device.[/yellow]" ) self.scaffold = Scaffold() else: console.print( - f"[yellow]Using Scaffold device: [/yellow][bold yellow]{args.dev}[/bold yellow]" + "[yellow]Using Scaffold device: [/yellow]" + f"[bold yellow]{args.dev}[/bold yellow]" ) self.scaffold = Scaffold(dev=args.dev) except (RuntimeError, serial.serialutil.SerialException): From eaedd10ca2e43fd76c4bd0caf76ef34f57384fd0 Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Thu, 3 Jul 2025 10:02:35 +0200 Subject: [PATCH 04/10] feat(cli): add color on all subcommands help --- api/cli.py | 52 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/api/cli.py b/api/cli.py index 8a34301..99459ba 100644 --- a/api/cli.py +++ b/api/cli.py @@ -4,6 +4,7 @@ import serial.tools.list_ports from rich.console import Console from rich.prompt import Prompt +from rich_argparse import RichHelpFormatter from scaffold import Scaffold from scaffold.iso7816 import Smartcard @@ -44,11 +45,14 @@ class CLI: def __init__(self): self.scaffold = None - self.parser = self.create_parser() + self.parser = CLI.create_parser() - def create_parser(self): + @staticmethod + def create_parser(): """Create the argument parser for the CLI.""" - parser = argparse.ArgumentParser(prog="scaffold") + parser = argparse.ArgumentParser( + prog="scaffold", formatter_class=RichHelpFormatter + ) parser.add_argument( "--dev", help="Select scaffold device (optional)", @@ -58,13 +62,21 @@ def create_parser(self): subparsers = parser.add_subparsers(dest="command", required=True) # scaffold list - subparsers.add_parser("list", help="List available board") + subparsers.add_parser( + "list", help="List available board", formatter_class=RichHelpFormatter + ) # scaffold version - subparsers.add_parser("version", help="Show version information") + subparsers.add_parser( + "version", + help="Show version information", + formatter_class=RichHelpFormatter, + ) # scaffold power dut/platform/all on/off - power_parser = subparsers.add_parser("power", help="Control power") + power_parser = subparsers.add_parser( + "power", help="Control power", formatter_class=RichHelpFormatter + ) power_parser.add_argument( "target", choices=["dut", "platform", "all"], help="Power target" ) @@ -72,23 +84,27 @@ def create_parser(self): power_parser.add_argument( "--trigger", help="Optional trigger for power control", - choices=self.DIGITAL_IO, + choices=CLI.DIGITAL_IO, default=None, required=False, ) # scaffold d0/d1/d2/d3/d4/d5 on/off - d_parser = subparsers.add_parser("io", help="Control I/Os") - d_parser.add_argument("line", choices=self.DIGITAL_IO, help="I/O line") + d_parser = subparsers.add_parser( + "io", help="Control I/Os", formatter_class=RichHelpFormatter + ) + d_parser.add_argument("line", choices=CLI.DIGITAL_IO, help="I/O line") d_parser.add_argument("state", choices=["on", "off"], help="Line state") # scaffold uart - uart_parser = subparsers.add_parser("uart", help="UART interactive shell") + uart_parser = subparsers.add_parser( + "uart", help="UART interactive shell", formatter_class=RichHelpFormatter + ) uart_parser.add_argument( - "rx", choices=self.DIGITAL_IO, help="RX I/O line (required)" + "rx", choices=CLI.DIGITAL_IO, help="RX I/O line (required)" ) uart_parser.add_argument( - "tx", choices=self.DIGITAL_IO, help="TX I/O line (required)" + "tx", choices=CLI.DIGITAL_IO, help="TX I/O line (required)" ) uart_parser.add_argument( "--baudrate", type=int, default=9600, help="UART baudrate (default: 9600)" @@ -113,13 +129,17 @@ def create_parser(self): ) # scaffold iso7816 - iso7816_parser = subparsers.add_parser("iso7816", help="ISO 7816 interface") + iso7816_parser = subparsers.add_parser( + "iso7816", help="ISO 7816 interface", formatter_class=RichHelpFormatter + ) iso7816_subparsers = iso7816_parser.add_subparsers( dest="iso7816_command", required=True ) # iso7816 apdu - apdu_parser = iso7816_subparsers.add_parser("apdu", help="Send APDU command") + apdu_parser = iso7816_subparsers.add_parser( + "apdu", help="Send APDU command", formatter_class=RichHelpFormatter + ) apdu_parser.add_argument("hexstr", help="APDU command as hex string") apdu_parser.add_argument( "--trigger", @@ -130,7 +150,9 @@ def create_parser(self): ) # iso7816 reset - iso7816_subparsers.add_parser("reset", help="Reset ISO 7816 interface") + iso7816_subparsers.add_parser( + "reset", help="Reset ISO 7816 interface", formatter_class=RichHelpFormatter + ) return parser From 625a666993e961c114be960670a924afd202e3be Mon Sep 17 00:00:00 2001 From: mmouchous-ledger Date: Thu, 3 Jul 2025 10:57:25 +0200 Subject: [PATCH 05/10] Doc update: Manufacturer's address --- docs/info.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/info.rst b/docs/info.rst index a5991d0..9ba7f99 100644 --- a/docs/info.rst +++ b/docs/info.rst @@ -68,5 +68,4 @@ See :ref:`Using external power supplies ` for more info Manufacturer ------------ -**Ledger**, 1 rue du Mail, 75002 Paris, France - +**Ledger**, 106 rue du Temple, 75003 Paris, France From 9ae7e513b7c96dca6c8b5be2e050fc9fd9aabfa8 Mon Sep 17 00:00:00 2001 From: mmouchous-ledger Date: Thu, 3 Jul 2025 11:05:40 +0200 Subject: [PATCH 06/10] docs(issue33) Add a note about trigger assertion --- docs/iso7816_module.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/iso7816_module.rst b/docs/iso7816_module.rst index 30bac6a..a09beee 100644 --- a/docs/iso7816_module.rst +++ b/docs/iso7816_module.rst @@ -115,6 +115,11 @@ trigger_long transmission, and cleared at the beginning of next byte reception or transmission. +.. note:: + The assertion of the signal will be done for any byte reception, + including when a `S-block` is received, in the T1 protocol, for instance + when the card sends a `WTX` request. + divisor register ^^^^^^^^^^^^^^^^ From c22a512b8b287dd7bc94c4a40bf052feea5b87a6 Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Thu, 3 Jul 2025 11:27:14 +0200 Subject: [PATCH 07/10] docs(cli): add CLI related documentation --- docs/getting_started.rst | 60 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 7a9b25d..5c2770d 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -117,3 +117,63 @@ Scaffold. # UART is now ready to use uart.transmit('Hello world !'.encode(), trigger=True) +Using the Command-Line-Interface +-------------------------------- + +The Python3 API also provides a command-line interface (CLI) for quick Scaffold +interaction and scripting without writing Python code. + +To get global help and see available modules, run: + +.. code-block:: bash + + scaffold -h + +To get help on a specific module, use: + +.. code-block:: bash + + scaffold -h + +Available modules are: + +- power: manage power state of the DUT and platform (and setup triggers) +- io: manage I/O pins power state +- uart: receive and transmit UART messages +- iso7816: send APDUs to smartcards +- version: display the scaffold version +- list: list connected Scaffold boards + +Typical usage examples: + +- list connected Scaffold boards: + + .. code-block:: bash + + scaffold list + +- power on the DUT with a trigger on d4: + + .. code-block:: bash + + scaffold power dut on --trigger d4 + +- change state of the platform I/O d0 to high: + + .. code-block:: bash + + scaffold io d0 on + +- get an interactive UART shell (rx and tx connected to d1 and d0) with a baudrate of 115200: + + .. code-block:: bash + + scaffold --dev /dev/ttyUSB0 uart d1 d0 --baudrate 115200 --mode repl + +- send an APDU command to a smartcard: + + .. code-block:: bash + + scaffold iso7816 apdu 00b2000000 + +Refer to the CLI help for more options and details on each command. From f73005c3e6089f040ef96c8fa5f5e63cde425418 Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Thu, 3 Jul 2025 13:35:02 +0200 Subject: [PATCH 08/10] refactor(cli): change IO state for on/off to high/low --- api/cli.py | 5 +++-- docs/getting_started.rst | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/api/cli.py b/api/cli.py index 99459ba..77cddfe 100644 --- a/api/cli.py +++ b/api/cli.py @@ -94,7 +94,7 @@ def create_parser(): "io", help="Control I/Os", formatter_class=RichHelpFormatter ) d_parser.add_argument("line", choices=CLI.DIGITAL_IO, help="I/O line") - d_parser.add_argument("state", choices=["on", "off"], help="Line state") + d_parser.add_argument("state", choices=["high", "low"], help="Line state") # scaffold uart uart_parser = subparsers.add_parser( @@ -191,9 +191,10 @@ def handle_io(self, args: argparse.Namespace) -> None: :param args: Parsed command-line arguments. :type args: argparse.Namespace """ - value = 1 if args.state == "on" else 0 + value = 1 if args.state == "high" else 0 getattr(self.scaffold, args.line) << value console.print( + "[green]I/O [/green]" f"[bold yellow]{args.line}[/bold yellow]" "[green] set to [/green]" f"[bold yellow]{args.state}[/bold yellow]" diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 5c2770d..56a1416 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -162,7 +162,7 @@ Typical usage examples: .. code-block:: bash - scaffold io d0 on + scaffold io d0 high - get an interactive UART shell (rx and tx connected to d1 and d0) with a baudrate of 115200: From ee6d7f57aba29381cdc54f3c36483910b3639af0 Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Thu, 3 Jul 2025 13:46:12 +0200 Subject: [PATCH 09/10] feat(cli): add reset command --- api/cli.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/api/cli.py b/api/cli.py index 77cddfe..3e510cd 100644 --- a/api/cli.py +++ b/api/cli.py @@ -73,6 +73,13 @@ def create_parser(): formatter_class=RichHelpFormatter, ) + # scaffold reset + subparsers.add_parser( + "reset", + help="Reset the scaffold (including I/Os)", + formatter_class=RichHelpFormatter, + ) + # scaffold power dut/platform/all on/off power_parser = subparsers.add_parser( "power", help="Control power", formatter_class=RichHelpFormatter @@ -209,6 +216,14 @@ def handle_version(self) -> None: f"[bold yellow]{self.scaffold.version}[/bold yellow]" ) + def handle_reset(self) -> None: + """ + Handle the 'reset' command to reset the Scaffold board. + This includes resetting all I/O lines. + """ + self.scaffold.reset_config(init_ios=True) + console.print("[green]Scaffold board reset successfully.[/green]") + def handle_list(self) -> None: """ Handle the 'list' command to list available Scaffold devices. @@ -375,6 +390,8 @@ def run(self) -> None: self.handle_uart(args) elif args.command == "iso7816": self.handle_iso7816(args) + elif args.command == "reset": + self.handle_reset() def main() -> None: From f824a569e7237c1638110fc1a7c94395a6e69703 Mon Sep 17 00:00:00 2001 From: mmouchous-ledger Date: Fri, 4 Jul 2025 11:16:37 +0200 Subject: [PATCH 10/10] Update pyproject and move cli file into scaffold folder --- api/{ => scaffold}/cli.py | 0 pyproject.toml | 5 +++-- 2 files changed, 3 insertions(+), 2 deletions(-) rename api/{ => scaffold}/cli.py (100%) diff --git a/api/cli.py b/api/scaffold/cli.py similarity index 100% rename from api/cli.py rename to api/scaffold/cli.py diff --git a/pyproject.toml b/pyproject.toml index cc2fcc9..d293077 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,12 +23,13 @@ keywords = ["scaffold", "donjon", "ledger"] [tool.poetry.dependencies] -python = "^3.7" +python = "^3.9" pyserial = "*" crcmod = "*" requests = "*" packaging = "*" rich = "*" +rich-argparse = "*" [tool.poetry.group.test.dependencies] pytest = "^6.0.0" @@ -41,7 +42,7 @@ sphinx = "*" sphinx_rtd_theme = "*" [tool.poetry.scripts] -scaffold = "cli:main" +scaffold = "scaffold.cli:main" [build-system] requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]