diff --git a/README.md b/README.md index 9caadc4..12e734b 100644 --- a/README.md +++ b/README.md @@ -381,6 +381,72 @@ cf gpio-config --project-root /path/to/project > [!NOTE] > Invalid modes cannot be saved. All GPIOs must have valid configurations. +### Configure an Openframe Project (GPIO pads and power domains) + +On openframe projects (`project.type: openframe`), `cf gpio-config` opens a 44-pad grid instead of the +caravel editor. It edits the `project.openframe` block in `.cf/project.json` and regenerates: + +- `verilog/rtl/openframe_gpio.v`: one `CF_gpio_config` instance per pad, with named user ports + (instantiated by `openframe_project_wrapper.v`; do not edit). +- `openlane/openframe_project_wrapper/power.json`: `VDD_NETS`, `GND_NETS` and `PDN_MACRO_CONNECTIONS` + for the wrapper PDN (LibreLane 3.x variable names). + +Grid keys: arrows move, `Space` selects, `Enter` edits the selected pads (mode, signal, bit, overrides), +`u` cycles the mode for unlisted pads, `p` edits power domains and macros, `d` validates and saves, +`q` quits without saving. Selecting several pads and giving a signal name assigns consecutive bits. + +```bash +cf gpio-config # interactive grid +cf gpio-config --view # print the current pinout +cf openframe validate # validate project.openframe +cf openframe generate # regenerate openframe_gpio.v and power.json +cf openframe generate --check # CI: exit 1 if generated files are stale +cf openframe export pinout.yaml # standalone YAML or JSON (by extension or --format) +cf openframe import pinout.yaml # validate, store in .cf/project.json and regenerate +cf openframe import pinout.yaml --force # replace a different existing spec +``` + +Spec format (`schema_version: 1`, JSON Schema in `chipfoundry_cli/openframe/openframe.schema.json`): + +```yaml +schema_version: 1 +gpio: + unused_mode: analog # optional: analog | input_pd | input_pu; if omitted, all 44 pads must be listed + pads: + - {pad: 0, mode: input, signal: clk} + - {pad: 1, mode: input_pu, signal: rst} + - {pad: 2, mode: output, signal: out, bit: 0} + # ... + - {pad: 12, mode: output, signal: out, bit: 10} + - {pad: 20, mode: bidir, signal: sda, overrides: {slow: 1, vtrip: 1}} +power: + domains: [vccd1] # from vccd1, vccd2, vdda1, vdda2; the first is the primary domain + macros: + - {instance: mprj, domain: vccd1, vdd_pin: vccd1, gnd_pin: vssd1} +``` + +- Modes map to `CF_gpio_config` `MODE`: `analog`=0, `input`=1, `input_pd`=2, `input_pu`=3, `output`=4, `bidir`=5. +- Pads with the same `signal` form a bus; `bit` indices must be unique and contiguous from 0, and all pads of a + signal must share a direction. A `bidir` signal `x` becomes ports `x_in`, `x_out` and `x_oeb`. +- `output`/`bidir` pads need a signal; `analog` pads cannot have one (use `analog_io[n]` on the wrapper). +- `overrides` (`slow`, `vtrip`, `ib_mode`, `holdover`, `analog_en`, `analog_sel`, `analog_pol`, each 0 or 1) + require `CF_gpio_config` v1.2.0 or later. +- Invalid specs are rejected with every problem listed; nothing is generated from an invalid spec. + +`cf init` on an openframe project seeds `project.openframe` from the template's `.cf/openframe_default.json` +when the project has no spec yet. + +**Harden and precheck integration:** +- `cf harden openframe_project_wrapper` runs `librelane … power.json config.json`: LibreLane has no config + include and the last file's `meta` wins, so the generated `power.json` (PDN nets) goes first and + `config.json` (flow, step reorder) last. This applies to any macro directory with a `power.json`. +- `cf harden openframe_project_wrapper` and `cf precheck` print a warning panel when the generated files do not + match `project.openframe`, with the reason per file: missing, generated from a different spec (the + `spec-sha256` header differs), edited by hand, or not generated by cf. They also warn when the spec is + missing or invalid. The command continues with the files on disk; run `cf openframe generate` to fix it. + +See [docs/OPENFRAME.md](docs/OPENFRAME.md) for the full configure → generate → harden → precheck flow. + ### Harden a Macro ```bash @@ -398,6 +464,11 @@ cf harden [MACRO] [OPTIONS] 2. Runs the hardening flow (synthesis, placement, routing) for a specific macro 3. Generates GDS, LEF, and other output files 4. Supports both Nix and Docker execution environments +5. If `openlane//power.json` exists (generated by `cf openframe generate` for + `openframe_project_wrapper`), passes it to LibreLane **before** `config.*` + (`librelane … power.json config.json`), on both Nix and Docker, like `openlane/Makefile` +6. For `openframe_project_wrapper`, warns when `project.openframe` and the generated files differ + (see [Openframe](#configure-an-openframe-project-gpio-pads-and-power-domains)) **Key Options:** - `--list` or no argument: List all available macros @@ -473,7 +544,8 @@ cf precheck [OPTIONS] **Prerequisites:** - **Must run `cf init` first** - Project initialization is required -- **Must run `cf gpio-config` first** - GPIO configuration is required +- **Must run `cf gpio-config` first** - GPIO configuration is required (caravel/caravan; on openframe it + configures `project.openframe` instead and is not gated) - Precheck tools must be installed (via `cf setup`) - PDK must be installed (via `cf setup`) - Docker must be available @@ -483,6 +555,7 @@ cf precheck [OPTIONS] 2. Runs DRC, LVS, and other design rule checks 3. Ensures your design will fit into the SoC reference design 4. Generates reports highlighting any violations +5. On openframe projects, warns first if `project.openframe` and the generated files differ **Key Options:** diff --git a/chipfoundry_cli/librelane_run.py b/chipfoundry_cli/librelane_run.py index 1ddf5a4..2df63e5 100644 --- a/chipfoundry_cli/librelane_run.py +++ b/chipfoundry_cli/librelane_run.py @@ -33,6 +33,21 @@ def find_config_file(macro_dir: Path) -> Optional[Path]: return None +POWER_JSON_NAME = "power.json" + + +def librelane_config_files(config_file: Path) -> List[Path]: + """Config files to pass to LibreLane for a macro, in order. + + A ``power.json`` next to the config (generated by ``cf openframe generate`` for + openframe_project_wrapper) carries the PDN nets. LibreLane has no config include and + the last file's ``meta`` wins, so ``power.json`` must come before ``config.*``, + matching ``openlane/Makefile``. + """ + power = config_file.parent / POWER_JSON_NAME + return [power, config_file] if power.is_file() else [config_file] + + def list_macro_configs(project_root: Path) -> List[Dict[str, str]]: """Discover LibreLane macros as ``{name, config_path, flow_root}``.""" found: List[Dict[str, str]] = [] @@ -220,7 +235,7 @@ def build_librelane_command( cmd.extend(["--from", from_step]) if to_step: cmd.extend(["--to", to_step]) - cmd.append(str(config_file)) + cmd.extend(str(p) for p in librelane_config_files(Path(config_file))) return cmd, env diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index deacfd5..3d5d069 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -9,6 +9,14 @@ verify_remote_precheck_repo, ) from chipfoundry_cli.version_check import maybe_warn_outdated +from chipfoundry_cli.librelane_run import librelane_config_files +from chipfoundry_cli.openframe.cli import ( + OPENFRAME_WRAPPER, + register_openframe, + run_openframe_gpio_config, + seed_openframe_spec, + warn_openframe_out_of_sync, +) from chipfoundry_cli.utils import ( collect_project_files, ensure_cf_directory, update_or_create_project_json, sftp_connect, upload_with_progress, sftp_ensure_dirs, sftp_download_recursive, @@ -46,6 +54,20 @@ console = Console() +# LibreLane 3 writes terminal control sequences (e.g. cursor restore) to stdout on exit, +# so the step-list probe tags its JSON line instead of owning the whole stream. +STEP_LIST_MARKER = "CF_STEP_LIST_JSON:" + + +def parse_step_list_output(stdout: str) -> List[str]: + """Extract the step list printed by the `cf harden` LibreLane probe.""" + for line in stdout.splitlines(): + idx = line.find(STEP_LIST_MARKER) + if idx != -1: + return json.loads(line[idx + len(STEP_LIST_MARKER):])['steps'] + raise ValueError(f"LibreLane step list not found in probe output: {stdout.strip()[:500]!r}") + + class CategorizedCommand(click.Command): """Click command with categorized help sections for options.""" @@ -593,6 +615,8 @@ def _merged(key_local: str, key_platform: Optional[str] = None) -> Optional[str] proj['github_repo_url'] = github_repo_url else: proj.pop('github_repo_url', None) + if project_type == 'openframe': + seed_openframe_spec(Path(project_root), proj) if not api_key: with open(project_json_path, 'w') as f: @@ -728,7 +752,12 @@ def _merged(key_local: str, key_platform: Optional[str] = None) -> Optional[str] @click.option('--project-root', required=False, type=click.Path(exists=True, file_okay=False), help='Path to the project directory (defaults to current directory).') @click.option('--view', is_flag=True, help='Display current GPIO configuration summary without editing.') def gpio_config(project_root, view): - """Configure GPIO settings interactively and save to project config and user_defines.v.""" + """Configure GPIO settings interactively. + + Caravel/caravan: saves project.gpio_config and updates user_defines.v. + Openframe: edits project.openframe (pad modes, signals, overrides, power) and + regenerates verilog/rtl/openframe_gpio.v and the wrapper power.json. + """ if not project_root: project_root = os.getcwd() @@ -780,11 +809,10 @@ def gpio_config(project_root, view): project_data = json.load(f) project_type = project_data.get('project', {}).get('type', 'digital') - # For openframe, GPIO config is not needed + # Openframe uses the project.openframe spec and generated openframe_gpio.v, not user_defines.v if project_type == 'openframe': - console.print("[red]GPIO configuration is not available for openframe projects.[/red]") - console.print("[yellow]Openframe projects do not use user_defines.v.[/yellow]") - raise click.Abort() + run_openframe_gpio_config(project_root, project_json_path, view) + return user_defines_path = project_root / 'verilog' / 'rtl' / 'user_defines.v' @@ -4018,6 +4046,13 @@ def harden( project_root_path = Path(project_root) + # The wrapper is the only design built from the generated openframe files (openframe_gpio.v, power.json). + if ( + macro == OPENFRAME_WRAPPER + and not (list_designs or list_from_steps or open_in_openroad or open_in_klayout) + ): + warn_openframe_out_of_sync(project_root_path, 'cf harden') + if poll and not remote: console.print("[red]✗[/red] --poll requires --remote.") raise SystemExit(1) @@ -4137,6 +4172,7 @@ def harden( console.print(f"[red]✗[/red] No config file found for {macro}") console.print(f"[yellow]Expected one of: config.json, config.yaml, config.tcl[/yellow]") return + config_files = [str(p) for p in librelane_config_files(Path(config_file))] # Check for LibreLane venv librelane_venv = openlane_dir / '.venv' @@ -4178,7 +4214,7 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): " continue\n" " seen.add(step_id)\n" " steps.append(step_id)\n" - "print(json.dumps({'steps': steps}))\n" + f"print({STEP_LIST_MARKER!r} + json.dumps({{'steps': steps}}))\n" ) result = subprocess.run( [str(librelane_python), '-c', script, macro_config], @@ -4190,8 +4226,7 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): err = (result.stderr or result.stdout or 'unknown error').strip() return None, err try: - payload = json.loads(result.stdout.strip()) - return payload.get('steps', []), None + return parse_step_list_output(result.stdout), None except Exception as exc: return None, str(exc) @@ -4350,7 +4385,7 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): # Display configuration console.print("\n" + "="*60) console.print(f"[bold cyan]Hardening: {macro}[/bold cyan]") - console.print(f"Config: [yellow]{Path(config_file).name}[/yellow]") + console.print(f"Config: [yellow]{' + '.join(Path(f).name for f in config_files)}[/yellow]") console.print(f"Run tag: [yellow]{tag}[/yellow]") if auto_selected_latest_tag: if gui_mode_count: @@ -4390,7 +4425,7 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): cmd.append('--overwrite') if from_step: cmd.extend(['--from', from_step]) - cmd.append(config_file) + cmd.extend(config_files) env = os.environ.copy() env.update({ @@ -4453,7 +4488,7 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): cmd.append('--overwrite') if from_step: cmd.extend(['--from', from_step]) - cmd.append(config_file) + cmd.extend(config_files) # Run LibreLane @@ -4678,6 +4713,7 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run, return project_json_path = project_root_path / '.cf' / 'project.json' + warn_openframe_out_of_sync(project_root_path, 'cf precheck') if poll and not remote: console.print("[red]✗[/red] --poll requires --remote.") @@ -5790,6 +5826,7 @@ def whoami_cmd(): from chipfoundry_cli.preview import register_preview register_preview(main) +register_openframe(main) if __name__ == "__main__": diff --git a/chipfoundry_cli/openframe/__init__.py b/chipfoundry_cli/openframe/__init__.py new file mode 100644 index 0000000..dd01db6 --- /dev/null +++ b/chipfoundry_cli/openframe/__init__.py @@ -0,0 +1,15 @@ +"""Openframe project configuration: GPIO pad spec, validation and code generation.""" + +from chipfoundry_cli.openframe.generate import render_outputs, stale_outputs, write_outputs # noqa: F401 +from chipfoundry_cli.openframe.spec import ( # noqa: F401 + MODES, + NUM_PADS, + OVERRIDES, + POWER_DOMAINS, + OpenframeSpec, + SpecError, + load_spec_from_project, + read_spec_file, + validate_spec, + write_spec_file, +) diff --git a/chipfoundry_cli/openframe/cli.py b/chipfoundry_cli/openframe/cli.py new file mode 100644 index 0000000..5756263 --- /dev/null +++ b/chipfoundry_cli/openframe/cli.py @@ -0,0 +1,310 @@ +"""`cf openframe ...` commands and the openframe branch of `cf gpio-config`.""" + +import re +from pathlib import Path +from typing import Any, Dict, List, Optional + +import click +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from chipfoundry_cli.openframe.generate import stale_outputs, write_outputs +from chipfoundry_cli.openframe.gpio_gen import GPIO_RTL_REL +from chipfoundry_cli.openframe.power_gen import POWER_JSON_REL +from chipfoundry_cli.openframe.spec import ( + OpenframeSpec, + SpecError, + _format_ranges, + load_spec_from_project, + normalize_spec, + read_spec_file, + validate_spec, + write_spec_file, +) +from chipfoundry_cli.utils import ( + CF_PROJECT_JSON_REL, + get_openframe_spec_from_project_json, + load_project_json, + save_openframe_spec_to_project_json, +) + +console = Console() + +# Seed file shipped by the openframe_user_project template; `cf init` imports it once. +OPENFRAME_SEED_REL = ".cf/openframe_default.json" +OPENFRAME_WRAPPER = "openframe_project_wrapper" + + +def _print_spec_error(err: SpecError) -> None: + console.print("[red]✗ Invalid openframe spec:[/red]") + for msg in err.errors: + console.print(f" [red]-[/red] {msg}") + + +def _project_root(project_root: Optional[str]) -> Path: + return Path(project_root).resolve() if project_root else Path.cwd() + + +def _require_openframe_project(root: Path) -> Path: + project_json = root / CF_PROJECT_JSON_REL + if not project_json.exists(): + console.print(f"[red]✗ {project_json} not found. Run 'cf init' first.[/red]") + raise click.Abort() + project_type = load_project_json(str(project_json)).get("project", {}).get("type") + if project_type != "openframe": + console.print( + f"[red]✗ This command is for openframe projects; project.type in {project_json} is '{project_type}'.[/red]" + ) + raise click.Abort() + return project_json + + +def _load_or_abort(root: Path) -> OpenframeSpec: + try: + return load_spec_from_project(root) + except SpecError as e: + _print_spec_error(e) + raise click.Abort() + + +def print_spec_summary(spec: OpenframeSpec) -> None: + table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2)) + table.add_column("Signal", style="cyan") + table.add_column("Direction") + table.add_column("Pads") + table.add_column("Modes") + for sig in spec.signals: + name = f"{sig.name}[{sig.width - 1}:0]" if sig.width is not None else sig.name + modes = sorted({spec.pads[p].mode for p in sig.pads}) + table.add_row(name, sig.direction, _format_ranges(list(sig.pads)), ", ".join(modes)) + console.print(table) + + by_mode: Dict[str, list] = {} + for pad in spec.pads: + if pad.signal is None: + label = f"{pad.mode} (unused)" if pad.unused else f"{pad.mode} (no signal)" + by_mode.setdefault(label, []).append(pad.pad) + for label, pads in by_mode.items(): + console.print(f" [dim]{label}:[/dim] {_format_ranges(pads)}") + overridden = [p for p in spec.pads if p.overrides] + for p in overridden: + ov = ", ".join(f"{k}={v}" for k, v in p.overrides.items()) + console.print(f" [dim]pad {p.pad} overrides:[/dim] {ov}") + macros = ", ".join(f"{m.instance}->{m.domain}" for m in spec.macros) or "none" + console.print(f" [dim]power domains:[/dim] {', '.join(spec.domains)} [dim]macros:[/dim] {macros}") + + +def _generate(root: Path, spec: OpenframeSpec) -> None: + try: + changed = write_outputs(root, spec) + except FileNotFoundError as e: + console.print(f"[red]✗ {e}[/red]") + raise click.Abort() + if changed: + for rel in changed: + console.print(f"[green]✓ Wrote {rel}[/green]") + else: + console.print("[green]✓ Generated files are up to date[/green]") + + +_SPEC_HASH_RE = re.compile(r'spec-sha256"?:\s*"?([0-9a-f]{64})') + + +def _stale_reason(path: Path, spec: OpenframeSpec) -> str: + if not path.exists(): + return "missing" + match = _SPEC_HASH_RE.search(path.read_text()) + if match is None: + return "not generated by cf openframe generate (no spec-sha256 header)" + if match.group(1) != spec.sha256: + return "generated from a different spec" + return "edited by hand since it was generated" + + +def openframe_sync_problems(project_root) -> List[str]: + """Reasons the generated openframe files may not match ``project.openframe``. + + Empty when the project is not openframe or everything is in sync. + """ + root = Path(project_root) + project_json = root / CF_PROJECT_JSON_REL + if not project_json.exists(): + return [] + if load_project_json(str(project_json)).get("project", {}).get("type") != "openframe": + return [] + try: + block = get_openframe_spec_from_project_json(str(project_json)) + except ValueError as e: + return [f"cannot read {CF_PROJECT_JSON_REL}: {e}"] + if block is None: + return [ + f"{CF_PROJECT_JSON_REL} has no project.openframe spec, so {GPIO_RTL_REL} and {POWER_JSON_REL} " + "cannot be checked. Run 'cf gpio-config' or 'cf openframe import '." + ] + try: + spec = validate_spec(block) + except SpecError as e: + return ["project.openframe is invalid, so the generated files cannot be checked:"] + [ + f" {msg}" for msg in e.errors + ] + return [f"{rel}: {_stale_reason(root / rel, spec)}" for rel in stale_outputs(root, spec)] + + +def warn_openframe_out_of_sync(project_root, command: str) -> bool: + """Print a warning panel if the openframe spec and generated files disagree. Returns True if it warned.""" + problems = openframe_sync_problems(project_root) + if not problems: + return False + body = "\n".join(f"• {p}" if not p.startswith(" ") else f" {p.strip()}" for p in problems) + body += ( + "\n\nRun [bold]cf openframe generate[/bold] to regenerate from project.openframe " + "([bold]cf openframe generate --check[/bold] re-checks without writing)." + f"\n{command} continues with the files currently on disk." + ) + console.print(Panel(body, title="⚠ Openframe spec and generated files differ", border_style="yellow")) + return True + + +def seed_openframe_spec(project_root: Path, proj: Dict[str, Any]) -> None: + """Import the template's seed spec into ``proj`` when the project has none. Used by `cf init`.""" + if "openframe" in proj: + return + seed = Path(project_root) / OPENFRAME_SEED_REL + if not seed.exists(): + console.print( + "[dim]No openframe spec yet. Run [bold]cf gpio-config[/bold] or " + "[bold]cf openframe import [/bold] to create one.[/dim]" + ) + return + try: + spec = validate_spec(read_spec_file(seed, "json")) + except SpecError as e: + console.print(f"[red]✗ {seed} is not a valid openframe spec.[/red]") + _print_spec_error(e) + raise click.Abort() + proj["openframe"] = spec.raw + console.print(f"[green]✓ Seeded project.openframe from {OPENFRAME_SEED_REL}[/green]") + + +def run_openframe_gpio_config(project_root: Path, project_json_path: Path, view: bool) -> None: + """Openframe branch of `cf gpio-config`: edit project.openframe in a 44-pad grid, then regenerate.""" + block = get_openframe_spec_from_project_json(str(project_json_path)) + if view: + if block is None: + console.print("[yellow]No openframe spec found.[/yellow]") + console.print("[dim]Run 'cf gpio-config' to configure GPIOs.[/dim]") + return + try: + spec = validate_spec(block) + except SpecError as e: + _print_spec_error(e) + raise click.Abort() + console.print("\n[bold cyan]Openframe GPIO configuration[/bold cyan]") + print_spec_summary(spec) + return + + from chipfoundry_cli.openframe.tui import OpenframeGridApp + + project_name = load_project_json(str(project_json_path)).get("project", {}).get("name", "") + result = OpenframeGridApp(block, project_name).run() + if result is None: + console.print("[yellow]Quit without saving; .cf/project.json unchanged.[/yellow]") + return + spec = validate_spec(result) + save_openframe_spec_to_project_json(str(project_json_path), spec.raw) + console.print(f"[green]✓ Saved project.openframe to {project_json_path}[/green]") + print_spec_summary(spec) + _generate(project_root, spec) + + +def register_openframe(main_group) -> None: + @main_group.group("openframe") + def openframe(): + """Openframe GPIO/power spec: validate, generate RTL and PDN settings, import/export.""" + + project_root_opt = click.option( + "--project-root", + type=click.Path(exists=True, file_okay=False), + help="Path to the project directory (defaults to current directory).", + ) + + @openframe.command("validate") + @project_root_opt + def validate_cmd(project_root): + """Validate project.openframe in .cf/project.json.""" + root = _project_root(project_root) + _require_openframe_project(root) + spec = _load_or_abort(root) + console.print("[green]✓ project.openframe is valid[/green]") + print_spec_summary(spec) + + @openframe.command("generate") + @project_root_opt + @click.option("--check", is_flag=True, help="Do not write; exit non-zero if generated files are missing or stale.") + def generate_cmd(project_root, check): + """Generate verilog/rtl/openframe_gpio.v and openlane/openframe_project_wrapper/power.json.""" + root = _project_root(project_root) + _require_openframe_project(root) + spec = _load_or_abort(root) + if check: + stale = stale_outputs(root, spec) + if stale: + for rel in stale: + console.print(f"[red]✗ {rel} is missing or out of date[/red]") + console.print("[yellow]Run 'cf openframe generate' to update.[/yellow]") + raise SystemExit(1) + console.print("[green]✓ Generated files match project.openframe[/green]") + return + _generate(root, spec) + + @openframe.command("export") + @click.argument("output", type=click.Path(dir_okay=False)) + @project_root_opt + @click.option("--format", "fmt", type=click.Choice(["json", "yaml"]), help="Defaults to the file extension.") + def export_cmd(output, project_root, fmt): + """Write project.openframe to a standalone YAML or JSON file.""" + root = _project_root(project_root) + _require_openframe_project(root) + spec = _load_or_abort(root) + try: + write_spec_file(output, spec.raw, fmt) + except SpecError as e: + _print_spec_error(e) + raise click.Abort() + console.print(f"[green]✓ Exported project.openframe to {output}[/green]") + + @openframe.command("import") + @click.argument("input_file", metavar="INPUT", type=click.Path(exists=True, dir_okay=False)) + @project_root_opt + @click.option("--format", "fmt", type=click.Choice(["json", "yaml"]), help="Defaults to the file extension.") + @click.option("--force", is_flag=True, help="Replace an existing, different project.openframe block.") + @click.option("--generate/--no-generate", "do_generate", default=True, show_default=True, + help="Regenerate RTL and power.json after importing.") + def import_cmd(input_file, project_root, fmt, force, do_generate): + """Validate a YAML or JSON spec file and store it as project.openframe.""" + root = _project_root(project_root) + project_json = _require_openframe_project(root) + try: + spec = validate_spec(read_spec_file(input_file, fmt)) + except SpecError as e: + _print_spec_error(e) + raise click.Abort() + existing = get_openframe_spec_from_project_json(str(project_json)) + if existing is not None and not force: + same = False + try: + same = normalize_spec(existing) == spec.raw + except (KeyError, TypeError): + same = False + if not same: + console.print( + "[red]✗ .cf/project.json already has a different project.openframe block. " + "Re-run with --force to replace it (export it first to keep a copy).[/red]" + ) + raise click.Abort() + save_openframe_spec_to_project_json(str(project_json), spec.raw) + console.print(f"[green]✓ Imported {input_file} into {project_json}[/green]") + print_spec_summary(spec) + if do_generate: + _generate(root, spec) diff --git a/chipfoundry_cli/openframe/generate.py b/chipfoundry_cli/openframe/generate.py new file mode 100644 index 0000000..85f2845 --- /dev/null +++ b/chipfoundry_cli/openframe/generate.py @@ -0,0 +1,44 @@ +"""Render and write every file derived from the openframe spec.""" + +from pathlib import Path +from typing import Dict, List + +from chipfoundry_cli.openframe.gpio_gen import GPIO_RTL_REL, generate_gpio_rtl +from chipfoundry_cli.openframe.power_gen import POWER_JSON_REL, generate_power_json +from chipfoundry_cli.openframe.spec import OpenframeSpec + + +def render_outputs(spec: OpenframeSpec) -> Dict[str, str]: + """Relative path -> file content for every generated file.""" + return { + GPIO_RTL_REL: generate_gpio_rtl(spec), + POWER_JSON_REL: generate_power_json(spec), + } + + +def stale_outputs(project_root, spec: OpenframeSpec) -> List[str]: + """Relative paths whose on-disk content differs from what the spec generates.""" + root = Path(project_root) + stale = [] + for rel, content in render_outputs(spec).items(): + path = root / rel + if not path.exists() or path.read_text() != content: + stale.append(rel) + return stale + + +def write_outputs(project_root, spec: OpenframeSpec) -> List[str]: + """Write generated files and return the relative paths that changed.""" + root = Path(project_root) + changed = [] + for rel, content in render_outputs(spec).items(): + path = root / rel + if not path.parent.is_dir(): + raise FileNotFoundError( + f"{path.parent} does not exist; is {root} an openframe_user_project checkout?" + ) + if path.exists() and path.read_text() == content: + continue + path.write_text(content) + changed.append(rel) + return changed diff --git a/chipfoundry_cli/openframe/gpio_gen.py b/chipfoundry_cli/openframe/gpio_gen.py new file mode 100644 index 0000000..96d5c51 --- /dev/null +++ b/chipfoundry_cli/openframe/gpio_gen.py @@ -0,0 +1,131 @@ +"""Generate verilog/rtl/openframe_gpio.v from a validated openframe spec.""" + +from typing import List + +from chipfoundry_cli.openframe.spec import MODES, NUM_PADS, OVERRIDES, PAD_PORTS, OpenframeSpec, Pad + +GPIO_RTL_REL = "verilog/rtl/openframe_gpio.v" +MODULE_NAME = "openframe_gpio" + + +def _range(width) -> str: + return f"[{width - 1}:0]" if width is not None else "" + + +def _signal_ref(name: str, width, bit: int) -> str: + return f"{name}[{bit}]" if width is not None else name + + +def _pad_label(spec: OpenframeSpec, pad: Pad) -> str: + if pad.unused: + return "(unused)" + if pad.signal is None: + return "(no signal)" + sig = next(s for s in spec.signals if s.name == pad.signal) + return f"{pad.signal}[{pad.bit}]" if sig.width is not None else pad.signal + + +def generate_gpio_rtl(spec: OpenframeSpec) -> str: + lines: List[str] = [] + w = lines.append + + w("// " + "-" * 77) + w(f"// {MODULE_NAME}") + w("//") + w("// GENERATED FILE - DO NOT EDIT.") + w("// Generated by `cf openframe generate` from the") + w("// project.openframe block in .cf/project.json. Edit the spec with") + w("// `cf gpio-config` or `cf openframe import`, then regenerate.") + w(f"// spec-sha256: {spec.sha256}") + if any(p.overrides for p in spec.pads): + w("// Uses CF_gpio_config pad overrides: requires CF_gpio_config v1.2.0 or later.") + w("//") + w("// Pad map:") + for pad in spec.pads: + w(f"// pad {pad.pad:2d} {pad.mode:<8s} {_pad_label(spec, pad)}") + w("// " + "-" * 77) + w("") + w("`default_nettype none") + w("") + w("// io_in is left open on pads that have no input signal.") + w("/* verilator lint_off PINCONNECTEMPTY */") + w(f"module {MODULE_NAME} (") + + port_lines = [] + if spec.signals: + port_lines.append(" // User design ports") + for sig in spec.signals: + for port, direction in sig.ports(): + rng = _range(sig.width) + port_lines.append(f" {direction:<6s} wire {rng:<7s} {port},") + port_lines.append("") + port_lines.append(" // Openframe pad interface: connect to the openframe_project_wrapper ports of the same name") + pad_rng = f"[{NUM_PADS - 1}:0]" + for i, (port, direction) in enumerate(PAD_PORTS): + sep = "," if i < len(PAD_PORTS) - 1 else "" + port_lines.append(f" {direction:<6s} wire {pad_rng:<7s} {port}{sep}") + lines.extend(port_lines) + w(");") + + for pad in spec.pads: + lines.extend(_instance(spec, pad)) + + w("") + w(f"endmodule // {MODULE_NAME}") + w("/* verilator lint_on PINCONNECTEMPTY */") + w("") + w("`default_nettype wire") + return "\n".join(lines) + "\n" + + +def _instance(spec: OpenframeSpec, pad: Pad) -> List[str]: + n = pad.pad + # Unused user inputs are tied to the pad's loopback constants (ignored by CF_gpio_config in these modes). + io_in = "" + io_out = f"gpio_loopback_zero[{n}]" + io_oeb = f"gpio_loopback_one[{n}]" + if pad.signal is not None: + sig = next(s for s in spec.signals if s.name == pad.signal) + bit = pad.bit if pad.bit is not None else 0 + if sig.direction == "in": + io_in = _signal_ref(sig.name, sig.width, bit) + elif sig.direction == "out": + io_out = _signal_ref(sig.name, sig.width, bit) + else: + io_in = _signal_ref(f"{sig.name}_in", sig.width, bit) + io_out = _signal_ref(f"{sig.name}_out", sig.width, bit) + io_oeb = _signal_ref(f"{sig.name}_oeb", sig.width, bit) + + params = [f".MODE(3'd{MODES[pad.mode]})"] + for key, param in OVERRIDES.items(): + if key in pad.overrides: + params.append(f".{param}(1'b{pad.overrides[key]})") + + out = ["", f" // pad {n}: {pad.mode} {_pad_label(spec, pad)}"] + out.append(" CF_gpio_config #(") + out.extend(f" {p}{',' if i < len(params) - 1 else ''}" for i, p in enumerate(params)) + out.append(f" ) gpio_cfg_{n:02d} (") + conns = [ + ("io_out", io_out), + ("io_in", io_in), + ("io_oeb", io_oeb), + ("gpio_zero", f"gpio_loopback_zero[{n}]"), + ("gpio_one", f"gpio_loopback_one[{n}]"), + ("gpio_in", f"gpio_in[{n}]"), + ("gpio_dm", f"{{gpio_dm2[{n}], gpio_dm1[{n}], gpio_dm0[{n}]}}"), + ("gpio_inp_dis", f"gpio_inp_dis[{n}]"), + ("gpio_oeb_out", f"gpio_oeb[{n}]"), + ("gpio_out_val", f"gpio_out[{n}]"), + ("gpio_analog_en", f"gpio_analog_en[{n}]"), + ("gpio_analog_sel", f"gpio_analog_sel[{n}]"), + ("gpio_analog_pol", f"gpio_analog_pol[{n}]"), + ("gpio_ib_mode_sel", f"gpio_ib_mode_sel[{n}]"), + ("gpio_vtrip_sel", f"gpio_vtrip_sel[{n}]"), + ("gpio_slow_sel", f"gpio_slow_sel[{n}]"), + ("gpio_holdover", f"gpio_holdover[{n}]"), + ] + for i, (pin, net) in enumerate(conns): + sep = "," if i < len(conns) - 1 else "" + out.append(f" .{pin:<16s} ({net}){sep}") + out.append(" );") + return out diff --git a/chipfoundry_cli/openframe/openframe.schema.json b/chipfoundry_cli/openframe/openframe.schema.json new file mode 100644 index 0000000..848956d --- /dev/null +++ b/chipfoundry_cli/openframe/openframe.schema.json @@ -0,0 +1,119 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://chipfoundry.io/schemas/openframe/v1.json", + "title": "ChipFoundry openframe project spec", + "description": "GPIO pad modes and power domains for an openframe_user_project. Stored under project.openframe in .cf/project.json.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "gpio", "power"], + "properties": { + "schema_version": { + "description": "Version of this schema. Only 1 is defined.", + "const": 1 + }, + "gpio": { + "type": "object", + "additionalProperties": false, + "required": ["pads"], + "properties": { + "unused_mode": { + "description": "Mode given to pads not listed in pads. When omitted, all 44 pads must be listed.", + "enum": ["analog", "input_pd", "input_pu"] + }, + "pads": { + "type": "array", + "maxItems": 44, + "items": { "$ref": "#/definitions/pad" } + } + } + }, + "power": { + "type": "object", + "additionalProperties": false, + "required": ["domains", "macros"], + "properties": { + "domains": { + "description": "Enabled power domains, named by their supply net. The first entry is the primary domain.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "enum": ["vccd1", "vccd2", "vdda1", "vdda2"] } + }, + "macros": { + "description": "Power domain of each user macro instance in openframe_project_wrapper.", + "type": "array", + "items": { "$ref": "#/definitions/macro" } + } + } + } + }, + "definitions": { + "identifier": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "bit": { + "type": "integer", + "enum": [0, 1] + }, + "pad": { + "type": "object", + "additionalProperties": false, + "required": ["pad", "mode"], + "properties": { + "pad": { + "description": "Openframe GPIO pad index.", + "type": "integer", + "minimum": 0, + "maximum": 43 + }, + "mode": { + "description": "CF_gpio_config MODE: analog=0, input=1, input_pd=2, input_pu=3, output=4, bidir=5.", + "enum": ["analog", "input", "input_pd", "input_pu", "output", "bidir"] + }, + "signal": { + "description": "User-facing port name on the generated openframe_gpio module.", + "$ref": "#/definitions/identifier" + }, + "bit": { + "description": "Bit index within signal. Omit for a 1-bit signal.", + "type": "integer", + "minimum": 0, + "maximum": 43 + }, + "overrides": { + "description": "Static pad controls. Each value drives the pad control bit directly. Requires CF_gpio_config v1.2.0.", + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "slow": { "$ref": "#/definitions/bit" }, + "vtrip": { "$ref": "#/definitions/bit" }, + "ib_mode": { "$ref": "#/definitions/bit" }, + "holdover": { "$ref": "#/definitions/bit" }, + "analog_en": { "$ref": "#/definitions/bit" }, + "analog_sel": { "$ref": "#/definitions/bit" }, + "analog_pol": { "$ref": "#/definitions/bit" } + } + } + } + }, + "macro": { + "type": "object", + "additionalProperties": false, + "required": ["instance", "domain", "vdd_pin", "gnd_pin"], + "properties": { + "instance": { + "description": "Macro instance name in openframe_project_wrapper (hierarchy separated by '/').", + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*(/[A-Za-z_][A-Za-z0-9_]*)*$" + }, + "domain": { + "enum": ["vccd1", "vccd2", "vdda1", "vdda2"] + }, + "vdd_pin": { "$ref": "#/definitions/identifier" }, + "gnd_pin": { "$ref": "#/definitions/identifier" } + } + } + } +} diff --git a/chipfoundry_cli/openframe/power_gen.py b/chipfoundry_cli/openframe/power_gen.py new file mode 100644 index 0000000..a76075d --- /dev/null +++ b/chipfoundry_cli/openframe/power_gen.py @@ -0,0 +1,29 @@ +"""Generate openlane/openframe_project_wrapper/power.json (LibreLane 3.x PDN variables).""" + +import json +from typing import Any, Dict + +from chipfoundry_cli.openframe.spec import POWER_DOMAINS, OpenframeSpec + +POWER_JSON_REL = "openlane/openframe_project_wrapper/power.json" + + +def power_config(spec: OpenframeSpec) -> Dict[str, Any]: + """LibreLane config fragment for the wrapper PDN. + + ``PDN_MACRO_CONNECTIONS`` entries follow LibreLane's + `` `` format. + """ + return { + "//": "Generated by `cf openframe generate` from project.openframe.power in .cf/project.json. Do not edit.", + "// spec-sha256": spec.sha256, + "VDD_NETS": list(spec.domains), + "GND_NETS": [POWER_DOMAINS[d] for d in spec.domains], + "PDN_MACRO_CONNECTIONS": [ + f"{m.instance} {m.domain} {m.gnd_net} {m.vdd_pin} {m.gnd_pin}" for m in spec.macros + ], + } + + +def generate_power_json(spec: OpenframeSpec) -> str: + return json.dumps(power_config(spec), indent=4) + "\n" diff --git a/chipfoundry_cli/openframe/spec.py b/chipfoundry_cli/openframe/spec.py new file mode 100644 index 0000000..e2a7764 --- /dev/null +++ b/chipfoundry_cli/openframe/spec.py @@ -0,0 +1,482 @@ +"""Openframe project spec: GPIO pad modes and power domains. + +The spec lives in ``.cf/project.json`` under ``project.openframe`` and is +validated in two passes: the JSON Schema shipped next to this module checks +structure, then :func:`validate_spec` checks the cross-field rules the schema +cannot express (duplicate pads, bus gaps, mixed directions, name clashes). +""" + +import hashlib +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import jsonschema +import yaml + +SCHEMA_VERSION = 1 +NUM_PADS = 44 +SCHEMA_PATH = Path(__file__).with_name("openframe.schema.json") + +MODES: Dict[str, int] = { + "analog": 0, + "input": 1, + "input_pd": 2, + "input_pu": 3, + "output": 4, + "bidir": 5, +} + +MODE_DESCRIPTIONS: Dict[str, str] = { + "analog": "Analog (digital buffers off)", + "input": "Input, no pull", + "input_pd": "Input, pull-down", + "input_pu": "Input, pull-up", + "output": "Output, push-pull", + "bidir": "Bidirectional", +} + +UNUSED_MODES = ("analog", "input_pd", "input_pu") + +# Direction of the user-facing signal, seen from the user design. +MODE_DIRECTION: Dict[str, Optional[str]] = { + "analog": None, + "input": "in", + "input_pd": "in", + "input_pu": "in", + "output": "out", + "bidir": "inout", +} + +# Spec override key -> CF_gpio_config parameter. +OVERRIDES: Dict[str, str] = { + "slow": "SLOW", + "vtrip": "VTRIP", + "ib_mode": "IB_MODE", + "holdover": "HOLDOVER", + "analog_en": "ANALOG_EN", + "analog_sel": "ANALOG_SEL", + "analog_pol": "ANALOG_POL", +} + +# Power domain (VDD net) -> ground net. +POWER_DOMAINS: Dict[str, str] = { + "vccd1": "vssd1", + "vccd2": "vssd2", + "vdda1": "vssa1", + "vdda2": "vssa2", +} + +# Ports of the generated openframe_gpio module that face the padframe. +PAD_PORTS: Tuple[Tuple[str, str], ...] = ( + ("gpio_in", "input"), + ("gpio_out", "output"), + ("gpio_oeb", "output"), + ("gpio_inp_dis", "output"), + ("gpio_ib_mode_sel", "output"), + ("gpio_vtrip_sel", "output"), + ("gpio_slow_sel", "output"), + ("gpio_holdover", "output"), + ("gpio_analog_en", "output"), + ("gpio_analog_sel", "output"), + ("gpio_analog_pol", "output"), + ("gpio_dm2", "output"), + ("gpio_dm1", "output"), + ("gpio_dm0", "output"), + ("gpio_loopback_one", "input"), + ("gpio_loopback_zero", "input"), +) + +# Remaining openframe_project_wrapper ports; reserved so user signals never shadow them. +_WRAPPER_PORTS = ( + "gpio_in_h", "analog_io", "analog_noesd_io", "porb_h", "porb_l", "por_l", + "resetb_h", "resetb_l", "mask_rev", + "vdda", "vdda1", "vdda2", "vssa", "vssa1", "vssa2", "vccd", "vccd1", "vccd2", + "vssd", "vssd1", "vssd2", "vddio", "vssio", +) + +RESERVED_NAMES = frozenset([name for name, _ in PAD_PORTS] + list(_WRAPPER_PORTS)) + +VERILOG_KEYWORDS = frozenset(""" +always and assign automatic begin buf bufif0 bufif1 case casex casez cell cmos config deassign default +defparam design disable edge else end endcase endconfig endfunction endgenerate endmodule endprimitive +endspecify endtable endtask event for force forever fork function generate genvar highz0 highz1 if ifnone +incdir include initial inout input instance integer join large liblist library localparam macromodule +medium module nand negedge nmos nor noshowcancelled not notif0 notif1 or output parameter pmos posedge +primitive pull0 pull1 pulldown pullup pulsestyle_ondetect pulsestyle_onevent rcmos real realtime reg +release repeat rnmos rpmos rtran rtranif0 rtranif1 scalared showcancelled signed small specify specparam +strong0 strong1 supply0 supply1 table task time tran tranif0 tranif1 tri tri0 tri1 triand trior trireg +unsigned use uwire vectored wait wand weak0 weak1 while wire wor xnor xor +logic bit byte int shortint longint interface endinterface class endclass package endpackage import export +""".split()) + +_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +class SpecError(ValueError): + """Raised when an openframe spec is missing or invalid. ``errors`` lists every problem found.""" + + def __init__(self, errors: List[str]): + if isinstance(errors, str): + errors = [errors] + self.errors = list(errors) + super().__init__("Invalid openframe spec:\n" + "\n".join(f" - {e}" for e in self.errors)) + + +@dataclass(frozen=True) +class Pad: + pad: int + mode: str + signal: Optional[str] = None + bit: Optional[int] = None + overrides: Dict[str, int] = field(default_factory=dict) + unused: bool = False + + +@dataclass(frozen=True) +class Signal: + """A user-facing signal made of one or more pads.""" + + name: str + direction: str # "in", "out" or "inout" (seen from the user design) + width: Optional[int] # None for a 1-bit signal without a bit index + pads: Tuple[int, ...] # pad index per bit, bit 0 first + + def ports(self) -> List[Tuple[str, str]]: + """(port name, verilog direction) pairs on the generated module.""" + if self.direction == "in": + return [(self.name, "output")] + if self.direction == "out": + return [(self.name, "input")] + return [(f"{self.name}_in", "output"), (f"{self.name}_out", "input"), (f"{self.name}_oeb", "input")] + + +@dataclass(frozen=True) +class MacroPower: + instance: str + domain: str + vdd_pin: str + gnd_pin: str + + @property + def gnd_net(self) -> str: + return POWER_DOMAINS[self.domain] + + +@dataclass(frozen=True) +class OpenframeSpec: + pads: Tuple[Pad, ...] # all 44 pads, index == pad number + signals: Tuple[Signal, ...] # ordered by lowest pad index + domains: Tuple[str, ...] + macros: Tuple[MacroPower, ...] + raw: Dict[str, Any] # normalized spec dict + unused_mode: Optional[str] = None + + @property + def sha256(self) -> str: + return spec_sha256(self.raw) + + +def load_schema() -> Dict[str, Any]: + with open(SCHEMA_PATH, "r") as f: + return json.load(f) + + +def _schema_errors(data: Any) -> List[str]: + validator = jsonschema.Draft7Validator(load_schema()) + errors = [] + for err in sorted(validator.iter_errors(data), key=lambda e: [str(p) for p in e.absolute_path]): + location = "/".join(str(p) for p in err.absolute_path) or "" + errors.append(f"{location}: {err.message}") + return errors + + +def _check_name(name: str, what: str, errors: List[str]) -> None: + if not _IDENT_RE.match(name): + errors.append(f"{what} '{name}' is not a valid Verilog identifier") + elif name in VERILOG_KEYWORDS: + errors.append(f"{what} '{name}' is a Verilog keyword") + elif name in RESERVED_NAMES: + errors.append(f"{what} '{name}' clashes with an openframe_project_wrapper port") + + +def validate_spec(data: Any) -> OpenframeSpec: + """Validate a ``project.openframe`` block and return the parsed spec. + + Raises :class:`SpecError` listing every problem found. + """ + schema_errors = _schema_errors(data) + if schema_errors: + raise SpecError(schema_errors) + + errors: List[str] = [] + gpio = data["gpio"] + unused_mode = gpio.get("unused_mode") + + by_pad: Dict[int, Pad] = {} + for entry in gpio["pads"]: + n = entry["pad"] + if n in by_pad: + errors.append(f"pad {n} is listed more than once") + continue + mode = entry["mode"] + signal = entry.get("signal") + bit = entry.get("bit") + overrides = dict(entry.get("overrides", {})) + if mode == "analog" and signal is not None: + errors.append( + f"pad {n}: analog pads cannot have a signal; use analog_io[{n}] on openframe_project_wrapper directly" + ) + if mode in ("output", "bidir") and signal is None: + errors.append(f"pad {n}: mode '{mode}' requires a signal to drive the pad") + if bit is not None and signal is None: + errors.append(f"pad {n}: 'bit' is set but 'signal' is missing") + if signal is not None: + _check_name(signal, f"pad {n}: signal", errors) + by_pad[n] = Pad(pad=n, mode=mode, signal=signal, bit=bit, overrides=overrides) + + missing = [n for n in range(NUM_PADS) if n not in by_pad] + if missing and unused_mode is None: + errors.append( + f"pads {_format_ranges(missing)} are not configured; list them or set gpio.unused_mode " + f"(one of {', '.join(UNUSED_MODES)})" + ) + for n in missing: + by_pad[n] = Pad(pad=n, mode=unused_mode or "analog", unused=True) + + signals = _build_signals([by_pad[n] for n in sorted(by_pad) if not by_pad[n].unused], errors) + + port_owner: Dict[str, str] = {} + for sig in signals: + for port, _ in sig.ports(): + if port in port_owner: + errors.append( + f"signal '{sig.name}' creates port '{port}', which is already used by signal '{port_owner[port]}'" + ) + else: + if port != sig.name: + _check_name(port, f"signal '{sig.name}' port", errors) + port_owner[port] = sig.name + + power = data["power"] + domains = tuple(power["domains"]) + macros: List[MacroPower] = [] + seen_instances = set() + for m in power["macros"]: + inst = m["instance"] + if inst in seen_instances: + errors.append(f"power: macro instance '{inst}' is listed more than once") + continue + seen_instances.add(inst) + if m["domain"] not in domains: + errors.append( + f"power: macro '{inst}' uses domain '{m['domain']}', " + f"which is not in power.domains ({', '.join(domains)})" + ) + macros.append(MacroPower(instance=inst, domain=m["domain"], vdd_pin=m["vdd_pin"], gnd_pin=m["gnd_pin"])) + + if errors: + raise SpecError(errors) + + return OpenframeSpec( + pads=tuple(by_pad[n] for n in range(NUM_PADS)), + signals=tuple(signals), + domains=domains, + macros=tuple(macros), + raw=normalize_spec(data), + unused_mode=unused_mode, + ) + + +def _build_signals(pads: List[Pad], errors: List[str]) -> List[Signal]: + groups: Dict[str, List[Pad]] = {} + for p in pads: + if p.signal is not None: + groups.setdefault(p.signal, []).append(p) + + signals = [] + for name, members in groups.items(): + directions = {MODE_DIRECTION[p.mode] for p in members} + if len(directions) > 1: + detail = ", ".join(f"pad {p.pad}={p.mode}" for p in members) + errors.append(f"signal '{name}' mixes directions ({detail}); all its pads must be input, output or bidir") + continue + direction = directions.pop() + + with_bit = [p for p in members if p.bit is not None] + if with_bit and len(with_bit) != len(members): + no_bit = [p.pad for p in members if p.bit is None] + errors.append( + f"signal '{name}': pads {_format_ranges(no_bit)} need a 'bit' because other pads of the bus set one" + ) + continue + + if not with_bit: + if len(members) > 1: + errors.append( + f"signal '{name}' is used by pads {_format_ranges([p.pad for p in members])} without bit indices" + ) + continue + signals.append(Signal(name=name, direction=direction, width=None, pads=(members[0].pad,))) + continue + + by_bit: Dict[int, int] = {} + bad = False + for p in members: + if p.bit in by_bit: + errors.append(f"signal '{name}': bit {p.bit} is assigned to both pad {by_bit[p.bit]} and pad {p.pad}") + bad = True + else: + by_bit[p.bit] = p.pad + if bad: + continue + width = max(by_bit) + 1 + gaps = [b for b in range(width) if b not in by_bit] + if gaps: + errors.append(f"signal '{name}[{width - 1}:0]' has no pad for bit(s) {_format_ranges(gaps)}") + continue + signals.append(Signal(name=name, direction=direction, width=width, pads=tuple(by_bit[b] for b in range(width)))) + + signals.sort(key=lambda s: min(s.pads)) + return signals + + +def normalize_spec(data: Dict[str, Any]) -> Dict[str, Any]: + """Canonical form: pads sorted by index, keys in a fixed order.""" + gpio = data["gpio"] + pads = [] + for entry in sorted(gpio["pads"], key=lambda e: e["pad"]): + out: Dict[str, Any] = {"pad": entry["pad"], "mode": entry["mode"]} + if "signal" in entry: + out["signal"] = entry["signal"] + if "bit" in entry: + out["bit"] = entry["bit"] + if entry.get("overrides"): + out["overrides"] = {k: entry["overrides"][k] for k in OVERRIDES if k in entry["overrides"]} + pads.append(out) + norm_gpio: Dict[str, Any] = {} + if "unused_mode" in gpio: + norm_gpio["unused_mode"] = gpio["unused_mode"] + norm_gpio["pads"] = pads + power = data["power"] + return { + "schema_version": data["schema_version"], + "gpio": norm_gpio, + "power": { + "domains": list(power["domains"]), + "macros": [ + {"instance": m["instance"], "domain": m["domain"], "vdd_pin": m["vdd_pin"], "gnd_pin": m["gnd_pin"]} + for m in power["macros"] + ], + }, + } + + +def spec_sha256(data: Dict[str, Any]) -> str: + canonical = json.dumps(normalize_spec(data), sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _format_ranges(nums: List[int]) -> str: + nums = sorted(nums) + if not nums: + return "-" + out = [] + start = prev = nums[0] + for n in nums[1:]: + if n == prev + 1: + prev = n + continue + out.append(f"{start}-{prev}" if start != prev else str(start)) + start = prev = n + out.append(f"{start}-{prev}" if start != prev else str(start)) + return ", ".join(out) + + +# --------------------------------------------------------------------------- +# File I/O (standalone YAML/JSON and .cf/project.json) +# --------------------------------------------------------------------------- + +def _reject_duplicate_keys(pairs): + out = {} + for key, value in pairs: + if key in out: + raise SpecError([f"duplicate key '{key}'"]) + out[key] = value + return out + + +class _StrictSafeLoader(yaml.SafeLoader): + pass + + +def _construct_mapping_no_dupes(loader, node, deep=False): + keys = set() + for key_node, _ in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in keys: + raise SpecError([f"duplicate key '{key}' (line {key_node.start_mark.line + 1})"]) + keys.add(key) + return loader.construct_mapping(node, deep=deep) + + +_StrictSafeLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping_no_dupes) + + +def _file_format(path: Path, fmt: Optional[str]) -> str: + if fmt: + if fmt not in ("json", "yaml"): + raise SpecError([f"unknown format '{fmt}'; use json or yaml"]) + return fmt + suffix = path.suffix.lower() + if suffix == ".json": + return "json" + if suffix in (".yaml", ".yml"): + return "yaml" + raise SpecError([f"cannot infer format from '{path.name}'; use a .json/.yaml/.yml extension or pass --format"]) + + +def read_spec_file(path, fmt: Optional[str] = None) -> Dict[str, Any]: + """Read a standalone spec file. Accepts either the bare block or ``{"openframe": {...}}``.""" + path = Path(path) + fmt = _file_format(path, fmt) + text = path.read_text() + try: + if fmt == "json": + data = json.loads(text, object_pairs_hook=_reject_duplicate_keys) + else: + data = yaml.load(text, Loader=_StrictSafeLoader) + except (json.JSONDecodeError, yaml.YAMLError) as e: + raise SpecError([f"{path}: cannot parse {fmt}: {e}"]) + if isinstance(data, dict) and set(data.keys()) == {"openframe"}: + data = data["openframe"] + if not isinstance(data, dict): + raise SpecError([f"{path}: expected a mapping at the top level"]) + return data + + +def write_spec_file(path, data: Dict[str, Any], fmt: Optional[str] = None) -> None: + path = Path(path) + fmt = _file_format(path, fmt) + data = normalize_spec(data) + if fmt == "json": + path.write_text(json.dumps(data, indent=2) + "\n") + else: + path.write_text(yaml.safe_dump(data, sort_keys=False, default_flow_style=None)) + + +def load_spec_from_project(project_root) -> OpenframeSpec: + """Load and validate ``project.openframe`` from ``/.cf/project.json``.""" + from chipfoundry_cli.utils import CF_PROJECT_JSON_REL, get_openframe_spec_from_project_json + + project_json = Path(project_root) / CF_PROJECT_JSON_REL + if not project_json.exists(): + raise SpecError([f"{project_json} not found; run 'cf init' first"]) + block = get_openframe_spec_from_project_json(str(project_json)) + if block is None: + raise SpecError([ + f"{project_json} has no project.openframe block; run 'cf gpio-config' or 'cf openframe import '" + ]) + return validate_spec(block) diff --git a/chipfoundry_cli/openframe/tui.py b/chipfoundry_cli/openframe/tui.py new file mode 100644 index 0000000..8cd7ef2 --- /dev/null +++ b/chipfoundry_cli/openframe/tui.py @@ -0,0 +1,399 @@ +"""Textual 44-pad grid for editing the openframe spec (``cf gpio-config`` on openframe projects).""" + +import copy +from typing import Any, Dict, List, Optional + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Grid, Horizontal, Vertical, VerticalScroll +from textual.screen import ModalScreen +from textual.widgets import Button, Checkbox, Footer, Header, Input, Label, RadioButton, RadioSet, Static, TextArea + +from chipfoundry_cli.openframe.spec import ( + MODE_DESCRIPTIONS, + MODES, + NUM_PADS, + OVERRIDES, + POWER_DOMAINS, + UNUSED_MODES, + SpecError, + validate_spec, +) + +MODE_COLORS = { + "analog": "magenta", + "input": "cyan", + "input_pd": "cyan", + "input_pu": "cyan", + "output": "ansi_bright_green", + "bidir": "yellow", +} + +GRID_COLS = 11 + + +def empty_spec() -> Dict[str, Any]: + """A spec with no pads and no power domains; it only validates once the user fills it in.""" + return {"schema_version": 1, "gpio": {"pads": []}, "power": {"domains": [], "macros": []}} + + +class PadCell(Static): + """One pad in the grid.""" + + def __init__(self, pad: int, entry: Optional[Dict[str, Any]], unused_mode: Optional[str]): + super().__init__(id=f"pad_{pad}") + self.pad = pad + self.entry = entry + self.unused_mode = unused_mode + self.is_selected = False + + def on_mount(self) -> None: + self.refresh_view() + + def refresh_view(self) -> None: + entry = self.entry + if entry is None: + mode = self.unused_mode + text = f"[b]{self.pad}[/b]\n[dim]{mode + '*' if mode else 'unset'}[/dim]\n" + color = MODE_COLORS.get(mode, "red") if mode else "red" + else: + mode = entry["mode"] + sig = entry.get("signal", "") + if sig and "bit" in entry: + sig = f"{sig}[{entry['bit']}]" + flag = "+" if entry.get("overrides") else "" + text = f"[b]{self.pad}[/b]\n{mode}{flag}\n{sig}" + color = MODE_COLORS[mode] + self.update(text) + self.set_class(self.is_selected, "selected") + if not self.is_selected: + self.styles.color = color + self.styles.border = ("solid", color) + + def on_click(self) -> None: + self.app.toggle_pad(self.pad) + + +class PadEditScreen(ModalScreen): + """Edit mode, signal, bit and overrides for one or more pads.""" + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + CSS = """ + PadEditScreen { align: center middle; } + #pad-dialog { width: 90; height: auto; max-height: 95%; padding: 1 2; background: $surface; border: solid cyan; } + #pad-title { text-style: bold; color: cyan; margin-bottom: 1; } + .field-label { margin-top: 1; text-style: bold; } + #overrides { grid-size: 4; height: auto; } + #overrides Checkbox { width: 1fr; } + #pad-buttons { margin-top: 1; height: auto; } + #pad-buttons Button { margin: 0 1; } + """ + + def __init__(self, pads: List[int], template: Optional[Dict[str, Any]]): + super().__init__() + self.pads = sorted(pads) + self.template = template or {} + + def compose(self) -> ComposeResult: + pads_str = ", ".join(str(p) for p in self.pads[:8]) + (" ..." if len(self.pads) > 8 else "") + multi = len(self.pads) > 1 + with VerticalScroll(id="pad-dialog"): + yield Label(f"Configure pad(s): {pads_str}", id="pad-title") + yield Label("Mode", classes="field-label") + with RadioSet(id="mode"): + current = self.template.get("mode", "input") + for mode in MODES: + label = f"{mode:<9s} {MODE_DESCRIPTIONS[mode]}" + yield RadioButton(label, value=(mode == current), id=f"mode_{mode}") + yield Label("Signal (port name on openframe_gpio; empty for none)", classes="field-label") + yield Input(value=self.template.get("signal", ""), placeholder="e.g. uart_tx", id="signal") + if multi: + bit_label = "Start bit (pads get consecutive bits in pad order)" + else: + bit_label = "Bit (empty for a 1-bit signal)" + yield Label(bit_label, classes="field-label") + bit_default = "0" if multi and "bit" not in self.template else str(self.template.get("bit", "")) + yield Input(value=bit_default, placeholder="e.g. 0", id="bit") + yield Label("Pad overrides (CF_gpio_config v1.2.0+)", classes="field-label") + overrides = self.template.get("overrides", {}) + with Grid(id="overrides"): + for key in OVERRIDES: + yield Checkbox(key, value=bool(overrides.get(key, 0)), id=f"ov_{key}") + yield Label("", id="pad-error") + with Horizontal(id="pad-buttons"): + yield Button("Apply", variant="primary", id="apply") + yield Button("Clear pad(s)", id="clear") + yield Button("Cancel", variant="error", id="cancel") + + def _selected_mode(self) -> Optional[str]: + radio = self.query_one("#mode", RadioSet) + if radio.pressed_button is None: + return None + return radio.pressed_button.id[len("mode_"):] + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "cancel": + self.dismiss(None) + elif event.button.id == "clear": + self.dismiss({"clear": True}) + elif event.button.id == "apply": + self._apply() + + def _apply(self) -> None: + error = self.query_one("#pad-error", Label) + mode = self._selected_mode() + if mode is None: + error.update("[red]Select a mode.[/red]") + return + signal = self.query_one("#signal", Input).value.strip() + bit_text = self.query_one("#bit", Input).value.strip() + bit: Optional[int] = None + if bit_text: + if not bit_text.isdigit(): + error.update(f"[red]Bit must be a non-negative integer, got '{bit_text}'.[/red]") + return + bit = int(bit_text) + overrides = {k: 1 for k in OVERRIDES if self.query_one(f"#ov_{k}", Checkbox).value} + self.dismiss({"mode": mode, "signal": signal or None, "bit": bit, "overrides": overrides}) + + def action_cancel(self) -> None: + self.dismiss(None) + + +class PowerScreen(ModalScreen): + """Edit enabled power domains and the per-macro domain mapping.""" + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + CSS = """ + PowerScreen { align: center middle; } + #power-dialog { width: 80; height: auto; max-height: 95%; padding: 1 2; background: $surface; border: solid cyan; } + #power-title { text-style: bold; color: cyan; } + .field-label { margin-top: 1; text-style: bold; } + #macros { height: 8; } + #power-buttons { margin-top: 1; height: auto; } + #power-buttons Button { margin: 0 1; } + """ + + def __init__(self, power: Dict[str, Any]): + super().__init__() + self.power = power + + def compose(self) -> ComposeResult: + domains = self.power.get("domains", []) + with Vertical(id="power-dialog"): + yield Label("Power domains", id="power-title") + yield Label("Enabled domains (the first enabled one in this order is the primary):", classes="field-label") + for vdd, gnd in POWER_DOMAINS.items(): + yield Checkbox(f"{vdd} / {gnd}", value=vdd in domains, id=f"dom_{vdd}") + yield Label("Macros, one per line: ", classes="field-label") + text = "\n".join( + f"{m['instance']} {m['domain']} {m['vdd_pin']} {m['gnd_pin']}" for m in self.power.get("macros", []) + ) + yield TextArea(text, id="macros") + yield Label("", id="power-error") + with Horizontal(id="power-buttons"): + yield Button("Apply", variant="primary", id="apply") + yield Button("Cancel", variant="error", id="cancel") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "cancel": + self.dismiss(None) + return + # Preserve the existing domain order; append newly enabled domains in canonical order. + enabled = {d for d in POWER_DOMAINS if self.query_one(f"#dom_{d}", Checkbox).value} + domains = [d for d in self.power.get("domains", []) if d in enabled] + domains += [d for d in POWER_DOMAINS if d in enabled and d not in domains] + macros = [] + for n, line in enumerate(self.query_one("#macros", TextArea).text.splitlines(), start=1): + fields = line.split() + if not fields: + continue + if len(fields) != 4: + self.query_one("#power-error", Label).update( + f"[red]Line {n}: expected 4 fields, got {len(fields)}: '{line.strip()}'[/red]" + ) + return + inst, domain, vdd_pin, gnd_pin = fields + macros.append({"instance": inst, "domain": domain, "vdd_pin": vdd_pin, "gnd_pin": gnd_pin}) + self.dismiss({"domains": domains, "macros": macros}) + + def action_cancel(self) -> None: + self.dismiss(None) + + +class OpenframeGridApp(App): + """Returns the edited spec dict on save, or None when the user quits without saving.""" + + CSS = """ + #main { padding: 0 1; } + #title { text-style: bold; color: cyan; margin-bottom: 1; } + #pad-grid { grid-size: 11; grid-gutter: 0; height: auto; } + PadCell { height: 5; min-width: 11; border: solid grey; content-align: center middle; text-align: center; } + PadCell.current { border: double cyan; background: $primary; } + PadCell.selected { background: $success; color: black; } + #info { margin-top: 1; } + #status { margin-top: 1; color: yellow; } + """ + + BINDINGS = [ + Binding("up", "nav(-11)", "Up", show=False), + Binding("down", "nav(11)", "Down", show=False), + Binding("left", "nav(-1)", "Left", show=False), + Binding("right", "nav(1)", "Right", show=False), + Binding("space", "toggle", "Select"), + Binding("enter", "edit", "Edit pad(s)"), + Binding("a", "select_all", "All"), + Binding("n", "select_none", "None"), + Binding("u", "cycle_unused", "Unused mode"), + Binding("p", "power", "Power"), + Binding("d", "done", "Save & exit"), + Binding("q", "quit_nosave", "Quit (no save)"), + ] + + def __init__(self, spec: Optional[Dict[str, Any]], project_name: str = ""): + super().__init__() + self.spec = copy.deepcopy(spec) if spec else empty_spec() + self.project_name = project_name + self.entries: Dict[int, Dict[str, Any]] = {e["pad"]: dict(e) for e in self.spec["gpio"]["pads"]} + self.cells: Dict[int, PadCell] = {} + self.current = 0 + + @property + def unused_mode(self) -> Optional[str]: + return self.spec["gpio"].get("unused_mode") + + def compose(self) -> ComposeResult: + yield Header(show_clock=False) + with Vertical(id="main"): + yield Label(f"Openframe GPIO configuration {self.project_name}".strip(), id="title") + with Grid(id="pad-grid"): + for pad in range(NUM_PADS): + cell = PadCell(pad, self.entries.get(pad), self.unused_mode) + self.cells[pad] = cell + yield cell + yield Label("", id="info") + yield Label("", id="status") + yield Footer() + + def on_mount(self) -> None: + self._highlight() + self._update_info() + + # -- helpers ----------------------------------------------------------- + def _highlight(self) -> None: + for pad, cell in self.cells.items(): + cell.set_class(pad == self.current, "current") + self.cells[self.current].scroll_visible() + + def _selected(self) -> List[int]: + chosen = [p for p, c in self.cells.items() if c.is_selected] + return chosen or [self.current] + + def _update_info(self) -> None: + power = self.spec["power"] + domains = ", ".join(power.get("domains", [])) or "[red]none[/red]" + macros = ", ".join(f"{m['instance']}->{m['domain']}" for m in power.get("macros", [])) or "none" + unused = self.unused_mode or "[red]unset: every pad must be configured[/red]" + self.query_one("#info", Label).update( + f"Unused pads (*): {unused} Power domains: {domains} Macros: {macros}" + ) + + def _set_status(self, text: str) -> None: + self.query_one("#status", Label).update(text) + + def _refresh_cells(self) -> None: + for pad, cell in self.cells.items(): + cell.entry = self.entries.get(pad) + cell.unused_mode = self.unused_mode + cell.refresh_view() + + def current_spec(self) -> Dict[str, Any]: + spec = copy.deepcopy(self.spec) + spec["gpio"]["pads"] = [self.entries[p] for p in sorted(self.entries)] + return spec + + # -- actions ----------------------------------------------------------- + def toggle_pad(self, pad: int) -> None: + self.current = pad + cell = self.cells[pad] + cell.is_selected = not cell.is_selected + cell.refresh_view() + self._highlight() + + def action_nav(self, delta: int) -> None: + target = self.current + delta + if 0 <= target < NUM_PADS: + self.current = target + self._highlight() + + def action_toggle(self) -> None: + self.toggle_pad(self.current) + + def action_select_all(self) -> None: + for cell in self.cells.values(): + cell.is_selected = True + cell.refresh_view() + + def action_select_none(self) -> None: + for cell in self.cells.values(): + cell.is_selected = False + cell.refresh_view() + + def action_cycle_unused(self) -> None: + order = [None] + list(UNUSED_MODES) + nxt = order[(order.index(self.unused_mode) + 1) % len(order)] + if nxt is None: + self.spec["gpio"].pop("unused_mode", None) + else: + self.spec["gpio"]["unused_mode"] = nxt + self._refresh_cells() + self._update_info() + + def action_edit(self) -> None: + pads = self._selected() + self.push_screen(PadEditScreen(pads, self.entries.get(pads[0])), lambda r: self._apply_edit(pads, r)) + + def _apply_edit(self, pads: List[int], result: Optional[Dict[str, Any]]) -> None: + if result is None: + return + if result.get("clear"): + for pad in pads: + self.entries.pop(pad, None) + else: + for i, pad in enumerate(sorted(pads)): + entry: Dict[str, Any] = {"pad": pad, "mode": result["mode"]} + if result["signal"]: + entry["signal"] = result["signal"] + if result["bit"] is not None: + entry["bit"] = result["bit"] + i if len(pads) > 1 else result["bit"] + if result["overrides"]: + entry["overrides"] = dict(result["overrides"]) + self.entries[pad] = entry + self.action_select_none() + self._refresh_cells() + self._set_status("") + + def action_power(self) -> None: + self.push_screen(PowerScreen(self.spec["power"]), self._apply_power) + + def _apply_power(self, result: Optional[Dict[str, Any]]) -> None: + if result is None: + return + self.spec["power"] = result + self._update_info() + + def action_done(self) -> None: + spec = self.current_spec() + try: + validate_spec(spec) + except SpecError as e: + shown = e.errors[:4] + more = f" (+{len(e.errors) - 4} more)" if len(e.errors) > 4 else "" + self._set_status("[red]Cannot save:[/red] " + "; ".join(shown) + more) + return + self.exit(result=spec) + + def action_quit_nosave(self) -> None: + self.exit(result=None) diff --git a/chipfoundry_cli/utils.py b/chipfoundry_cli/utils.py index 4def908..325b91f 100644 --- a/chipfoundry_cli/utils.py +++ b/chipfoundry_cli/utils.py @@ -794,4 +794,33 @@ def save_gpio_config_to_project_json(project_json_path: str, gpio_configs: Dict[ hex_config[str(gpio_num)] = mode_name data["project"]["gpio_config"] = hex_config + save_project_json(project_json_path, data) + + +def _no_duplicate_keys(pairs): + out = {} + for key, value in pairs: + if key in out: + raise ValueError(f"duplicate key '{key}'") + out[key] = value + return out + + +def get_openframe_spec_from_project_json(project_json_path: str) -> Optional[dict]: + """Return the raw project.openframe block, or None if the project has none. + + Duplicate JSON keys raise ValueError instead of silently keeping the last one. + """ + with open(project_json_path, 'r') as f: + data = json.load(f, object_pairs_hook=_no_duplicate_keys) + return data.get("project", {}).get("openframe") + + +def save_openframe_spec_to_project_json(project_json_path: str, spec_block: dict): + """Write the project.openframe block, leaving every other key (including gpio_config) untouched.""" + if Path(project_json_path).exists(): + data = load_project_json(project_json_path) + else: + data = {"project": {}} + data.setdefault("project", {})["openframe"] = spec_block save_project_json(project_json_path, data) \ No newline at end of file diff --git a/docs/OPENFRAME.md b/docs/OPENFRAME.md new file mode 100644 index 0000000..6103d25 --- /dev/null +++ b/docs/OPENFRAME.md @@ -0,0 +1,95 @@ +# Openframe flow with cf-cli + +This guide covers an `openframe_user_project` from pad configuration to precheck. The GPIO pad modes and +the power domains live in one spec, the `project.openframe` block of `.cf/project.json`. Everything else +is generated from it. + +```mermaid +flowchart LR + tui["cf gpio-config"] --> spec[".cf/project.json project.openframe"] + file["pinout.yaml / .json"] -->|"cf openframe import"| spec + spec -->|"cf openframe generate"| rtl["verilog/rtl/openframe_gpio.v"] + spec -->|"cf openframe generate"| pwr["openlane/openframe_project_wrapper/power.json"] + rtl --> harden["cf harden openframe_project_wrapper"] + pwr --> harden + harden --> precheck["cf precheck"] +``` + +## 1. Initialize + +```bash +cf init # choose type "openframe"; seeds project.openframe from .cf/openframe_default.json +cf setup # PDK, LibreLane (pinned in versions.json), precheck image +ipm install-dep --ip-root ip # CF_gpio_config (v1.2.0+ needed for pad overrides) +``` + +## 2. Configure the pads and power domains + +```bash +cf gpio-config # 44-pad grid +cf gpio-config --view # print the pinout +``` + +In the grid: arrows move, `Space` selects, `Enter` edits the selected pads (mode, signal, bit, overrides), +`u` cycles the mode for unlisted pads, `p` edits power domains and macro mappings, `d` validates and saves, +and `q` quits without saving. Saving also regenerates the files below. + +To edit by hand or exchange with other tools: + +```bash +cf openframe export pinout.yaml +cf openframe import pinout.yaml # validates, stores, regenerates +cf openframe import pinout.yaml --force # replace a different existing spec +``` + +The spec format, validation rules and JSON Schema are described in the README section +"Configure an Openframe Project". + +## 3. Generate + +```bash +cf openframe generate # write the generated files +cf openframe generate --check # exit 1 if any generated file is missing or stale (CI) +``` + +| File | Content | +|---|---| +| `verilog/rtl/openframe_gpio.v` | One `CF_gpio_config` per pad, with user ports named by the spec. `openframe_project_wrapper.v` instantiates it. | +| `openlane/openframe_project_wrapper/power.json` | `VDD_NETS`, `GND_NETS`, `PDN_MACRO_CONNECTIONS` (LibreLane 3.x) for the wrapper PDN. | + +Both files start with the `spec-sha256` of the spec they were generated from. Do not edit them by hand. + +## 4. Harden + +```bash +cf harden user_proj_timer # your macros first +cf harden openframe_project_wrapper # runs: librelane … power.json config.json +``` + +When a macro directory contains `power.json`, `cf harden` passes it before `config.*` on both the Nix and +Docker backends, exactly like `openlane/Makefile`. LibreLane has no config include and takes `meta` from +the last file, so `config.json` (flow and step reorder) must come last and must not set `VDD_NETS`, +`GND_NETS` or `PDN_MACRO_CONNECTIONS`. If `power.json` is missing, the wrapper PDN fails with an explicit +error instead of guessing nets. + +## 5. Precheck + +```bash +cf precheck +``` + +## Out-of-sync warnings + +`cf harden openframe_project_wrapper` and `cf precheck` compare the generated files with what +`project.openframe` would generate now. On a mismatch they print a warning panel with one line per file: + +| Reason | Meaning | +|---|---| +| `missing` | The file does not exist. | +| `generated from a different spec` | The `spec-sha256` header does not match the current spec (spec edited after generating). | +| `edited by hand since it was generated` | The header matches but the content differs. | +| `not generated by cf openframe generate` | The file has no `spec-sha256` header. | + +They also warn when `project.openframe` is missing or invalid (listing the validation errors). The command +still runs with the files on disk. Run `cf openframe generate` to bring them back in sync. Hardening other +macros does not warn, because they do not use the generated files. diff --git a/pyproject.toml b/pyproject.toml index 3f5a3f5..bd0f0c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chipfoundry-cli" -version = "2.6.0" +version = "2.7.1" description = "CLI tool to automate ChipFoundry project submission to SFTP server" authors = ["ChipFoundry "] readme = "README.md" @@ -17,6 +17,8 @@ paramiko = ">=3.0.0,<4" toml = ">=0.10,<1.0" httpx = ">=0.24.0,<1.0" textual = ">=0.40.0,<1" +pyyaml = ">=6.0,<7" +jsonschema = ">=4.0,<5" cf-ipm = "*" [tool.poetry.group.dev.dependencies] diff --git a/tests/data/CF_gpio_config.v b/tests/data/CF_gpio_config.v new file mode 100644 index 0000000..af46952 --- /dev/null +++ b/tests/data/CF_gpio_config.v @@ -0,0 +1,144 @@ +//============================================================================= +// Module: CF_gpio_config +// Description: Sky130 GPIO Pad Configuration Wrapper for Efabless Openframe +// Simple parameterized module - set MODE and forget about pad details +// License: Apache 2.0 +//============================================================================= + +`default_nettype none + +module CF_gpio_config #( + parameter [2:0] MODE = 3'd1, // 0=ANALOG, 1=INPUT, 2=INPUT_PD, 3=INPUT_PU, 4=OUTPUT, 5=BIDIR + //------------------------------------------------------------------------- + // Static pad control overrides (default 0 keeps the v1.1.x behavior) + //------------------------------------------------------------------------- + parameter [0:0] SLOW = 1'b0, // gpio_slow_sel: 0=fast slew, 1=slow slew + parameter [0:0] VTRIP = 1'b0, // gpio_vtrip_sel: 0=CMOS trip point, 1=LVTTL trip point + parameter [0:0] IB_MODE = 1'b0, // gpio_ib_mode_sel: input buffer mode select + parameter [0:0] HOLDOVER = 1'b0, // gpio_holdover: 1=hold pad state in sleep mode + parameter [0:0] ANALOG_EN = 1'b0, // gpio_analog_en: 1=enable analog mux connection + parameter [0:0] ANALOG_SEL = 1'b0, // gpio_analog_sel: 0=amuxbus_a, 1=amuxbus_b + parameter [0:0] ANALOG_POL = 1'b0 // gpio_analog_pol: analog mux polarity +)( + //------------------------------------------------------------------------- + // User Interface - Connect these to your design + //------------------------------------------------------------------------- + input wire io_out, // Data to drive pad (used in OUTPUT/BIDIR modes) + output wire io_in, // Data from pad (directly from gpio_in) + input wire io_oeb, // Output enable bar (BIDIR mode: 0=drive, 1=hi-z) + + //------------------------------------------------------------------------- + // Openframe Interface - From openframe_project_wrapper + //------------------------------------------------------------------------- + input wire gpio_zero, // Connection for 1'b0 + input wire gpio_one, // Connection for 1'b1 + input wire gpio_in, // Pad input (connect to gpio_in[n]) + + //------------------------------------------------------------------------- + // Openframe Interface - To openframe_project_wrapper pad config + //------------------------------------------------------------------------- + output wire [2:0] gpio_dm, // Drive mode {dm2, dm1, dm0} + output wire gpio_inp_dis, // Input disable + output wire gpio_oeb_out, // Output enable bar (active low) + output wire gpio_out_val, // Output value for gpio_out (includes pull mode values) + output wire gpio_analog_en, + output wire gpio_analog_sel, + output wire gpio_analog_pol, + output wire gpio_ib_mode_sel, + output wire gpio_vtrip_sel, + output wire gpio_slow_sel, + output wire gpio_holdover +); + + //------------------------------------------------------------------------- + // Mode Definitions + //------------------------------------------------------------------------- + localparam [2:0] MODE_ANALOG = 3'd0; // Analog mode - input/output disabled + localparam [2:0] MODE_INPUT = 3'd1; // Digital input, no pull resistor + localparam [2:0] MODE_INPUT_PD = 3'd2; // Digital input with pull-down + localparam [2:0] MODE_INPUT_PU = 3'd3; // Digital input with pull-up + localparam [2:0] MODE_OUTPUT = 3'd4; // Digital output, push-pull + localparam [2:0] MODE_BIDIR = 3'd5; // Bidirectional, direction from io_oeb + + //------------------------------------------------------------------------- + // Drive Mode (dm[2:0]) - Based on Sky130 pad behavioral model + //------------------------------------------------------------------------- + // The Sky130 GPIO pad uses bufif1 primitives with specific strengths: + // dm=110: bufif1(strong1, strong0) - strong 1, strong 0 + // dm=111: bufif1(pull1, pull0) - weak 1, weak 0 + // + // For weak pull behavior: + // - Pull-DOWN (read 0 when floating): dm=111 with out=0 gives weak pull to 0 + // - Pull-UP (read 1 when floating): dm=111 with out=1 gives weak pull to 1 + // + // MODE_ANALOG: dm=000 (Hi-Z, analog mode) + // MODE_INPUT: dm=001 (Input only, no pull) + // MODE_INPUT_PD: dm=111 (Weak pull to 0, requires oeb=0 and out=0) + // MODE_INPUT_PU: dm=111 (Weak pull to 1, requires oeb=0 and out=1) + // MODE_OUTPUT: dm=110 (Strong push-pull) + // MODE_BIDIR: dm=110 (Strong push-pull, direction from io_oeb) + //------------------------------------------------------------------------- + assign gpio_dm = (MODE == MODE_ANALOG) ? {gpio_zero, gpio_zero, gpio_zero} : // 3'b000 + (MODE == MODE_INPUT) ? {gpio_zero, gpio_zero, gpio_one } : // 3'b001 + (MODE == MODE_INPUT_PD) ? {gpio_one, gpio_one, gpio_one } : // 3'b111 Weak output + (MODE == MODE_INPUT_PU) ? {gpio_one, gpio_one, gpio_one } : // 3'b111 Weak output + (MODE == MODE_OUTPUT) ? {gpio_one, gpio_one, gpio_zero} : // 3'b110 Strong output + (MODE == MODE_BIDIR) ? {gpio_one, gpio_one, gpio_zero} : // 3'b110 Strong output + {gpio_zero, gpio_zero, gpio_one }; // 3'b001 Default: INPUT + + //------------------------------------------------------------------------- + // Input Disable + //------------------------------------------------------------------------- + // Disable input buffer for ANALOG and OUTPUT modes (not reading pad) + assign gpio_inp_dis = (MODE == MODE_ANALOG) ? gpio_one : // Disable input buffer for analog mode + (MODE == MODE_OUTPUT) ? gpio_one : // Disable input buffer for output mode + gpio_zero; // Enable input buffer + + //------------------------------------------------------------------------- + // Output Enable Bar (active low: 0=driving, 1=hi-z) + //------------------------------------------------------------------------- + // OUTPUT: always driving (oeb=0) + // BIDIR: controlled by user's io_oeb signal + // INPUT_PD: must drive to enable weak pull (oeb=0) + // INPUT_PU: must drive to enable weak pull (oeb=0) + // Others: always hi-z (oeb=1) + assign gpio_oeb_out = (MODE == MODE_OUTPUT) ? gpio_zero : + (MODE == MODE_BIDIR) ? io_oeb : + (MODE == MODE_INPUT_PD) ? gpio_zero : // Enable weak pull driver + (MODE == MODE_INPUT_PU) ? gpio_zero : // Enable weak pull driver + gpio_one ; + + //------------------------------------------------------------------------- + // Static Configuration (parameter overrides, default 0 for all modes) + //------------------------------------------------------------------------- + // Tied to the pad loopback constants, never to literal 1'b0/1'b1. + assign gpio_analog_en = ANALOG_EN ? gpio_one : gpio_zero; // Enable amuxbus_a/b for ground/power + assign gpio_analog_sel = ANALOG_SEL ? gpio_one : gpio_zero; // Choose amuxbus_a (0) or amuxbus_b (1) + assign gpio_analog_pol = ANALOG_POL ? gpio_one : gpio_zero; // Use amuxbus_a/b as ground (0) or power (1) + assign gpio_ib_mode_sel = IB_MODE ? gpio_one : gpio_zero; // Input buffer mode (0: VDDIO) + assign gpio_vtrip_sel = VTRIP ? gpio_one : gpio_zero; // Trip point (0: CMOS) + assign gpio_slow_sel = SLOW ? gpio_one : gpio_zero; // Slew rate (0: fast) + assign gpio_holdover = HOLDOVER ? gpio_one : gpio_zero; // Holdover (0: none) + + //------------------------------------------------------------------------- + // Output Value + //------------------------------------------------------------------------- + // For pull modes, drive the correct value to activate weak pull: + // INPUT_PD: out=0 to get weak pull to 0 + // INPUT_PU: out=1 to get weak pull to 1 + // For OUTPUT/BIDIR: pass through user's io_out + // For others: drive 0 (doesn't matter since oeb=1) + assign gpio_out_val = (MODE == MODE_OUTPUT) ? io_out : + (MODE == MODE_BIDIR) ? io_out : + (MODE == MODE_INPUT_PD) ? gpio_zero : // Drive 0 for weak pull-down + (MODE == MODE_INPUT_PU) ? gpio_one : // Drive 1 for weak pull-up + gpio_zero ; + + //------------------------------------------------------------------------- + // Input Passthrough + //------------------------------------------------------------------------- + assign io_in = gpio_in; + +endmodule + +`default_nettype wire diff --git a/tests/data/openframe_gpio_timer.v b/tests/data/openframe_gpio_timer.v new file mode 100644 index 0000000..e52a88f --- /dev/null +++ b/tests/data/openframe_gpio_timer.v @@ -0,0 +1,1101 @@ +// ----------------------------------------------------------------------------- +// openframe_gpio +// +// GENERATED FILE - DO NOT EDIT. +// Generated by `cf openframe generate` from the +// project.openframe block in .cf/project.json. Edit the spec with +// `cf gpio-config` or `cf openframe import`, then regenerate. +// spec-sha256: 151733931264b0fe1416e572d7fd11d76a4effdec5bf42e1cd0eb14fcdb1db11 +// +// Pad map: +// pad 0 input clk +// pad 1 input_pu rst +// pad 2 output out[0] +// pad 3 output out[1] +// pad 4 output out[2] +// pad 5 output out[3] +// pad 6 output out[4] +// pad 7 output out[5] +// pad 8 output out[6] +// pad 9 output out[7] +// pad 10 output out[8] +// pad 11 output out[9] +// pad 12 output out[10] +// pad 13 analog (unused) +// pad 14 analog (unused) +// pad 15 analog (unused) +// pad 16 analog (unused) +// pad 17 analog (unused) +// pad 18 analog (unused) +// pad 19 analog (unused) +// pad 20 analog (unused) +// pad 21 analog (unused) +// pad 22 analog (unused) +// pad 23 analog (unused) +// pad 24 analog (unused) +// pad 25 analog (unused) +// pad 26 analog (unused) +// pad 27 analog (unused) +// pad 28 analog (unused) +// pad 29 analog (unused) +// pad 30 analog (unused) +// pad 31 analog (unused) +// pad 32 analog (unused) +// pad 33 analog (unused) +// pad 34 analog (unused) +// pad 35 analog (unused) +// pad 36 analog (unused) +// pad 37 analog (unused) +// pad 38 analog (unused) +// pad 39 analog (unused) +// pad 40 analog (unused) +// pad 41 analog (unused) +// pad 42 analog (unused) +// pad 43 analog (unused) +// ----------------------------------------------------------------------------- + +`default_nettype none + +// io_in is left open on pads that have no input signal. +/* verilator lint_off PINCONNECTEMPTY */ +module openframe_gpio ( + // User design ports + output wire clk, + output wire rst, + input wire [10:0] out, + + // Openframe pad interface: connect to the openframe_project_wrapper ports of the same name + input wire [43:0] gpio_in, + output wire [43:0] gpio_out, + output wire [43:0] gpio_oeb, + output wire [43:0] gpio_inp_dis, + output wire [43:0] gpio_ib_mode_sel, + output wire [43:0] gpio_vtrip_sel, + output wire [43:0] gpio_slow_sel, + output wire [43:0] gpio_holdover, + output wire [43:0] gpio_analog_en, + output wire [43:0] gpio_analog_sel, + output wire [43:0] gpio_analog_pol, + output wire [43:0] gpio_dm2, + output wire [43:0] gpio_dm1, + output wire [43:0] gpio_dm0, + input wire [43:0] gpio_loopback_one, + input wire [43:0] gpio_loopback_zero +); + + // pad 0: input clk + CF_gpio_config #( + .MODE(3'd1) + ) gpio_cfg_00 ( + .io_out (gpio_loopback_zero[0]), + .io_in (clk), + .io_oeb (gpio_loopback_one[0]), + .gpio_zero (gpio_loopback_zero[0]), + .gpio_one (gpio_loopback_one[0]), + .gpio_in (gpio_in[0]), + .gpio_dm ({gpio_dm2[0], gpio_dm1[0], gpio_dm0[0]}), + .gpio_inp_dis (gpio_inp_dis[0]), + .gpio_oeb_out (gpio_oeb[0]), + .gpio_out_val (gpio_out[0]), + .gpio_analog_en (gpio_analog_en[0]), + .gpio_analog_sel (gpio_analog_sel[0]), + .gpio_analog_pol (gpio_analog_pol[0]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[0]), + .gpio_vtrip_sel (gpio_vtrip_sel[0]), + .gpio_slow_sel (gpio_slow_sel[0]), + .gpio_holdover (gpio_holdover[0]) + ); + + // pad 1: input_pu rst + CF_gpio_config #( + .MODE(3'd3) + ) gpio_cfg_01 ( + .io_out (gpio_loopback_zero[1]), + .io_in (rst), + .io_oeb (gpio_loopback_one[1]), + .gpio_zero (gpio_loopback_zero[1]), + .gpio_one (gpio_loopback_one[1]), + .gpio_in (gpio_in[1]), + .gpio_dm ({gpio_dm2[1], gpio_dm1[1], gpio_dm0[1]}), + .gpio_inp_dis (gpio_inp_dis[1]), + .gpio_oeb_out (gpio_oeb[1]), + .gpio_out_val (gpio_out[1]), + .gpio_analog_en (gpio_analog_en[1]), + .gpio_analog_sel (gpio_analog_sel[1]), + .gpio_analog_pol (gpio_analog_pol[1]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[1]), + .gpio_vtrip_sel (gpio_vtrip_sel[1]), + .gpio_slow_sel (gpio_slow_sel[1]), + .gpio_holdover (gpio_holdover[1]) + ); + + // pad 2: output out[0] + CF_gpio_config #( + .MODE(3'd4) + ) gpio_cfg_02 ( + .io_out (out[0]), + .io_in (), + .io_oeb (gpio_loopback_one[2]), + .gpio_zero (gpio_loopback_zero[2]), + .gpio_one (gpio_loopback_one[2]), + .gpio_in (gpio_in[2]), + .gpio_dm ({gpio_dm2[2], gpio_dm1[2], gpio_dm0[2]}), + .gpio_inp_dis (gpio_inp_dis[2]), + .gpio_oeb_out (gpio_oeb[2]), + .gpio_out_val (gpio_out[2]), + .gpio_analog_en (gpio_analog_en[2]), + .gpio_analog_sel (gpio_analog_sel[2]), + .gpio_analog_pol (gpio_analog_pol[2]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[2]), + .gpio_vtrip_sel (gpio_vtrip_sel[2]), + .gpio_slow_sel (gpio_slow_sel[2]), + .gpio_holdover (gpio_holdover[2]) + ); + + // pad 3: output out[1] + CF_gpio_config #( + .MODE(3'd4) + ) gpio_cfg_03 ( + .io_out (out[1]), + .io_in (), + .io_oeb (gpio_loopback_one[3]), + .gpio_zero (gpio_loopback_zero[3]), + .gpio_one (gpio_loopback_one[3]), + .gpio_in (gpio_in[3]), + .gpio_dm ({gpio_dm2[3], gpio_dm1[3], gpio_dm0[3]}), + .gpio_inp_dis (gpio_inp_dis[3]), + .gpio_oeb_out (gpio_oeb[3]), + .gpio_out_val (gpio_out[3]), + .gpio_analog_en (gpio_analog_en[3]), + .gpio_analog_sel (gpio_analog_sel[3]), + .gpio_analog_pol (gpio_analog_pol[3]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[3]), + .gpio_vtrip_sel (gpio_vtrip_sel[3]), + .gpio_slow_sel (gpio_slow_sel[3]), + .gpio_holdover (gpio_holdover[3]) + ); + + // pad 4: output out[2] + CF_gpio_config #( + .MODE(3'd4) + ) gpio_cfg_04 ( + .io_out (out[2]), + .io_in (), + .io_oeb (gpio_loopback_one[4]), + .gpio_zero (gpio_loopback_zero[4]), + .gpio_one (gpio_loopback_one[4]), + .gpio_in (gpio_in[4]), + .gpio_dm ({gpio_dm2[4], gpio_dm1[4], gpio_dm0[4]}), + .gpio_inp_dis (gpio_inp_dis[4]), + .gpio_oeb_out (gpio_oeb[4]), + .gpio_out_val (gpio_out[4]), + .gpio_analog_en (gpio_analog_en[4]), + .gpio_analog_sel (gpio_analog_sel[4]), + .gpio_analog_pol (gpio_analog_pol[4]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[4]), + .gpio_vtrip_sel (gpio_vtrip_sel[4]), + .gpio_slow_sel (gpio_slow_sel[4]), + .gpio_holdover (gpio_holdover[4]) + ); + + // pad 5: output out[3] + CF_gpio_config #( + .MODE(3'd4) + ) gpio_cfg_05 ( + .io_out (out[3]), + .io_in (), + .io_oeb (gpio_loopback_one[5]), + .gpio_zero (gpio_loopback_zero[5]), + .gpio_one (gpio_loopback_one[5]), + .gpio_in (gpio_in[5]), + .gpio_dm ({gpio_dm2[5], gpio_dm1[5], gpio_dm0[5]}), + .gpio_inp_dis (gpio_inp_dis[5]), + .gpio_oeb_out (gpio_oeb[5]), + .gpio_out_val (gpio_out[5]), + .gpio_analog_en (gpio_analog_en[5]), + .gpio_analog_sel (gpio_analog_sel[5]), + .gpio_analog_pol (gpio_analog_pol[5]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[5]), + .gpio_vtrip_sel (gpio_vtrip_sel[5]), + .gpio_slow_sel (gpio_slow_sel[5]), + .gpio_holdover (gpio_holdover[5]) + ); + + // pad 6: output out[4] + CF_gpio_config #( + .MODE(3'd4) + ) gpio_cfg_06 ( + .io_out (out[4]), + .io_in (), + .io_oeb (gpio_loopback_one[6]), + .gpio_zero (gpio_loopback_zero[6]), + .gpio_one (gpio_loopback_one[6]), + .gpio_in (gpio_in[6]), + .gpio_dm ({gpio_dm2[6], gpio_dm1[6], gpio_dm0[6]}), + .gpio_inp_dis (gpio_inp_dis[6]), + .gpio_oeb_out (gpio_oeb[6]), + .gpio_out_val (gpio_out[6]), + .gpio_analog_en (gpio_analog_en[6]), + .gpio_analog_sel (gpio_analog_sel[6]), + .gpio_analog_pol (gpio_analog_pol[6]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[6]), + .gpio_vtrip_sel (gpio_vtrip_sel[6]), + .gpio_slow_sel (gpio_slow_sel[6]), + .gpio_holdover (gpio_holdover[6]) + ); + + // pad 7: output out[5] + CF_gpio_config #( + .MODE(3'd4) + ) gpio_cfg_07 ( + .io_out (out[5]), + .io_in (), + .io_oeb (gpio_loopback_one[7]), + .gpio_zero (gpio_loopback_zero[7]), + .gpio_one (gpio_loopback_one[7]), + .gpio_in (gpio_in[7]), + .gpio_dm ({gpio_dm2[7], gpio_dm1[7], gpio_dm0[7]}), + .gpio_inp_dis (gpio_inp_dis[7]), + .gpio_oeb_out (gpio_oeb[7]), + .gpio_out_val (gpio_out[7]), + .gpio_analog_en (gpio_analog_en[7]), + .gpio_analog_sel (gpio_analog_sel[7]), + .gpio_analog_pol (gpio_analog_pol[7]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[7]), + .gpio_vtrip_sel (gpio_vtrip_sel[7]), + .gpio_slow_sel (gpio_slow_sel[7]), + .gpio_holdover (gpio_holdover[7]) + ); + + // pad 8: output out[6] + CF_gpio_config #( + .MODE(3'd4) + ) gpio_cfg_08 ( + .io_out (out[6]), + .io_in (), + .io_oeb (gpio_loopback_one[8]), + .gpio_zero (gpio_loopback_zero[8]), + .gpio_one (gpio_loopback_one[8]), + .gpio_in (gpio_in[8]), + .gpio_dm ({gpio_dm2[8], gpio_dm1[8], gpio_dm0[8]}), + .gpio_inp_dis (gpio_inp_dis[8]), + .gpio_oeb_out (gpio_oeb[8]), + .gpio_out_val (gpio_out[8]), + .gpio_analog_en (gpio_analog_en[8]), + .gpio_analog_sel (gpio_analog_sel[8]), + .gpio_analog_pol (gpio_analog_pol[8]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[8]), + .gpio_vtrip_sel (gpio_vtrip_sel[8]), + .gpio_slow_sel (gpio_slow_sel[8]), + .gpio_holdover (gpio_holdover[8]) + ); + + // pad 9: output out[7] + CF_gpio_config #( + .MODE(3'd4) + ) gpio_cfg_09 ( + .io_out (out[7]), + .io_in (), + .io_oeb (gpio_loopback_one[9]), + .gpio_zero (gpio_loopback_zero[9]), + .gpio_one (gpio_loopback_one[9]), + .gpio_in (gpio_in[9]), + .gpio_dm ({gpio_dm2[9], gpio_dm1[9], gpio_dm0[9]}), + .gpio_inp_dis (gpio_inp_dis[9]), + .gpio_oeb_out (gpio_oeb[9]), + .gpio_out_val (gpio_out[9]), + .gpio_analog_en (gpio_analog_en[9]), + .gpio_analog_sel (gpio_analog_sel[9]), + .gpio_analog_pol (gpio_analog_pol[9]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[9]), + .gpio_vtrip_sel (gpio_vtrip_sel[9]), + .gpio_slow_sel (gpio_slow_sel[9]), + .gpio_holdover (gpio_holdover[9]) + ); + + // pad 10: output out[8] + CF_gpio_config #( + .MODE(3'd4) + ) gpio_cfg_10 ( + .io_out (out[8]), + .io_in (), + .io_oeb (gpio_loopback_one[10]), + .gpio_zero (gpio_loopback_zero[10]), + .gpio_one (gpio_loopback_one[10]), + .gpio_in (gpio_in[10]), + .gpio_dm ({gpio_dm2[10], gpio_dm1[10], gpio_dm0[10]}), + .gpio_inp_dis (gpio_inp_dis[10]), + .gpio_oeb_out (gpio_oeb[10]), + .gpio_out_val (gpio_out[10]), + .gpio_analog_en (gpio_analog_en[10]), + .gpio_analog_sel (gpio_analog_sel[10]), + .gpio_analog_pol (gpio_analog_pol[10]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[10]), + .gpio_vtrip_sel (gpio_vtrip_sel[10]), + .gpio_slow_sel (gpio_slow_sel[10]), + .gpio_holdover (gpio_holdover[10]) + ); + + // pad 11: output out[9] + CF_gpio_config #( + .MODE(3'd4) + ) gpio_cfg_11 ( + .io_out (out[9]), + .io_in (), + .io_oeb (gpio_loopback_one[11]), + .gpio_zero (gpio_loopback_zero[11]), + .gpio_one (gpio_loopback_one[11]), + .gpio_in (gpio_in[11]), + .gpio_dm ({gpio_dm2[11], gpio_dm1[11], gpio_dm0[11]}), + .gpio_inp_dis (gpio_inp_dis[11]), + .gpio_oeb_out (gpio_oeb[11]), + .gpio_out_val (gpio_out[11]), + .gpio_analog_en (gpio_analog_en[11]), + .gpio_analog_sel (gpio_analog_sel[11]), + .gpio_analog_pol (gpio_analog_pol[11]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[11]), + .gpio_vtrip_sel (gpio_vtrip_sel[11]), + .gpio_slow_sel (gpio_slow_sel[11]), + .gpio_holdover (gpio_holdover[11]) + ); + + // pad 12: output out[10] + CF_gpio_config #( + .MODE(3'd4) + ) gpio_cfg_12 ( + .io_out (out[10]), + .io_in (), + .io_oeb (gpio_loopback_one[12]), + .gpio_zero (gpio_loopback_zero[12]), + .gpio_one (gpio_loopback_one[12]), + .gpio_in (gpio_in[12]), + .gpio_dm ({gpio_dm2[12], gpio_dm1[12], gpio_dm0[12]}), + .gpio_inp_dis (gpio_inp_dis[12]), + .gpio_oeb_out (gpio_oeb[12]), + .gpio_out_val (gpio_out[12]), + .gpio_analog_en (gpio_analog_en[12]), + .gpio_analog_sel (gpio_analog_sel[12]), + .gpio_analog_pol (gpio_analog_pol[12]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[12]), + .gpio_vtrip_sel (gpio_vtrip_sel[12]), + .gpio_slow_sel (gpio_slow_sel[12]), + .gpio_holdover (gpio_holdover[12]) + ); + + // pad 13: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_13 ( + .io_out (gpio_loopback_zero[13]), + .io_in (), + .io_oeb (gpio_loopback_one[13]), + .gpio_zero (gpio_loopback_zero[13]), + .gpio_one (gpio_loopback_one[13]), + .gpio_in (gpio_in[13]), + .gpio_dm ({gpio_dm2[13], gpio_dm1[13], gpio_dm0[13]}), + .gpio_inp_dis (gpio_inp_dis[13]), + .gpio_oeb_out (gpio_oeb[13]), + .gpio_out_val (gpio_out[13]), + .gpio_analog_en (gpio_analog_en[13]), + .gpio_analog_sel (gpio_analog_sel[13]), + .gpio_analog_pol (gpio_analog_pol[13]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[13]), + .gpio_vtrip_sel (gpio_vtrip_sel[13]), + .gpio_slow_sel (gpio_slow_sel[13]), + .gpio_holdover (gpio_holdover[13]) + ); + + // pad 14: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_14 ( + .io_out (gpio_loopback_zero[14]), + .io_in (), + .io_oeb (gpio_loopback_one[14]), + .gpio_zero (gpio_loopback_zero[14]), + .gpio_one (gpio_loopback_one[14]), + .gpio_in (gpio_in[14]), + .gpio_dm ({gpio_dm2[14], gpio_dm1[14], gpio_dm0[14]}), + .gpio_inp_dis (gpio_inp_dis[14]), + .gpio_oeb_out (gpio_oeb[14]), + .gpio_out_val (gpio_out[14]), + .gpio_analog_en (gpio_analog_en[14]), + .gpio_analog_sel (gpio_analog_sel[14]), + .gpio_analog_pol (gpio_analog_pol[14]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[14]), + .gpio_vtrip_sel (gpio_vtrip_sel[14]), + .gpio_slow_sel (gpio_slow_sel[14]), + .gpio_holdover (gpio_holdover[14]) + ); + + // pad 15: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_15 ( + .io_out (gpio_loopback_zero[15]), + .io_in (), + .io_oeb (gpio_loopback_one[15]), + .gpio_zero (gpio_loopback_zero[15]), + .gpio_one (gpio_loopback_one[15]), + .gpio_in (gpio_in[15]), + .gpio_dm ({gpio_dm2[15], gpio_dm1[15], gpio_dm0[15]}), + .gpio_inp_dis (gpio_inp_dis[15]), + .gpio_oeb_out (gpio_oeb[15]), + .gpio_out_val (gpio_out[15]), + .gpio_analog_en (gpio_analog_en[15]), + .gpio_analog_sel (gpio_analog_sel[15]), + .gpio_analog_pol (gpio_analog_pol[15]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[15]), + .gpio_vtrip_sel (gpio_vtrip_sel[15]), + .gpio_slow_sel (gpio_slow_sel[15]), + .gpio_holdover (gpio_holdover[15]) + ); + + // pad 16: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_16 ( + .io_out (gpio_loopback_zero[16]), + .io_in (), + .io_oeb (gpio_loopback_one[16]), + .gpio_zero (gpio_loopback_zero[16]), + .gpio_one (gpio_loopback_one[16]), + .gpio_in (gpio_in[16]), + .gpio_dm ({gpio_dm2[16], gpio_dm1[16], gpio_dm0[16]}), + .gpio_inp_dis (gpio_inp_dis[16]), + .gpio_oeb_out (gpio_oeb[16]), + .gpio_out_val (gpio_out[16]), + .gpio_analog_en (gpio_analog_en[16]), + .gpio_analog_sel (gpio_analog_sel[16]), + .gpio_analog_pol (gpio_analog_pol[16]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[16]), + .gpio_vtrip_sel (gpio_vtrip_sel[16]), + .gpio_slow_sel (gpio_slow_sel[16]), + .gpio_holdover (gpio_holdover[16]) + ); + + // pad 17: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_17 ( + .io_out (gpio_loopback_zero[17]), + .io_in (), + .io_oeb (gpio_loopback_one[17]), + .gpio_zero (gpio_loopback_zero[17]), + .gpio_one (gpio_loopback_one[17]), + .gpio_in (gpio_in[17]), + .gpio_dm ({gpio_dm2[17], gpio_dm1[17], gpio_dm0[17]}), + .gpio_inp_dis (gpio_inp_dis[17]), + .gpio_oeb_out (gpio_oeb[17]), + .gpio_out_val (gpio_out[17]), + .gpio_analog_en (gpio_analog_en[17]), + .gpio_analog_sel (gpio_analog_sel[17]), + .gpio_analog_pol (gpio_analog_pol[17]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[17]), + .gpio_vtrip_sel (gpio_vtrip_sel[17]), + .gpio_slow_sel (gpio_slow_sel[17]), + .gpio_holdover (gpio_holdover[17]) + ); + + // pad 18: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_18 ( + .io_out (gpio_loopback_zero[18]), + .io_in (), + .io_oeb (gpio_loopback_one[18]), + .gpio_zero (gpio_loopback_zero[18]), + .gpio_one (gpio_loopback_one[18]), + .gpio_in (gpio_in[18]), + .gpio_dm ({gpio_dm2[18], gpio_dm1[18], gpio_dm0[18]}), + .gpio_inp_dis (gpio_inp_dis[18]), + .gpio_oeb_out (gpio_oeb[18]), + .gpio_out_val (gpio_out[18]), + .gpio_analog_en (gpio_analog_en[18]), + .gpio_analog_sel (gpio_analog_sel[18]), + .gpio_analog_pol (gpio_analog_pol[18]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[18]), + .gpio_vtrip_sel (gpio_vtrip_sel[18]), + .gpio_slow_sel (gpio_slow_sel[18]), + .gpio_holdover (gpio_holdover[18]) + ); + + // pad 19: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_19 ( + .io_out (gpio_loopback_zero[19]), + .io_in (), + .io_oeb (gpio_loopback_one[19]), + .gpio_zero (gpio_loopback_zero[19]), + .gpio_one (gpio_loopback_one[19]), + .gpio_in (gpio_in[19]), + .gpio_dm ({gpio_dm2[19], gpio_dm1[19], gpio_dm0[19]}), + .gpio_inp_dis (gpio_inp_dis[19]), + .gpio_oeb_out (gpio_oeb[19]), + .gpio_out_val (gpio_out[19]), + .gpio_analog_en (gpio_analog_en[19]), + .gpio_analog_sel (gpio_analog_sel[19]), + .gpio_analog_pol (gpio_analog_pol[19]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[19]), + .gpio_vtrip_sel (gpio_vtrip_sel[19]), + .gpio_slow_sel (gpio_slow_sel[19]), + .gpio_holdover (gpio_holdover[19]) + ); + + // pad 20: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_20 ( + .io_out (gpio_loopback_zero[20]), + .io_in (), + .io_oeb (gpio_loopback_one[20]), + .gpio_zero (gpio_loopback_zero[20]), + .gpio_one (gpio_loopback_one[20]), + .gpio_in (gpio_in[20]), + .gpio_dm ({gpio_dm2[20], gpio_dm1[20], gpio_dm0[20]}), + .gpio_inp_dis (gpio_inp_dis[20]), + .gpio_oeb_out (gpio_oeb[20]), + .gpio_out_val (gpio_out[20]), + .gpio_analog_en (gpio_analog_en[20]), + .gpio_analog_sel (gpio_analog_sel[20]), + .gpio_analog_pol (gpio_analog_pol[20]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[20]), + .gpio_vtrip_sel (gpio_vtrip_sel[20]), + .gpio_slow_sel (gpio_slow_sel[20]), + .gpio_holdover (gpio_holdover[20]) + ); + + // pad 21: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_21 ( + .io_out (gpio_loopback_zero[21]), + .io_in (), + .io_oeb (gpio_loopback_one[21]), + .gpio_zero (gpio_loopback_zero[21]), + .gpio_one (gpio_loopback_one[21]), + .gpio_in (gpio_in[21]), + .gpio_dm ({gpio_dm2[21], gpio_dm1[21], gpio_dm0[21]}), + .gpio_inp_dis (gpio_inp_dis[21]), + .gpio_oeb_out (gpio_oeb[21]), + .gpio_out_val (gpio_out[21]), + .gpio_analog_en (gpio_analog_en[21]), + .gpio_analog_sel (gpio_analog_sel[21]), + .gpio_analog_pol (gpio_analog_pol[21]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[21]), + .gpio_vtrip_sel (gpio_vtrip_sel[21]), + .gpio_slow_sel (gpio_slow_sel[21]), + .gpio_holdover (gpio_holdover[21]) + ); + + // pad 22: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_22 ( + .io_out (gpio_loopback_zero[22]), + .io_in (), + .io_oeb (gpio_loopback_one[22]), + .gpio_zero (gpio_loopback_zero[22]), + .gpio_one (gpio_loopback_one[22]), + .gpio_in (gpio_in[22]), + .gpio_dm ({gpio_dm2[22], gpio_dm1[22], gpio_dm0[22]}), + .gpio_inp_dis (gpio_inp_dis[22]), + .gpio_oeb_out (gpio_oeb[22]), + .gpio_out_val (gpio_out[22]), + .gpio_analog_en (gpio_analog_en[22]), + .gpio_analog_sel (gpio_analog_sel[22]), + .gpio_analog_pol (gpio_analog_pol[22]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[22]), + .gpio_vtrip_sel (gpio_vtrip_sel[22]), + .gpio_slow_sel (gpio_slow_sel[22]), + .gpio_holdover (gpio_holdover[22]) + ); + + // pad 23: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_23 ( + .io_out (gpio_loopback_zero[23]), + .io_in (), + .io_oeb (gpio_loopback_one[23]), + .gpio_zero (gpio_loopback_zero[23]), + .gpio_one (gpio_loopback_one[23]), + .gpio_in (gpio_in[23]), + .gpio_dm ({gpio_dm2[23], gpio_dm1[23], gpio_dm0[23]}), + .gpio_inp_dis (gpio_inp_dis[23]), + .gpio_oeb_out (gpio_oeb[23]), + .gpio_out_val (gpio_out[23]), + .gpio_analog_en (gpio_analog_en[23]), + .gpio_analog_sel (gpio_analog_sel[23]), + .gpio_analog_pol (gpio_analog_pol[23]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[23]), + .gpio_vtrip_sel (gpio_vtrip_sel[23]), + .gpio_slow_sel (gpio_slow_sel[23]), + .gpio_holdover (gpio_holdover[23]) + ); + + // pad 24: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_24 ( + .io_out (gpio_loopback_zero[24]), + .io_in (), + .io_oeb (gpio_loopback_one[24]), + .gpio_zero (gpio_loopback_zero[24]), + .gpio_one (gpio_loopback_one[24]), + .gpio_in (gpio_in[24]), + .gpio_dm ({gpio_dm2[24], gpio_dm1[24], gpio_dm0[24]}), + .gpio_inp_dis (gpio_inp_dis[24]), + .gpio_oeb_out (gpio_oeb[24]), + .gpio_out_val (gpio_out[24]), + .gpio_analog_en (gpio_analog_en[24]), + .gpio_analog_sel (gpio_analog_sel[24]), + .gpio_analog_pol (gpio_analog_pol[24]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[24]), + .gpio_vtrip_sel (gpio_vtrip_sel[24]), + .gpio_slow_sel (gpio_slow_sel[24]), + .gpio_holdover (gpio_holdover[24]) + ); + + // pad 25: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_25 ( + .io_out (gpio_loopback_zero[25]), + .io_in (), + .io_oeb (gpio_loopback_one[25]), + .gpio_zero (gpio_loopback_zero[25]), + .gpio_one (gpio_loopback_one[25]), + .gpio_in (gpio_in[25]), + .gpio_dm ({gpio_dm2[25], gpio_dm1[25], gpio_dm0[25]}), + .gpio_inp_dis (gpio_inp_dis[25]), + .gpio_oeb_out (gpio_oeb[25]), + .gpio_out_val (gpio_out[25]), + .gpio_analog_en (gpio_analog_en[25]), + .gpio_analog_sel (gpio_analog_sel[25]), + .gpio_analog_pol (gpio_analog_pol[25]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[25]), + .gpio_vtrip_sel (gpio_vtrip_sel[25]), + .gpio_slow_sel (gpio_slow_sel[25]), + .gpio_holdover (gpio_holdover[25]) + ); + + // pad 26: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_26 ( + .io_out (gpio_loopback_zero[26]), + .io_in (), + .io_oeb (gpio_loopback_one[26]), + .gpio_zero (gpio_loopback_zero[26]), + .gpio_one (gpio_loopback_one[26]), + .gpio_in (gpio_in[26]), + .gpio_dm ({gpio_dm2[26], gpio_dm1[26], gpio_dm0[26]}), + .gpio_inp_dis (gpio_inp_dis[26]), + .gpio_oeb_out (gpio_oeb[26]), + .gpio_out_val (gpio_out[26]), + .gpio_analog_en (gpio_analog_en[26]), + .gpio_analog_sel (gpio_analog_sel[26]), + .gpio_analog_pol (gpio_analog_pol[26]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[26]), + .gpio_vtrip_sel (gpio_vtrip_sel[26]), + .gpio_slow_sel (gpio_slow_sel[26]), + .gpio_holdover (gpio_holdover[26]) + ); + + // pad 27: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_27 ( + .io_out (gpio_loopback_zero[27]), + .io_in (), + .io_oeb (gpio_loopback_one[27]), + .gpio_zero (gpio_loopback_zero[27]), + .gpio_one (gpio_loopback_one[27]), + .gpio_in (gpio_in[27]), + .gpio_dm ({gpio_dm2[27], gpio_dm1[27], gpio_dm0[27]}), + .gpio_inp_dis (gpio_inp_dis[27]), + .gpio_oeb_out (gpio_oeb[27]), + .gpio_out_val (gpio_out[27]), + .gpio_analog_en (gpio_analog_en[27]), + .gpio_analog_sel (gpio_analog_sel[27]), + .gpio_analog_pol (gpio_analog_pol[27]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[27]), + .gpio_vtrip_sel (gpio_vtrip_sel[27]), + .gpio_slow_sel (gpio_slow_sel[27]), + .gpio_holdover (gpio_holdover[27]) + ); + + // pad 28: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_28 ( + .io_out (gpio_loopback_zero[28]), + .io_in (), + .io_oeb (gpio_loopback_one[28]), + .gpio_zero (gpio_loopback_zero[28]), + .gpio_one (gpio_loopback_one[28]), + .gpio_in (gpio_in[28]), + .gpio_dm ({gpio_dm2[28], gpio_dm1[28], gpio_dm0[28]}), + .gpio_inp_dis (gpio_inp_dis[28]), + .gpio_oeb_out (gpio_oeb[28]), + .gpio_out_val (gpio_out[28]), + .gpio_analog_en (gpio_analog_en[28]), + .gpio_analog_sel (gpio_analog_sel[28]), + .gpio_analog_pol (gpio_analog_pol[28]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[28]), + .gpio_vtrip_sel (gpio_vtrip_sel[28]), + .gpio_slow_sel (gpio_slow_sel[28]), + .gpio_holdover (gpio_holdover[28]) + ); + + // pad 29: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_29 ( + .io_out (gpio_loopback_zero[29]), + .io_in (), + .io_oeb (gpio_loopback_one[29]), + .gpio_zero (gpio_loopback_zero[29]), + .gpio_one (gpio_loopback_one[29]), + .gpio_in (gpio_in[29]), + .gpio_dm ({gpio_dm2[29], gpio_dm1[29], gpio_dm0[29]}), + .gpio_inp_dis (gpio_inp_dis[29]), + .gpio_oeb_out (gpio_oeb[29]), + .gpio_out_val (gpio_out[29]), + .gpio_analog_en (gpio_analog_en[29]), + .gpio_analog_sel (gpio_analog_sel[29]), + .gpio_analog_pol (gpio_analog_pol[29]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[29]), + .gpio_vtrip_sel (gpio_vtrip_sel[29]), + .gpio_slow_sel (gpio_slow_sel[29]), + .gpio_holdover (gpio_holdover[29]) + ); + + // pad 30: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_30 ( + .io_out (gpio_loopback_zero[30]), + .io_in (), + .io_oeb (gpio_loopback_one[30]), + .gpio_zero (gpio_loopback_zero[30]), + .gpio_one (gpio_loopback_one[30]), + .gpio_in (gpio_in[30]), + .gpio_dm ({gpio_dm2[30], gpio_dm1[30], gpio_dm0[30]}), + .gpio_inp_dis (gpio_inp_dis[30]), + .gpio_oeb_out (gpio_oeb[30]), + .gpio_out_val (gpio_out[30]), + .gpio_analog_en (gpio_analog_en[30]), + .gpio_analog_sel (gpio_analog_sel[30]), + .gpio_analog_pol (gpio_analog_pol[30]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[30]), + .gpio_vtrip_sel (gpio_vtrip_sel[30]), + .gpio_slow_sel (gpio_slow_sel[30]), + .gpio_holdover (gpio_holdover[30]) + ); + + // pad 31: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_31 ( + .io_out (gpio_loopback_zero[31]), + .io_in (), + .io_oeb (gpio_loopback_one[31]), + .gpio_zero (gpio_loopback_zero[31]), + .gpio_one (gpio_loopback_one[31]), + .gpio_in (gpio_in[31]), + .gpio_dm ({gpio_dm2[31], gpio_dm1[31], gpio_dm0[31]}), + .gpio_inp_dis (gpio_inp_dis[31]), + .gpio_oeb_out (gpio_oeb[31]), + .gpio_out_val (gpio_out[31]), + .gpio_analog_en (gpio_analog_en[31]), + .gpio_analog_sel (gpio_analog_sel[31]), + .gpio_analog_pol (gpio_analog_pol[31]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[31]), + .gpio_vtrip_sel (gpio_vtrip_sel[31]), + .gpio_slow_sel (gpio_slow_sel[31]), + .gpio_holdover (gpio_holdover[31]) + ); + + // pad 32: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_32 ( + .io_out (gpio_loopback_zero[32]), + .io_in (), + .io_oeb (gpio_loopback_one[32]), + .gpio_zero (gpio_loopback_zero[32]), + .gpio_one (gpio_loopback_one[32]), + .gpio_in (gpio_in[32]), + .gpio_dm ({gpio_dm2[32], gpio_dm1[32], gpio_dm0[32]}), + .gpio_inp_dis (gpio_inp_dis[32]), + .gpio_oeb_out (gpio_oeb[32]), + .gpio_out_val (gpio_out[32]), + .gpio_analog_en (gpio_analog_en[32]), + .gpio_analog_sel (gpio_analog_sel[32]), + .gpio_analog_pol (gpio_analog_pol[32]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[32]), + .gpio_vtrip_sel (gpio_vtrip_sel[32]), + .gpio_slow_sel (gpio_slow_sel[32]), + .gpio_holdover (gpio_holdover[32]) + ); + + // pad 33: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_33 ( + .io_out (gpio_loopback_zero[33]), + .io_in (), + .io_oeb (gpio_loopback_one[33]), + .gpio_zero (gpio_loopback_zero[33]), + .gpio_one (gpio_loopback_one[33]), + .gpio_in (gpio_in[33]), + .gpio_dm ({gpio_dm2[33], gpio_dm1[33], gpio_dm0[33]}), + .gpio_inp_dis (gpio_inp_dis[33]), + .gpio_oeb_out (gpio_oeb[33]), + .gpio_out_val (gpio_out[33]), + .gpio_analog_en (gpio_analog_en[33]), + .gpio_analog_sel (gpio_analog_sel[33]), + .gpio_analog_pol (gpio_analog_pol[33]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[33]), + .gpio_vtrip_sel (gpio_vtrip_sel[33]), + .gpio_slow_sel (gpio_slow_sel[33]), + .gpio_holdover (gpio_holdover[33]) + ); + + // pad 34: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_34 ( + .io_out (gpio_loopback_zero[34]), + .io_in (), + .io_oeb (gpio_loopback_one[34]), + .gpio_zero (gpio_loopback_zero[34]), + .gpio_one (gpio_loopback_one[34]), + .gpio_in (gpio_in[34]), + .gpio_dm ({gpio_dm2[34], gpio_dm1[34], gpio_dm0[34]}), + .gpio_inp_dis (gpio_inp_dis[34]), + .gpio_oeb_out (gpio_oeb[34]), + .gpio_out_val (gpio_out[34]), + .gpio_analog_en (gpio_analog_en[34]), + .gpio_analog_sel (gpio_analog_sel[34]), + .gpio_analog_pol (gpio_analog_pol[34]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[34]), + .gpio_vtrip_sel (gpio_vtrip_sel[34]), + .gpio_slow_sel (gpio_slow_sel[34]), + .gpio_holdover (gpio_holdover[34]) + ); + + // pad 35: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_35 ( + .io_out (gpio_loopback_zero[35]), + .io_in (), + .io_oeb (gpio_loopback_one[35]), + .gpio_zero (gpio_loopback_zero[35]), + .gpio_one (gpio_loopback_one[35]), + .gpio_in (gpio_in[35]), + .gpio_dm ({gpio_dm2[35], gpio_dm1[35], gpio_dm0[35]}), + .gpio_inp_dis (gpio_inp_dis[35]), + .gpio_oeb_out (gpio_oeb[35]), + .gpio_out_val (gpio_out[35]), + .gpio_analog_en (gpio_analog_en[35]), + .gpio_analog_sel (gpio_analog_sel[35]), + .gpio_analog_pol (gpio_analog_pol[35]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[35]), + .gpio_vtrip_sel (gpio_vtrip_sel[35]), + .gpio_slow_sel (gpio_slow_sel[35]), + .gpio_holdover (gpio_holdover[35]) + ); + + // pad 36: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_36 ( + .io_out (gpio_loopback_zero[36]), + .io_in (), + .io_oeb (gpio_loopback_one[36]), + .gpio_zero (gpio_loopback_zero[36]), + .gpio_one (gpio_loopback_one[36]), + .gpio_in (gpio_in[36]), + .gpio_dm ({gpio_dm2[36], gpio_dm1[36], gpio_dm0[36]}), + .gpio_inp_dis (gpio_inp_dis[36]), + .gpio_oeb_out (gpio_oeb[36]), + .gpio_out_val (gpio_out[36]), + .gpio_analog_en (gpio_analog_en[36]), + .gpio_analog_sel (gpio_analog_sel[36]), + .gpio_analog_pol (gpio_analog_pol[36]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[36]), + .gpio_vtrip_sel (gpio_vtrip_sel[36]), + .gpio_slow_sel (gpio_slow_sel[36]), + .gpio_holdover (gpio_holdover[36]) + ); + + // pad 37: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_37 ( + .io_out (gpio_loopback_zero[37]), + .io_in (), + .io_oeb (gpio_loopback_one[37]), + .gpio_zero (gpio_loopback_zero[37]), + .gpio_one (gpio_loopback_one[37]), + .gpio_in (gpio_in[37]), + .gpio_dm ({gpio_dm2[37], gpio_dm1[37], gpio_dm0[37]}), + .gpio_inp_dis (gpio_inp_dis[37]), + .gpio_oeb_out (gpio_oeb[37]), + .gpio_out_val (gpio_out[37]), + .gpio_analog_en (gpio_analog_en[37]), + .gpio_analog_sel (gpio_analog_sel[37]), + .gpio_analog_pol (gpio_analog_pol[37]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[37]), + .gpio_vtrip_sel (gpio_vtrip_sel[37]), + .gpio_slow_sel (gpio_slow_sel[37]), + .gpio_holdover (gpio_holdover[37]) + ); + + // pad 38: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_38 ( + .io_out (gpio_loopback_zero[38]), + .io_in (), + .io_oeb (gpio_loopback_one[38]), + .gpio_zero (gpio_loopback_zero[38]), + .gpio_one (gpio_loopback_one[38]), + .gpio_in (gpio_in[38]), + .gpio_dm ({gpio_dm2[38], gpio_dm1[38], gpio_dm0[38]}), + .gpio_inp_dis (gpio_inp_dis[38]), + .gpio_oeb_out (gpio_oeb[38]), + .gpio_out_val (gpio_out[38]), + .gpio_analog_en (gpio_analog_en[38]), + .gpio_analog_sel (gpio_analog_sel[38]), + .gpio_analog_pol (gpio_analog_pol[38]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[38]), + .gpio_vtrip_sel (gpio_vtrip_sel[38]), + .gpio_slow_sel (gpio_slow_sel[38]), + .gpio_holdover (gpio_holdover[38]) + ); + + // pad 39: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_39 ( + .io_out (gpio_loopback_zero[39]), + .io_in (), + .io_oeb (gpio_loopback_one[39]), + .gpio_zero (gpio_loopback_zero[39]), + .gpio_one (gpio_loopback_one[39]), + .gpio_in (gpio_in[39]), + .gpio_dm ({gpio_dm2[39], gpio_dm1[39], gpio_dm0[39]}), + .gpio_inp_dis (gpio_inp_dis[39]), + .gpio_oeb_out (gpio_oeb[39]), + .gpio_out_val (gpio_out[39]), + .gpio_analog_en (gpio_analog_en[39]), + .gpio_analog_sel (gpio_analog_sel[39]), + .gpio_analog_pol (gpio_analog_pol[39]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[39]), + .gpio_vtrip_sel (gpio_vtrip_sel[39]), + .gpio_slow_sel (gpio_slow_sel[39]), + .gpio_holdover (gpio_holdover[39]) + ); + + // pad 40: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_40 ( + .io_out (gpio_loopback_zero[40]), + .io_in (), + .io_oeb (gpio_loopback_one[40]), + .gpio_zero (gpio_loopback_zero[40]), + .gpio_one (gpio_loopback_one[40]), + .gpio_in (gpio_in[40]), + .gpio_dm ({gpio_dm2[40], gpio_dm1[40], gpio_dm0[40]}), + .gpio_inp_dis (gpio_inp_dis[40]), + .gpio_oeb_out (gpio_oeb[40]), + .gpio_out_val (gpio_out[40]), + .gpio_analog_en (gpio_analog_en[40]), + .gpio_analog_sel (gpio_analog_sel[40]), + .gpio_analog_pol (gpio_analog_pol[40]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[40]), + .gpio_vtrip_sel (gpio_vtrip_sel[40]), + .gpio_slow_sel (gpio_slow_sel[40]), + .gpio_holdover (gpio_holdover[40]) + ); + + // pad 41: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_41 ( + .io_out (gpio_loopback_zero[41]), + .io_in (), + .io_oeb (gpio_loopback_one[41]), + .gpio_zero (gpio_loopback_zero[41]), + .gpio_one (gpio_loopback_one[41]), + .gpio_in (gpio_in[41]), + .gpio_dm ({gpio_dm2[41], gpio_dm1[41], gpio_dm0[41]}), + .gpio_inp_dis (gpio_inp_dis[41]), + .gpio_oeb_out (gpio_oeb[41]), + .gpio_out_val (gpio_out[41]), + .gpio_analog_en (gpio_analog_en[41]), + .gpio_analog_sel (gpio_analog_sel[41]), + .gpio_analog_pol (gpio_analog_pol[41]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[41]), + .gpio_vtrip_sel (gpio_vtrip_sel[41]), + .gpio_slow_sel (gpio_slow_sel[41]), + .gpio_holdover (gpio_holdover[41]) + ); + + // pad 42: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_42 ( + .io_out (gpio_loopback_zero[42]), + .io_in (), + .io_oeb (gpio_loopback_one[42]), + .gpio_zero (gpio_loopback_zero[42]), + .gpio_one (gpio_loopback_one[42]), + .gpio_in (gpio_in[42]), + .gpio_dm ({gpio_dm2[42], gpio_dm1[42], gpio_dm0[42]}), + .gpio_inp_dis (gpio_inp_dis[42]), + .gpio_oeb_out (gpio_oeb[42]), + .gpio_out_val (gpio_out[42]), + .gpio_analog_en (gpio_analog_en[42]), + .gpio_analog_sel (gpio_analog_sel[42]), + .gpio_analog_pol (gpio_analog_pol[42]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[42]), + .gpio_vtrip_sel (gpio_vtrip_sel[42]), + .gpio_slow_sel (gpio_slow_sel[42]), + .gpio_holdover (gpio_holdover[42]) + ); + + // pad 43: analog (unused) + CF_gpio_config #( + .MODE(3'd0) + ) gpio_cfg_43 ( + .io_out (gpio_loopback_zero[43]), + .io_in (), + .io_oeb (gpio_loopback_one[43]), + .gpio_zero (gpio_loopback_zero[43]), + .gpio_one (gpio_loopback_one[43]), + .gpio_in (gpio_in[43]), + .gpio_dm ({gpio_dm2[43], gpio_dm1[43], gpio_dm0[43]}), + .gpio_inp_dis (gpio_inp_dis[43]), + .gpio_oeb_out (gpio_oeb[43]), + .gpio_out_val (gpio_out[43]), + .gpio_analog_en (gpio_analog_en[43]), + .gpio_analog_sel (gpio_analog_sel[43]), + .gpio_analog_pol (gpio_analog_pol[43]), + .gpio_ib_mode_sel (gpio_ib_mode_sel[43]), + .gpio_vtrip_sel (gpio_vtrip_sel[43]), + .gpio_slow_sel (gpio_slow_sel[43]), + .gpio_holdover (gpio_holdover[43]) + ); + +endmodule // openframe_gpio +/* verilator lint_on PINCONNECTEMPTY */ + +`default_nettype wire diff --git a/tests/data/openframe_timer.json b/tests/data/openframe_timer.json new file mode 100644 index 0000000..7c4afb4 --- /dev/null +++ b/tests/data/openframe_timer.json @@ -0,0 +1,97 @@ +{ + "schema_version": 1, + "gpio": { + "unused_mode": "analog", + "pads": [ + { + "pad": 0, + "mode": "input", + "signal": "clk" + }, + { + "pad": 1, + "mode": "input_pu", + "signal": "rst" + }, + { + "pad": 2, + "mode": "output", + "signal": "out", + "bit": 0 + }, + { + "pad": 3, + "mode": "output", + "signal": "out", + "bit": 1 + }, + { + "pad": 4, + "mode": "output", + "signal": "out", + "bit": 2 + }, + { + "pad": 5, + "mode": "output", + "signal": "out", + "bit": 3 + }, + { + "pad": 6, + "mode": "output", + "signal": "out", + "bit": 4 + }, + { + "pad": 7, + "mode": "output", + "signal": "out", + "bit": 5 + }, + { + "pad": 8, + "mode": "output", + "signal": "out", + "bit": 6 + }, + { + "pad": 9, + "mode": "output", + "signal": "out", + "bit": 7 + }, + { + "pad": 10, + "mode": "output", + "signal": "out", + "bit": 8 + }, + { + "pad": 11, + "mode": "output", + "signal": "out", + "bit": 9 + }, + { + "pad": 12, + "mode": "output", + "signal": "out", + "bit": 10 + } + ] + }, + "power": { + "domains": [ + "vccd1" + ], + "macros": [ + { + "instance": "mprj", + "domain": "vccd1", + "vdd_pin": "vccd1", + "gnd_pin": "vssd1" + } + ] + } +} diff --git a/tests/test_harden_command.py b/tests/test_harden_command.py index 2f616d8..ec87fd9 100644 --- a/tests/test_harden_command.py +++ b/tests/test_harden_command.py @@ -3,7 +3,7 @@ """ import pytest from click.testing import CliRunner -from chipfoundry_cli.main import main +from chipfoundry_cli.main import STEP_LIST_MARKER, main, parse_step_list_output from pathlib import Path import tempfile import shutil @@ -87,3 +87,14 @@ def test_harden_with_all_options(self, temp_project_dir): if __name__ == '__main__': pytest.main([__file__, '-v']) + + +class TestStepListProbe: + def test_ignores_terminal_escapes(self): + # LibreLane 3 restores the cursor on stdout at exit when attached to a terminal. + out = STEP_LIST_MARKER + '{"steps": ["Verilator.Lint", "Yosys.Synthesis"]}\n\x1b[?25h' + assert parse_step_list_output(out) == ["Verilator.Lint", "Yosys.Synthesis"] + + def test_missing_marker_is_an_error(self): + with pytest.raises(ValueError, match="step list not found"): + parse_step_list_output('{"steps": []}') diff --git a/tests/test_openframe.py b/tests/test_openframe.py new file mode 100644 index 0000000..4e5af52 --- /dev/null +++ b/tests/test_openframe.py @@ -0,0 +1,575 @@ +"""Tests for the openframe spec validator, generators, CLI commands and TUI.""" + +import asyncio +import copy +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from chipfoundry_cli.main import main +from chipfoundry_cli.openframe import SpecError, read_spec_file, render_outputs, validate_spec, write_spec_file +from chipfoundry_cli.openframe.gpio_gen import GPIO_RTL_REL, generate_gpio_rtl +from chipfoundry_cli.openframe.power_gen import POWER_JSON_REL, power_config + +DATA = Path(__file__).parent / "data" +TIMER_SPEC = json.loads((DATA / "openframe_timer.json").read_text()) +CF_GPIO_CONFIG_RTL = DATA / "CF_gpio_config.v" + + +def timer_spec(): + return copy.deepcopy(TIMER_SPEC) + + +def errors_of(data): + with pytest.raises(SpecError) as exc: + validate_spec(data) + return exc.value.errors + + +def assert_error(data, pattern): + errors = errors_of(data) + assert any(re.search(pattern, e) for e in errors), errors + + +# --------------------------------------------------------------------------- +# Validator +# --------------------------------------------------------------------------- + +class TestValidator: + def test_timer_spec_is_valid(self): + spec = validate_spec(timer_spec()) + assert [(s.name, s.direction, s.width) for s in spec.signals] == [ + ("clk", "in", None), + ("rst", "in", None), + ("out", "out", 11), + ] + assert spec.signals[2].pads == tuple(range(2, 13)) + assert len(spec.pads) == 44 + assert all(p.unused and p.mode == "analog" for p in spec.pads[13:]) + assert spec.domains == ("vccd1",) + assert spec.macros[0].gnd_net == "vssd1" + + def test_full_listing_without_unused_mode(self): + data = timer_spec() + del data["gpio"]["unused_mode"] + data["gpio"]["pads"] += [{"pad": n, "mode": "analog"} for n in range(13, 44)] + spec = validate_spec(data) + assert not any(p.unused for p in spec.pads) + + def test_missing_pads_without_unused_mode(self): + data = timer_spec() + del data["gpio"]["unused_mode"] + assert_error(data, r"pads 13-43 are not configured") + + def test_duplicate_pad(self): + data = timer_spec() + data["gpio"]["pads"].append({"pad": 0, "mode": "input", "signal": "other"}) + assert_error(data, r"pad 0 is listed more than once") + + def test_bus_gap(self): + data = timer_spec() + data["gpio"]["pads"] = [p for p in data["gpio"]["pads"] if p.get("bit") != 4] + assert_error(data, r"signal 'out\[10:0\]' has no pad for bit\(s\) 4") + + def test_duplicate_bit(self): + data = timer_spec() + data["gpio"]["pads"].append({"pad": 20, "mode": "output", "signal": "out", "bit": 3}) + assert_error(data, r"bit 3 is assigned to both pad 5 and pad 20") + + def test_input_mode_on_output_bus(self): + data = timer_spec() + data["gpio"]["pads"][5]["mode"] = "input" + assert_error(data, r"signal 'out' mixes directions") + + def test_bus_mixing_bit_and_scalar(self): + data = timer_spec() + data["gpio"]["pads"].append({"pad": 20, "mode": "output", "signal": "out"}) + assert_error(data, r"signal 'out': pads 20 need a 'bit'") + + def test_scalar_signal_on_two_pads(self): + data = timer_spec() + data["gpio"]["pads"].append({"pad": 20, "mode": "input", "signal": "clk"}) + assert_error(data, r"signal 'clk' is used by pads 0, 20 without bit indices") + + def test_output_requires_signal(self): + data = timer_spec() + data["gpio"]["pads"].append({"pad": 20, "mode": "output"}) + assert_error(data, r"pad 20: mode 'output' requires a signal") + + def test_analog_rejects_signal(self): + data = timer_spec() + data["gpio"]["pads"].append({"pad": 20, "mode": "analog", "signal": "vin"}) + assert_error(data, r"pad 20: analog pads cannot have a signal") + + def test_bit_without_signal(self): + data = timer_spec() + data["gpio"]["pads"].append({"pad": 20, "mode": "input", "bit": 0}) + assert_error(data, r"'bit' is set but 'signal' is missing") + + def test_input_without_signal_is_allowed(self): + data = timer_spec() + data["gpio"]["pads"].append({"pad": 20, "mode": "input_pd"}) + spec = validate_spec(data) + assert spec.pads[20].mode == "input_pd" and not spec.pads[20].unused + + @pytest.mark.parametrize("name,pattern", [ + ("gpio_in", "clashes with an openframe_project_wrapper port"), + ("analog_io", "clashes with an openframe_project_wrapper port"), + ("wire", "is a Verilog keyword"), + ]) + def test_reserved_signal_names(self, name, pattern): + data = timer_spec() + data["gpio"]["pads"].append({"pad": 20, "mode": "input", "signal": name}) + assert_error(data, pattern) + + def test_bidir_port_collision(self): + data = timer_spec() + data["gpio"]["pads"] += [ + {"pad": 20, "mode": "bidir", "signal": "sda"}, + {"pad": 21, "mode": "input", "signal": "sda_in"}, + ] + assert_error(data, r"signal 'sda' creates port 'sda_in', which is already used by signal 'sda_in'|" + r"signal 'sda_in' creates port 'sda_in', which is already used by signal 'sda'") + + def test_bidir_port_clashes_with_pad_port(self): + data = timer_spec() + data["gpio"]["pads"].append({"pad": 20, "mode": "bidir", "signal": "gpio"}) + assert_error(data, r"signal 'gpio' port 'gpio_in' clashes with an openframe_project_wrapper port") + + def test_macro_domain_not_enabled(self): + data = timer_spec() + data["power"]["macros"][0]["domain"] = "vccd2" + assert_error(data, r"macro 'mprj' uses domain 'vccd2', which is not in power.domains") + + def test_duplicate_macro(self): + data = timer_spec() + data["power"]["macros"].append(dict(data["power"]["macros"][0])) + assert_error(data, r"macro instance 'mprj' is listed more than once") + + @pytest.mark.parametrize("mutate,pattern", [ + (lambda d: d.update(schema_version=2), r"schema_version: 1 was expected"), + (lambda d: d["gpio"]["pads"][0].update(mode="inout"), r"gpio/pads/0/mode: 'inout' is not one of"), + (lambda d: d["gpio"]["pads"][0].update(pad=44), r"gpio/pads/0/pad: 44 is greater than the maximum"), + (lambda d: d["gpio"]["pads"][0].update(overrides={"slow": 2}), r"overrides/slow: 2 is not one of"), + (lambda d: d["gpio"]["pads"][0].update(overrides={"slow": True}), r"overrides/slow: True is not of type"), + (lambda d: d["gpio"]["pads"][0].update(overrides={"fast": 1}), r"Additional properties are not allowed"), + (lambda d: d["gpio"]["pads"][0].update(signal="1bad"), r"gpio/pads/0/signal: '1bad' does not match"), + (lambda d: d["gpio"].update(unused_mode="output"), r"gpio/unused_mode: 'output' is not one of"), + (lambda d: d.pop("power"), r"'power' is a required property"), + (lambda d: d["power"].update(domains=[]), r"power/domains: \[\] should be non-empty"), + (lambda d: d["power"].update(domains=["vccd1", "vccd1"]), r"has non-unique elements"), + (lambda d: d["power"].update(domains=["vddio"]), r"'vddio' is not one of"), + (lambda d: d["power"]["macros"][0].pop("vdd_pin"), r"'vdd_pin' is a required property"), + (lambda d: d.update(extra=1), r"Additional properties are not allowed \('extra'"), + ]) + def test_schema_errors(self, mutate, pattern): + data = timer_spec() + mutate(data) + assert_error(data, pattern) + + def test_errors_are_collected(self): + data = timer_spec() + data["gpio"]["pads"].append({"pad": 0, "mode": "input"}) + data["gpio"]["pads"].append({"pad": 20, "mode": "output"}) + data["power"]["macros"][0]["domain"] = "vdda1" + assert len(errors_of(data)) == 3 + + def test_normalized_and_hash_stable(self): + data = timer_spec() + shuffled = timer_spec() + shuffled["gpio"]["pads"].reverse() + assert validate_spec(data).raw == validate_spec(shuffled).raw + assert validate_spec(data).sha256 == validate_spec(shuffled).sha256 + data["gpio"]["pads"][0]["mode"] = "input_pd" + assert validate_spec(data).sha256 != validate_spec(shuffled).sha256 + + +# --------------------------------------------------------------------------- +# File I/O +# --------------------------------------------------------------------------- + +class TestSpecFiles: + @pytest.mark.parametrize("name", ["spec.json", "spec.yaml", "spec.yml"]) + def test_round_trip(self, tmp_path, name): + path = tmp_path / name + write_spec_file(path, timer_spec()) + assert validate_spec(read_spec_file(path)).raw == validate_spec(timer_spec()).raw + + def test_wrapped_block_is_accepted(self, tmp_path): + path = tmp_path / "spec.json" + path.write_text(json.dumps({"openframe": timer_spec()})) + validate_spec(read_spec_file(path)) + + def test_duplicate_json_key(self, tmp_path): + path = tmp_path / "spec.json" + path.write_text('{"schema_version": 1, "schema_version": 1}') + with pytest.raises(SpecError, match="duplicate key 'schema_version'"): + read_spec_file(path) + + def test_duplicate_yaml_key(self, tmp_path): + path = tmp_path / "spec.yaml" + path.write_text("schema_version: 1\nschema_version: 1\n") + with pytest.raises(SpecError, match="duplicate key 'schema_version'"): + read_spec_file(path) + + def test_unknown_extension(self, tmp_path): + path = tmp_path / "spec.txt" + path.write_text("{}") + with pytest.raises(SpecError, match="cannot infer format"): + read_spec_file(path) + assert read_spec_file(path, "json") == {} + + def test_parse_error(self, tmp_path): + path = tmp_path / "spec.json" + path.write_text("{not json") + with pytest.raises(SpecError, match="cannot parse json"): + read_spec_file(path) + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +def _instance_params(rtl): + """pad index -> parameter text of its CF_gpio_config instance.""" + found = re.findall(r"CF_gpio_config #\(\n(.*?)\n \) gpio_cfg_(\d\d) \(", rtl, re.S) + return {int(n): " ".join(p.split()) for p, n in found} + + +class TestGpioGenerator: + def test_timer_matches_golden(self): + rtl = generate_gpio_rtl(validate_spec(timer_spec())) + assert rtl == (DATA / "openframe_gpio_timer.v").read_text() + + def test_timer_pinout(self): + rtl = generate_gpio_rtl(validate_spec(timer_spec())) + params = _instance_params(rtl) + assert len(params) == 44 + assert params[0] == ".MODE(3'd1)" + assert params[1] == ".MODE(3'd3)" + assert all(params[n] == ".MODE(3'd4)" for n in range(2, 13)) + assert all(params[n] == ".MODE(3'd0)" for n in range(13, 44)) + assert ".io_in (clk)" in rtl + assert ".io_in (rst)" in rtl + for i in range(11): + assert f".io_out (out[{i}])," in rtl + assert "input wire [10:0] out," in rtl + assert "CF_gpio_config v1.2.0" not in rtl + + def test_overrides_and_bidir(self): + data = timer_spec() + data["gpio"]["unused_mode"] = "input_pd" + data["gpio"]["pads"] += [ + {"pad": 20, "mode": "bidir", "signal": "sda", "overrides": {"slow": 1, "vtrip": 1}}, + {"pad": 21, "mode": "bidir", "signal": "io", "bit": 1}, + {"pad": 22, "mode": "bidir", "signal": "io", "bit": 0, "overrides": {"holdover": 1}}, + ] + rtl = generate_gpio_rtl(validate_spec(data)) + params = _instance_params(rtl) + assert params[20] == ".MODE(3'd5), .SLOW(1'b1), .VTRIP(1'b1)" + assert params[22] == ".MODE(3'd5), .HOLDOVER(1'b1)" + assert params[30] == ".MODE(3'd2)" + assert "requires CF_gpio_config v1.2.0" in rtl + for port in ("output wire sda_in,", "input wire sda_out,", "input wire sda_oeb,", + "output wire [1:0] io_in,", "input wire [1:0] io_out,", "input wire [1:0] io_oeb,"): + assert port in rtl + assert ".io_oeb (io_oeb[0])," in rtl + + def test_no_signals(self): + data = {"schema_version": 1, "gpio": {"unused_mode": "analog", "pads": []}, + "power": {"domains": ["vccd1"], "macros": []}} + rtl = generate_gpio_rtl(validate_spec(data)) + assert "// User design ports" not in rtl + assert len(_instance_params(rtl)) == 44 + + +class TestPowerGenerator: + def test_timer_power(self): + cfg = power_config(validate_spec(timer_spec())) + assert cfg["VDD_NETS"] == ["vccd1"] + assert cfg["GND_NETS"] == ["vssd1"] + assert cfg["PDN_MACRO_CONNECTIONS"] == ["mprj vccd1 vssd1 vccd1 vssd1"] + + def test_multi_domain_keeps_order(self): + data = timer_spec() + data["power"] = { + "domains": ["vccd2", "vccd1", "vdda1"], + "macros": [ + {"instance": "mprj", "domain": "vccd1", "vdd_pin": "vccd1", "gnd_pin": "vssd1"}, + {"instance": "core2/u_ana", "domain": "vdda1", "vdd_pin": "VDD", "gnd_pin": "VSS"}, + ], + } + cfg = power_config(validate_spec(data)) + assert cfg["VDD_NETS"] == ["vccd2", "vccd1", "vdda1"] + assert cfg["GND_NETS"] == ["vssd2", "vssd1", "vssa1"] + assert cfg["PDN_MACRO_CONNECTIONS"] == ["mprj vccd1 vssd1 vccd1 vssd1", "core2/u_ana vdda1 vssa1 VDD VSS"] + + +LINT_SPECS = { + "timer": timer_spec(), + "mixed": { + "schema_version": 1, + "gpio": {"unused_mode": "input_pu", "pads": [ + {"pad": 0, "mode": "input", "signal": "clk"}, + {"pad": 5, "mode": "bidir", "signal": "sda", "overrides": {"slow": 1, "analog_en": 1}}, + {"pad": 6, "mode": "bidir", "signal": "gp", "bit": 0}, + {"pad": 7, "mode": "bidir", "signal": "gp", "bit": 1}, + {"pad": 8, "mode": "output", "signal": "led"}, + {"pad": 9, "mode": "input_pd"}, + {"pad": 10, "mode": "analog", "overrides": {"analog_sel": 1, "analog_pol": 1}}, + ]}, + "power": {"domains": ["vccd1", "vccd2"], "macros": []}, + }, +} + + +@pytest.mark.parametrize("name", sorted(LINT_SPECS)) +class TestGeneratedRtlLints: + def _write(self, tmp_path, name): + path = tmp_path / "openframe_gpio.v" + path.write_text(generate_gpio_rtl(validate_spec(LINT_SPECS[name]))) + return path + + @pytest.mark.skipif(shutil.which("verilator") is None, reason="verilator not installed") + def test_verilator(self, tmp_path, name): + rtl = self._write(tmp_path, name) + res = subprocess.run( + ["verilator", "--lint-only", "-Wall", "--top-module", "openframe_gpio", str(rtl), str(CF_GPIO_CONFIG_RTL)], + capture_output=True, text=True, + ) + assert res.returncode == 0, res.stderr + + @pytest.mark.skipif(shutil.which("iverilog") is None, reason="iverilog not installed") + def test_iverilog(self, tmp_path, name): + rtl = self._write(tmp_path, name) + res = subprocess.run( + ["iverilog", "-g2005", "-Wall", "-Wno-timescale", "-o", str(tmp_path / "a.out"), str(rtl), + str(CF_GPIO_CONFIG_RTL)], + capture_output=True, text=True, + ) + assert res.returncode == 0, res.stderr + assert "warning" not in res.stderr.lower(), res.stderr + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +@pytest.fixture +def openframe_project(tmp_path): + (tmp_path / ".cf").mkdir() + (tmp_path / "verilog" / "rtl").mkdir(parents=True) + (tmp_path / "openlane" / "openframe_project_wrapper").mkdir(parents=True) + project = {"project": {"name": "demo", "type": "openframe", "openframe": timer_spec()}} + (tmp_path / ".cf" / "project.json").write_text(json.dumps(project, indent=2)) + return tmp_path + + +class _Result: + def __init__(self, res): + self.exit_code = res.exit_code + self.output = " ".join(res.output.split()) # undo Rich line wrapping + + +def run(args, **kw): + return _Result(CliRunner().invoke(main, args, catch_exceptions=False, **kw)) + + +class TestCli: + def test_generate_and_check(self, openframe_project): + root = str(openframe_project) + res = run(["openframe", "generate", "--check", "--project-root", root]) + assert res.exit_code == 1 and "missing or out of date" in res.output + + res = run(["openframe", "generate", "--project-root", root]) + assert res.exit_code == 0, res.output + rtl = (openframe_project / GPIO_RTL_REL).read_text() + assert rtl == render_outputs(validate_spec(timer_spec()))[GPIO_RTL_REL] + power = json.loads((openframe_project / POWER_JSON_REL).read_text()) + assert power["PDN_MACRO_CONNECTIONS"] == ["mprj vccd1 vssd1 vccd1 vssd1"] + + assert run(["openframe", "generate", "--check", "--project-root", root]).exit_code == 0 + res = run(["openframe", "generate", "--project-root", root]) + assert "up to date" in res.output + + (openframe_project / POWER_JSON_REL).write_text("{}") + assert run(["openframe", "generate", "--check", "--project-root", root]).exit_code == 1 + + def test_generate_rejects_invalid_spec(self, openframe_project): + pj = openframe_project / ".cf" / "project.json" + data = json.loads(pj.read_text()) + data["project"]["openframe"]["gpio"]["pads"][3]["mode"] = "input" + pj.write_text(json.dumps(data)) + res = run(["openframe", "generate", "--project-root", str(openframe_project)]) + assert res.exit_code != 0 + assert "mixes directions" in res.output + assert not (openframe_project / GPIO_RTL_REL).exists() + + def test_generate_requires_template_layout(self, openframe_project): + shutil.rmtree(openframe_project / "openlane") + res = run(["openframe", "generate", "--project-root", str(openframe_project)]) + assert res.exit_code != 0 and "does not exist" in res.output + + def test_missing_block(self, openframe_project): + pj = openframe_project / ".cf" / "project.json" + pj.write_text(json.dumps({"project": {"type": "openframe"}})) + res = run(["openframe", "validate", "--project-root", str(openframe_project)]) + assert res.exit_code != 0 and "no project.openframe block" in res.output + + def test_rejects_caravel_project(self, openframe_project): + pj = openframe_project / ".cf" / "project.json" + pj.write_text(json.dumps({"project": {"type": "digital"}})) + res = run(["openframe", "generate", "--project-root", str(openframe_project)]) + assert res.exit_code != 0 and "for openframe projects" in res.output + + def test_export_import(self, openframe_project, tmp_path_factory): + root = str(openframe_project) + out = tmp_path_factory.mktemp("x") / "spec.yaml" + assert run(["openframe", "export", str(out), "--project-root", root]).exit_code == 0 + assert validate_spec(read_spec_file(out)).raw == validate_spec(timer_spec()).raw + + # Re-importing the identical spec needs no --force. + res = run(["openframe", "import", str(out), "--project-root", root, "--no-generate"]) + assert res.exit_code == 0, res.output + + edited = read_spec_file(out) + edited["gpio"]["pads"][0]["mode"] = "input_pd" + write_spec_file(out, edited) + res = run(["openframe", "import", str(out), "--project-root", root]) + assert res.exit_code != 0 and "--force" in res.output + + res = run(["openframe", "import", str(out), "--project-root", root, "--force"]) + assert res.exit_code == 0, res.output + stored = json.loads((openframe_project / ".cf" / "project.json").read_text())["project"] + assert stored["openframe"]["gpio"]["pads"][0]["mode"] == "input_pd" + assert stored["name"] == "demo" + assert (openframe_project / GPIO_RTL_REL).exists() + + def test_import_invalid_file(self, openframe_project, tmp_path_factory): + bad = tmp_path_factory.mktemp("x") / "bad.json" + data = timer_spec() + data["gpio"]["pads"].append({"pad": 2, "mode": "output", "signal": "x"}) + bad.write_text(json.dumps(data)) + res = run(["openframe", "import", str(bad), "--project-root", str(openframe_project)]) + assert res.exit_code != 0 and "pad 2 is listed more than once" in res.output + + def test_gpio_config_view_openframe(self, openframe_project): + res = run(["gpio-config", "--view", "--project-root", str(openframe_project)]) + assert res.exit_code == 0, res.output + assert "out[10:0]" in res.output and "13-43" in res.output + + def test_save_keeps_caravel_gpio_config(self, openframe_project): + from chipfoundry_cli.utils import get_openframe_spec_from_project_json, save_openframe_spec_to_project_json + pj = openframe_project / ".cf" / "project.json" + data = json.loads(pj.read_text()) + data["project"]["gpio_config"] = {"5": "13'h1808"} + pj.write_text(json.dumps(data)) + save_openframe_spec_to_project_json(str(pj), {"schema_version": 1}) + stored = json.loads(pj.read_text())["project"] + assert stored["gpio_config"] == {"5": "13'h1808"} + assert get_openframe_spec_from_project_json(str(pj)) == {"schema_version": 1} + + def test_init_seeds_from_template(self, tmp_path, monkeypatch): + (tmp_path / ".cf").mkdir() + (tmp_path / ".cf" / "openframe_default.json").write_text(json.dumps(timer_spec())) + monkeypatch.setattr("chipfoundry_cli.main.load_user_config", lambda: {}) + answers = {"Project name": "demo", "Project type (digital/analog/openframe)": "openframe"} + monkeypatch.setattr("chipfoundry_cli.main._prompt_with_default", + lambda label, current, detected=None: answers.get(label)) + res = run(["init", "--project-root", str(tmp_path), "--description", ""]) + assert res.exit_code == 0, res.output + stored = json.loads((tmp_path / ".cf" / "project.json").read_text())["project"] + assert stored["openframe"] == validate_spec(timer_spec()).raw + + def test_init_rejects_invalid_seed(self, tmp_path, monkeypatch): + (tmp_path / ".cf").mkdir() + bad = timer_spec() + del bad["power"] + (tmp_path / ".cf" / "openframe_default.json").write_text(json.dumps(bad)) + monkeypatch.setattr("chipfoundry_cli.main.load_user_config", lambda: {}) + answers = {"Project name": "demo", "Project type (digital/analog/openframe)": "openframe"} + monkeypatch.setattr("chipfoundry_cli.main._prompt_with_default", + lambda label, current, detected=None: answers.get(label)) + res = run(["init", "--project-root", str(tmp_path), "--description", ""]) + assert res.exit_code != 0 and "'power' is a required property" in res.output + + +# --------------------------------------------------------------------------- +# TUI +# --------------------------------------------------------------------------- + +class TestTui: + def test_edit_pad_and_save(self): + from chipfoundry_cli.openframe.tui import OpenframeGridApp + + async def scenario(): + app = OpenframeGridApp(timer_spec()) + async with app.run_test(size=(200, 60)) as pilot: + app.current = 13 + await pilot.press("enter") + await pilot.pause() + screen = app.screen + screen.query_one("#mode_output").value = True + signal = screen.query_one("#signal") + signal.focus() + await pilot.press(*"led_d") # 'd' must go to the Input, not trigger save + await pilot.pause() + assert app.is_running + screen.query_one("#ov_slow").value = True + await pilot.click("#apply") + await pilot.pause() + await pilot.press("d") + await pilot.pause() + return app.return_value + + result = asyncio.run(scenario()) + assert result is not None + spec = validate_spec(result) + assert spec.pads[13].mode == "output" + assert spec.pads[13].signal == "led_d" + assert spec.pads[13].overrides == {"slow": 1} + + def test_save_blocked_when_invalid(self): + from chipfoundry_cli.openframe.tui import OpenframeGridApp + + async def scenario(): + app = OpenframeGridApp(None) + async with app.run_test(size=(200, 60)) as pilot: + await pilot.press("d") + await pilot.pause() + status = str(app.query_one("#status").renderable) + running = app.is_running + await pilot.press("q") + return status, running, app.return_value + + status, running, result = asyncio.run(scenario()) + assert running and "Cannot save" in status + assert result is None + + def test_multi_select_assigns_consecutive_bits(self): + from chipfoundry_cli.openframe.tui import OpenframeGridApp + + async def scenario(): + app = OpenframeGridApp(timer_spec()) + async with app.run_test(size=(200, 60)) as pilot: + for pad in (20, 21, 22): + app.toggle_pad(pad) + await pilot.press("enter") + await pilot.pause() + screen = app.screen + screen.query_one("#mode_input_pu").value = True + screen.query_one("#signal").value = "btn" + await pilot.click("#apply") + await pilot.pause() + await pilot.press("d") + await pilot.pause() + return app.return_value + + spec = validate_spec(asyncio.run(scenario())) + btn = next(s for s in spec.signals if s.name == "btn") + assert btn.width == 3 and btn.pads == (20, 21, 22) diff --git a/tests/test_openframe_integration.py b/tests/test_openframe_integration.py new file mode 100644 index 0000000..cd2d8a9 --- /dev/null +++ b/tests/test_openframe_integration.py @@ -0,0 +1,219 @@ +"""cf harden / cf precheck integration with the openframe spec: power.json ordering and sync warnings.""" + +import json +import subprocess +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from chipfoundry_cli.librelane_run import build_librelane_command, librelane_config_files +from chipfoundry_cli.main import STEP_LIST_MARKER, main +from chipfoundry_cli.openframe import render_outputs, validate_spec +from chipfoundry_cli.openframe.cli import openframe_sync_problems +from chipfoundry_cli.openframe.gpio_gen import GPIO_RTL_REL +from chipfoundry_cli.openframe.power_gen import POWER_JSON_REL + +DATA = Path(__file__).parent / "data" +TIMER_SPEC = json.loads((DATA / "openframe_timer.json").read_text()) +WARNING_TITLE = "Openframe spec and generated files differ" + + +def flat(text): + """Undo Rich wrapping and panel borders.""" + return " ".join(text.replace("│", " ").split()) + + +@pytest.fixture +def project(tmp_path): + """Minimal openframe project with a wrapper and a timer macro, LibreLane venv and PDK stubs.""" + (tmp_path / ".cf").mkdir() + (tmp_path / ".cf" / "project.json").write_text( + json.dumps({"project": {"name": "demo", "type": "openframe", "openframe": TIMER_SPEC}}) + ) + (tmp_path / "verilog" / "rtl").mkdir(parents=True) + wrapper = tmp_path / "openlane" / "openframe_project_wrapper" + wrapper.mkdir(parents=True) + (wrapper / "config.json").write_text(json.dumps({"DESIGN_NAME": "openframe_project_wrapper"})) + timer = tmp_path / "openlane" / "user_proj_timer" + timer.mkdir() + (timer / "config.json").write_text(json.dumps({"DESIGN_NAME": "user_proj_timer"})) + (tmp_path / "openlane" / ".venv" / "bin").mkdir(parents=True) + (tmp_path / "dependencies" / "pdks" / "sky130A").mkdir(parents=True) + return tmp_path + + +def generate(root): + for rel, content in render_outputs(validate_spec(TIMER_SPEC)).items(): + (root / rel).write_text(content) + + +class FakeProcess: + def __init__(self, cmd, **kwargs): + FakeProcess.cmd = cmd + FakeProcess.cwd = kwargs.get("cwd") + self.pid = 0 + + def wait(self, timeout=None): + return 0 + + +@pytest.fixture +def fake_librelane(monkeypatch): + """Stub everything cf harden shells out to; the LibreLane command lands in FakeProcess.cmd.""" + FakeProcess.cmd = None + real_run = subprocess.run + + def fake_run(cmd, *args, **kwargs): + if cmd[:2] == ["docker", "info"] or cmd[:3] == ["nix", "flake", "metadata"]: + return subprocess.CompletedProcess(cmd, 0, b"", b"") + if len(cmd) > 1 and cmd[1] == "-c": # step list query through the venv python + out = STEP_LIST_MARKER + json.dumps({"steps": ["Yosys.Synthesis"]}) + "\n\x1b[?25h" + return subprocess.CompletedProcess(cmd, 0, out, "") + return real_run(cmd, *args, **kwargs) + + monkeypatch.setattr("chipfoundry_cli.main.subprocess.run", fake_run) + monkeypatch.setattr("chipfoundry_cli.main.subprocess.Popen", FakeProcess) + monkeypatch.setattr( + "chipfoundry_cli.main.fetch_versions_from_upstream", lambda *a, **k: {"openlane_version": "CI2609"} + ) + real_which = __import__("shutil").which + monkeypatch.setattr( + "chipfoundry_cli.main.shutil.which", lambda name: "/usr/bin/nix" if name == "nix" else real_which(name) + ) + return FakeProcess + + +def harden(root, *args): + return CliRunner().invoke(main, ["harden", *args, "--project-root", str(root)], catch_exceptions=False) + + +class TestConfigFiles: + def test_power_json_goes_first(self, project): + wrapper = project / "openlane" / "openframe_project_wrapper" + assert librelane_config_files(wrapper / "config.json") == [wrapper / "config.json"] + generate(project) + assert librelane_config_files(wrapper / "config.json") == [wrapper / "power.json", wrapper / "config.json"] + + def test_power_json_is_per_macro(self, project): + generate(project) + timer = project / "openlane" / "user_proj_timer" / "config.json" + assert librelane_config_files(timer) == [timer] + + @pytest.mark.parametrize("backend", ["nix", "docker"]) + def test_build_librelane_command(self, project, backend): + generate(project) + wrapper = project / "openlane" / "openframe_project_wrapper" + cmd, _ = build_librelane_command( + project_root=project, flow_root=project / "openlane", config_file=wrapper / "config.json", + pdk="sky130A", pdk_root=project / "dependencies" / "pdks", tag="t", openlane_version="CI2609", + backend=backend, + ) + assert cmd[-2:] == [str(wrapper / "power.json"), str(wrapper / "config.json")] + + +class TestHardenCommand: + @pytest.mark.parametrize("flag,first", [("--use-docker", "python3"), ("--use-nix", "nix")]) + def test_wrapper_passes_power_json_before_config(self, project, fake_librelane, flag, first): + generate(project) + res = harden(project, "openframe_project_wrapper", flag) + assert res.exit_code == 0, res.output + wrapper = project / "openlane" / "openframe_project_wrapper" + cmd = fake_librelane.cmd + assert Path(cmd[0]).name == first + assert cmd[-2:] == [str(wrapper / "power.json"), str(wrapper / "config.json")] + assert cmd.count(str(wrapper / "config.json")) == 1 + assert "power.json + config.json" in res.output + assert WARNING_TITLE not in res.output + + def test_macro_without_power_json(self, project, fake_librelane): + generate(project) + res = harden(project, "user_proj_timer", "--use-docker") + assert res.exit_code == 0, res.output + timer = project / "openlane" / "user_proj_timer" + assert fake_librelane.cmd[-1] == str(timer / "config.json") + assert str(timer / "power.json") not in fake_librelane.cmd + + def test_warns_when_generated_files_missing(self, project, fake_librelane): + res = harden(project, "openframe_project_wrapper", "--use-docker") + out = flat(res.output) + assert WARNING_TITLE in out + assert f"{GPIO_RTL_REL}: missing" in out and f"{POWER_JSON_REL}: missing" in out + assert "cf openframe generate" in out + assert "cf harden continues with the files currently on disk" in out + assert res.exit_code == 0 + + def test_timer_macro_does_not_warn(self, project, fake_librelane): + res = harden(project, "user_proj_timer", "--use-docker") + assert WARNING_TITLE not in flat(res.output) + + def test_list_does_not_warn(self, project): + res = harden(project, "--list") + assert WARNING_TITLE not in flat(res.output) + + +class TestSyncProblems: + def test_in_sync(self, project): + generate(project) + assert openframe_sync_problems(project) == [] + + def test_spec_changed_after_generate(self, project): + generate(project) + pj = project / ".cf" / "project.json" + data = json.loads(pj.read_text()) + data["project"]["openframe"]["gpio"]["pads"][0]["mode"] = "input_pd" + pj.write_text(json.dumps(data)) + assert openframe_sync_problems(project) == [ + f"{GPIO_RTL_REL}: generated from a different spec", + f"{POWER_JSON_REL}: generated from a different spec", + ] + + def test_hand_edited(self, project): + generate(project) + rtl = project / GPIO_RTL_REL + rtl.write_text(rtl.read_text().replace(".MODE(3'd3)", ".MODE(3'd2)")) + assert openframe_sync_problems(project) == [f"{GPIO_RTL_REL}: edited by hand since it was generated"] + + def test_foreign_file(self, project): + generate(project) + (project / POWER_JSON_REL).write_text(json.dumps({"VDD_NETS": ["vccd1"]})) + assert openframe_sync_problems(project) == [ + f"{POWER_JSON_REL}: not generated by cf openframe generate (no spec-sha256 header)" + ] + + def test_invalid_spec(self, project): + pj = project / ".cf" / "project.json" + data = json.loads(pj.read_text()) + data["project"]["openframe"]["gpio"]["pads"][3]["mode"] = "input" + pj.write_text(json.dumps(data)) + problems = openframe_sync_problems(project) + assert problems[0] == "project.openframe is invalid, so the generated files cannot be checked:" + assert any("mixes directions" in p for p in problems[1:]) + + def test_missing_block(self, project): + (project / ".cf" / "project.json").write_text(json.dumps({"project": {"type": "openframe"}})) + problems = openframe_sync_problems(project) + assert len(problems) == 1 and "has no project.openframe spec" in problems[0] + + def test_not_openframe(self, project): + (project / ".cf" / "project.json").write_text(json.dumps({"project": {"type": "digital"}})) + assert openframe_sync_problems(project) == [] + + +class TestPrecheckWarning: + def test_precheck_warns_when_stale(self, project): + generate(project) + pj = project / ".cf" / "project.json" + data = json.loads(pj.read_text()) + data["project"]["openframe"]["power"]["domains"] = ["vccd1", "vccd2"] + pj.write_text(json.dumps(data)) + res = CliRunner().invoke(main, ["precheck", "--project-root", str(project), "--dry-run"]) + out = flat(res.output) + assert WARNING_TITLE in out + assert f"{POWER_JSON_REL}: generated from a different spec" in out + assert "cf precheck continues" in out + + def test_precheck_quiet_when_in_sync(self, project): + generate(project) + res = CliRunner().invoke(main, ["precheck", "--project-root", str(project), "--dry-run"]) + assert WARNING_TITLE not in flat(res.output) diff --git a/versions.json b/versions.json index 40c86d9..b5c4641 100644 --- a/versions.json +++ b/versions.json @@ -3,7 +3,7 @@ "sky130A": "2026.2.9", "sky130B": "2026.2.9" }, - "openlane_version": "CI2511", + "openlane_version": "CI2609", "open_pdks_commits": { "sky130A": "3e0e31dcce8519a7dbb82590346db16d91b7244f", "sky130B": "3e0e31dcce8519a7dbb82590346db16d91b7244f"