Source code for romulan.main

"""Romulan CLI entry point.

Usage:
    romulan input.txt --build --upload [--port PORT]
    romulan hardware <subcommand> ...

Examples:
    romulan program.txt --build           # Build bin/rom.bin only
    romulan program.txt --build --upload  # Build and upload
    romulan --upload                      # Upload existing bin/rom.bin
    romulan program.txt --upload --port /dev/ttyACM0
    romulan hardware upload bin/rom.bin --port /dev/ttyACM0
    romulan hardware capture --max-cycles 500
    romulan hardware monitor --disable
    romulan hardware reset --assert
    romulan hardware request-addr
"""

import argparse
import sys
from pathlib import Path

import serial

from .build_rom import build_rom
from .upload_rom import find_pico_port, upload


[docs] def create_parser() -> argparse.ArgumentParser: """Build the argument parser for the default build/upload workflow. Returns: A configured :class:`argparse.ArgumentParser` accepting the input file plus the ``--build``, ``--upload``, ``--output``, and ``--port`` options. """ parser = argparse.ArgumentParser( prog="romulan", description="Build and upload ROM images for the Pico-as-ROM 65C02 system.", ) parser.add_argument( "input", nargs="?", type=Path, help="Path to the annotated hex dump input file (required with --build)", ) parser.add_argument( "--build", action="store_true", help="Build a .bin ROM image from the input file", ) parser.add_argument( "--upload", action="store_true", help="Upload the ROM image to the Pico (plain-text protocol)", ) parser.add_argument( "-o", "--output", type=Path, default=Path("bin/rom.bin"), help="Output ROM binary path (default: bin/rom.bin)", ) parser.add_argument( "--port", default=None, help="Serial port for the Pico (auto-detected if omitted)", ) return parser
def _create_hardware_parser_standalone() -> argparse.ArgumentParser: """Create a dedicated parser for the ``hardware`` sub-command. The ``hardware`` command has its own subcommands (``upload``, ``capture``, ``monitor``, ``reset``, ``request-addr``), each sharing the common ``--port`` and ``--verbose`` options. Returns: A configured :class:`argparse.ArgumentParser` for ``romulan hardware``. """ parser = argparse.ArgumentParser( prog="romulan hardware", description="Hardware API commands (framed serial protocol)", ) sub = parser.add_subparsers(dest="hw_cmd", required=True) def _add_common_args(p): p.add_argument( "--port", default=None, help="Serial port for the Pico (auto-detected if omitted)", ) p.add_argument( "--verbose", "-v", action="store_true", help="Print hardware protocol messages (SEND/RECV trace)", ) # --- upload --- upload_parser = sub.add_parser( "upload", help="Upload a ROM binary using the framed protocol", ) upload_parser.add_argument( "bin_path", type=Path, help="Path to the 32 KB ROM binary file", ) _add_common_args(upload_parser) # --- capture --- capture_parser = sub.add_parser( "capture", help="Capture CPU bus cycles until STP or max_cycles", ) capture_parser.add_argument( "--max-cycles", type=int, default=500, help="Maximum number of cycles to capture (default: 500)", ) _add_common_args(capture_parser) # --- monitor --- monitor_parser = sub.add_parser( "monitor", help="Enable or disable the unstructured monitor output", ) monitor_parser.add_argument( "--enable", action="store_true", dest="enable", help="Enable monitor output", ) monitor_parser.add_argument( "--disable", action="store_true", dest="disable", help="Disable monitor output", ) _add_common_args(monitor_parser) # --- reset --- reset_parser = sub.add_parser( "reset", help="Assert or release the CPU reset line", ) reset_parser.add_argument( "--assert", action="store_true", dest="assert_reset", help="Hold CPU in reset", ) reset_parser.add_argument( "--release", action="store_true", dest="release_reset", help="Release CPU from reset", ) _add_common_args(reset_parser) # --- request-addr --- addr_parser = sub.add_parser( "request-addr", help="Request the current CPU address", ) _add_common_args(addr_parser) return parser def _resolve_port(port: str | None) -> str: """Return a usable serial port, auto-detecting one if not given. Args: port: An explicit serial device path, or ``None`` to auto-detect. Returns: The resolved serial device path. Raises: RuntimeError: If no port is given and none can be auto-detected. """ if port is None: port = find_pico_port() print(f"Auto-detected Pico on {port}") return port def _handle_hardware(args: argparse.Namespace) -> None: """Dispatch a parsed ``hardware`` sub-command against the hardware API. Opens a :class:`~romulan.hardware_api.HardwareAPI` on the resolved port and runs the requested operation (upload, capture, monitor, reset, or request-addr), printing results to stdout. Side effects: Opens the serial port and drives the hardware. May call :func:`sys.exit` with status 1 on conflicting flags or API errors. Args: args: Parsed arguments from the ``hardware`` sub-parser; must include ``hw_cmd``, ``port``, and ``verbose``. """ from .hardware_api import HardwareAPI, HardwareAPIError cmd = args.hw_cmd port = _resolve_port(args.port) try: with HardwareAPI(port, verbose=args.verbose) as api: if cmd == "upload": data = args.bin_path.read_bytes() result = api.upload_rom(data) print(f"Upload result: {result}") elif cmd == "capture": result = api.read_until_stp(max_cycles=args.max_cycles) print(f"Capture finished: {result.reason}") print(f"Cycles captured: {len(result.cycles)}") for cycle in result.cycles: print(f" {cycle}") elif cmd == "monitor": if args.enable and args.disable: print("ERROR: Cannot specify both --enable and --disable", file=sys.stderr) sys.exit(1) if not args.enable and not args.disable: print("ERROR: Must specify either --enable or --disable", file=sys.stderr) sys.exit(1) api.monitor(enable=args.enable) print(f"Monitor {'enabled' if args.enable else 'disabled'}") elif cmd == "reset": if args.assert_reset and args.release_reset: print("ERROR: Cannot specify both --assert and --release", file=sys.stderr) sys.exit(1) if not args.assert_reset and not args.release_reset: print("ERROR: Must specify either --assert or --release", file=sys.stderr) sys.exit(1) api.reset(assert_reset=args.assert_reset) print(f"Reset {'asserted' if args.assert_reset else 'released'}") elif cmd == "request-addr": addr = api.request_addr() print(f"Current CPU address: 0x{addr:04X}") except (HardwareAPIError, TimeoutError) as exc: print(f"ERROR: Hardware API failed: {exc}", file=sys.stderr) sys.exit(1) except serial.SerialException as exc: print(f"ERROR: Serial communication failed: {exc}", file=sys.stderr) sys.exit(1)
[docs] def main() -> None: """Entry point for the ``romulan`` command-line tool. Routes to the ``hardware`` sub-command when it is the first argument; otherwise runs the default workflow, which builds a ROM image from an input file (``--build``) and/or uploads it to the Pico (``--upload``). Side effects: Parses ``sys.argv``, may read/write files, and may open the serial port to talk to the Pico. Exits via :func:`sys.exit` (or ``parser.error``) on invalid arguments or failures. """ # When the first argument is "hardware" we dispatch to a dedicated # sub-parser so that the positional ``input`` argument does not # conflict with the sub-command name. if len(sys.argv) > 1 and sys.argv[1] == "hardware": hw_parser = _create_hardware_parser_standalone() args = hw_parser.parse_args(sys.argv[2:]) _handle_hardware(args) return parser = create_parser() args = parser.parse_args() # Default (legacy) workflow if not args.build and not args.upload: parser.error("At least one of --build or --upload is required.") if args.build: if not args.input: parser.error("--build requires an input file.") if not args.input.exists(): print(f"ERROR: Input file not found: {args.input}", file=sys.stderr) sys.exit(1) try: build_rom(args.input, args.output) except ValueError as exc: print(f"ERROR: Build failed: {exc}", file=sys.stderr) sys.exit(1) if args.upload: if not args.output.exists(): print( f"ERROR: ROM file not found: {args.output}\n" "Run with --build first to produce the ROM image.", file=sys.stderr, ) sys.exit(1) port = args.port if port is None: try: port = find_pico_port() print(f"Auto-detected Pico on {port}") except RuntimeError as exc: print(f"ERROR: {exc}", file=sys.stderr) sys.exit(1) try: upload(port, args.output) except serial.SerialException as exc: print(f"ERROR: Serial communication failed: {exc}", file=sys.stderr) sys.exit(1) except TimeoutError as exc: print(f"ERROR: Upload timed out: {exc}", file=sys.stderr) sys.exit(1)
if __name__ == "__main__": main()