From 0e8a6f7e9f197edb59473626fb07f023c268e30a Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Sat, 21 Mar 2026 22:23:08 +0100 Subject: [PATCH] Tooling V1 --- pyproject.toml | 4 +- src/neoecu/cli.py | 35 ++++++++++- src/neoecu/commands/build.py | 91 +++++++++++++++++++++++++++- src/neoecu/commands/clean.py | 98 ++++++++++++++++++++++++++++++ src/neoecu/commands/envcheck.py | 103 ++++++++++++++++++++++++++++++++ src/neoecu/commands/init.py | 91 ++++++++++++++++++++++++++++ src/neoecu/common.py | 33 ++++++++++ uv.lock | 57 ++++++++++++++++++ 8 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 src/neoecu/commands/clean.py create mode 100644 src/neoecu/commands/envcheck.py create mode 100644 src/neoecu/commands/init.py create mode 100644 src/neoecu/common.py create mode 100644 uv.lock diff --git a/pyproject.toml b/pyproject.toml index 2cb516f..05e7a2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,9 @@ description = "NeoECU build and flash tooling" authors = [{ name = "Hector van der Aa", email = "hector@h3cx.dev" }] readme = "README.md" requires-python = ">=3.12" -dependencies = [] +dependencies = [ + "rich>=14.3.3", +] [project.scripts] neoecu = "neoecu.cli:main" diff --git a/src/neoecu/cli.py b/src/neoecu/cli.py index 8bdaa99..abde4e6 100644 --- a/src/neoecu/cli.py +++ b/src/neoecu/cli.py @@ -3,22 +3,53 @@ # SPDX-License-Identifier: GPL-3.0-or-later import argparse +import sys +import neoecu.common def main(): parser = argparse.ArgumentParser(prog="neoecu") subparsers = parser.add_subparsers(dest="command") - subparsers.add_parser("build") + build_parser = subparsers.add_parser("build") subparsers.add_parser("flash") + subparsers.add_parser("envcheck") + subparsers.add_parser("init") + subparsers.add_parser("clean") + + build_parser.add_argument("--debug", action="store_true") + build_parser.add_argument("--release", action="store_true") + build_parser.add_argument("--clean", action="store_true") args = parser.parse_args() + project_root = neoecu.common.find_project_root() + + if project_root is None: + print("neoecu could not find a neoecu.toml file, perhaps you are not running the command from inside the project path") + sys.exit(1) + + config = neoecu.common.load_config(project_root) + + pair = neoecu.common.check_pair(config) + if not pair: + print("neoecu currently only support building ST for M7 and Zephyr for M4, update your neoecu.toml") + sys.exit(1) + if args.command == "build": from .commands.build import run - run() + run(project_root, args) elif args.command == "flash": from .commands.flash import run run() + elif args.command == "envcheck": + from .commands.envcheck import run + run() + elif args.command == "init": + from .commands.init import run + run(project_root) + elif args.command == "clean": + from .commands.clean import run + run(project_root) else: parser.print_help() diff --git a/src/neoecu/commands/build.py b/src/neoecu/commands/build.py index c583214..e4031de 100644 --- a/src/neoecu/commands/build.py +++ b/src/neoecu/commands/build.py @@ -1,5 +1,94 @@ # Copyright (C) 2026 Hector van der Aa # Copyright (C) 2026 Association Exergie # SPDX-License-Identifier: GPL-3.0-or-later -def run(): + +import sys +import subprocess +from pathlib import Path + +from rich.table import Table +from rich.console import Console + +import neoecu.common + + +def run_cmd(cmd: list[str], cwd: Path) -> bool: + result = subprocess.run(cmd, cwd=cwd) + return result.returncode == 0 + + +def init_m7(path: Path) -> bool: + debug_ok = run_cmd(["cmake", "--preset", "Debug"], path) + release_ok = run_cmd(["cmake", "--preset", "Release"], path) + return debug_ok and release_ok + + +def build_m7(path: Path, mode: str, clean: bool) -> bool: + cmd = ["cmake", "--build", "--preset", mode] + + if clean: + cmd.append("--clean-first") + + return run_cmd(cmd, path) + + +def build_m4(path: Path, clean: bool) -> bool: + cmd = ["west", "build", "-b", "arduino_giga_r1/stm32h747xx/m4"] + + if clean: + cmd.insert(2, "-p") + cmd.insert(3, "always") + + return run_cmd(cmd, path) + + +def run(root_path: Path, args): + console = Console() + print("Building NeoECU...") + + config = neoecu.common.load_config(root_path) + + m7_root = root_path / config["build"]["m7_dir"] + m4_root = root_path / config["build"]["m4_dir"] + + # Validate mode + if args.debug and args.release: + print("Cannot build Debug and Release at once") + sys.exit(1) + + mode = "Debug" if args.debug else "Release" + + # Ensure M7 initialized + m7_build_dir = m7_root / "build" + if not m7_build_dir.exists(): + if not init_m7(m7_root): + print("Failed to init STM32 project") + sys.exit(1) + + table = Table() + table.add_column("Step") + table.add_column("Status") + + ok_str = "[green]OK[/green]" + fail_str = "[red]FAILED[/red]" + + results: list[bool] = [] + + # M7 build + m7_ok = build_m7(m7_root, mode, args.clean) + table.add_row(f"M7 {mode}", ok_str if m7_ok else fail_str) + results.append(m7_ok) + + # M4 build + m4_ok = build_m4(m4_root, args.clean) + table.add_row("M4 build", ok_str if m4_ok else fail_str) + results.append(m4_ok) + + console.print(table) + + if all(results): + print("Build success") + else: + print("Build failed") + sys.exit(1) diff --git a/src/neoecu/commands/clean.py b/src/neoecu/commands/clean.py new file mode 100644 index 0000000..aee1f68 --- /dev/null +++ b/src/neoecu/commands/clean.py @@ -0,0 +1,98 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# SPDX-License-Identifier: GPL-3.0-or-later + +import shutil +import sys +from pathlib import Path + +from rich.table import Table +from rich.console import Console + +import neoecu.common + + +def safe_remove_dir(path: Path, root: Path, protected: list[Path]) -> bool: + """ + Safely remove a directory with strict protections: + - Must be inside project root + - Must not match protected paths + - Must contain "build" in its path + """ + + try: + resolved = path.resolve() + root_resolved = root.resolve() + + # Ensure inside project root + if not resolved.is_relative_to(root_resolved): + raise ValueError("outside project root") + + # Ensure not protected + for p in protected: + if resolved == p.resolve(): + raise ValueError("protected path") + + # Ensure this is actually a build directory + if "build" not in resolved.parts: + raise ValueError("not a build directory") + + # Remove if exists + if resolved.exists(): + shutil.rmtree(resolved) + return True + + return True # treat non-existent as success + + except Exception: + return False + + +def run(root_path: Path): + console = Console() + + config = neoecu.common.load_config(root_path) + + m7_root = root_path / config["build"]["m7_dir"] + m4_root = root_path / config["build"]["m4_dir"] + + # Build directories + m7_root_build_dir = m7_root / "build" + m7_cm7_build_dir = m7_root / "CM7" / "build" + m7_cm4_build_dir = m7_root / "CM4" / "build" + m4_root_build_dir = m4_root / "build" + + build_dirs = [ + ("M7 root build", m7_root_build_dir), + ("M7 CM7 build", m7_cm7_build_dir), + ("M7 CM4 build", m7_cm4_build_dir), + ("M4 root build", m4_root_build_dir), + ] + + protected = [ + root_path, + m7_root, + m4_root, + ] + + table = Table() + table.add_column("Directory") + table.add_column("Status") + + ok_str = "[green]OK[/green]" + fail_str = "[red]FAILED[/red]" + + results: list[bool] = [] + + for name, path in build_dirs: + result = safe_remove_dir(path, root_path, protected) + table.add_row(name, ok_str if result else fail_str) + results.append(result) + + console.print(table) + + if all(results): + print("Clean success") + else: + print("Clean completed with errors") + sys.exit(1) diff --git a/src/neoecu/commands/envcheck.py b/src/neoecu/commands/envcheck.py new file mode 100644 index 0000000..1d42638 --- /dev/null +++ b/src/neoecu/commands/envcheck.py @@ -0,0 +1,103 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# SPDX-License-Identifier: GPL-3.0-or-later + +import subprocess +from rich.table import Table +from rich.console import Console +import shutil +import os + +def which_check(tool: str) -> bool: + return shutil.which(tool) is not None + +def has_newlib() -> bool: + try: + result = subprocess.run( + ["arm-none-eabi-gcc", "-print-file-name=libc.a"], + capture_output=True, + text=True + ) + + path = result.stdout.strip() + + # If gcc returns just "libc.a", it means NOT found + return path != "libc.a" + + except FileNotFoundError: + return False + +def get_env(var: str) -> str | None: + return os.environ.get(var) + +def run(): + missing_str = "[red]MISSING[/red]" + ok_str = "[green]OK[/green]" + + console = Console() + print("Checking build tools") + + + build_tools_table = Table() + + build_tools_table.add_column("Tool") + build_tools_table.add_column("Status") + + tools = ["ninja", "cmake", "west", "STM32CubeMX", "JLinkExe"] + for tool in tools: + result = which_check(tool) + if result: + build_tools_table.add_row(tool, ok_str) + else: + build_tools_table.add_row(tool, missing_str) + + zephyr_base = get_env("ZEPHYR_BASE") + if zephyr_base is None: + build_tools_table.add_row("Zephyr Base", missing_str) + else: + build_tools_table.add_row("Zephyr Base", ok_str) + + zephyr_sdk = get_env("ZEPHYR_SDK_INSTALL_DIR") + if zephyr_sdk is None: + build_tools_table.add_row("Zephyr SDK", missing_str) + else: + build_tools_table.add_row("Zephyr SDK", ok_str) + + console.print(build_tools_table) + + + build_deps_table = Table() + + build_deps_table.add_column("Dependency") + build_deps_table.add_column("Status") + + deps = [ + # Compiler + "arm-none-eabi-gcc", + + # Binutils (core) + "arm-none-eabi-ld", # linker + "arm-none-eabi-as", # assembler + "arm-none-eabi-ar", # archive tool + "arm-none-eabi-nm", # symbol table + "arm-none-eabi-objcopy", # binary conversion + "arm-none-eabi-objdump", # disassembler + "arm-none-eabi-strip", # strip symbols + "arm-none-eabi-size", # size info + "arm-none-eabi-strings", # extract strings + "arm-none-eabi-readelf", # ELF inspection + ] + for dep in deps: + result = which_check(dep) + if result: + build_deps_table.add_row(dep, "[green]OK[/green]") + else: + build_deps_table.add_row(dep, "[red]MISSING[/red]") + + if has_newlib(): + build_deps_table.add_row("arm-none-eabi-newlib", "[green]OK[/green]") + else: + build_deps_table.add_row("arm-none-eabi-newlib", "[red]MISSING[/red]") + + console.print(build_deps_table) + diff --git a/src/neoecu/commands/init.py b/src/neoecu/commands/init.py new file mode 100644 index 0000000..d3564cc --- /dev/null +++ b/src/neoecu/commands/init.py @@ -0,0 +1,91 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# SPDX-License-Identifier: GPL-3.0-or-later + +import subprocess +import os +import sys +from pathlib import Path + +from rich.table import Table +from rich.console import Console + +from neoecu.commands import build +from neoecu.common import load_config + + +def get_env(var: str) -> str | None: + return os.environ.get(var) + + +def run_cmd(cmd: list[str], cwd: Path | None = None) -> bool: + result = subprocess.run(cmd, cwd=cwd) + return result.returncode == 0 + + +def run(root_path: Path): + console = Console() + + ok_str = "[green]OK[/green]" + fail_str = "[red]FAILED[/red]" + + print("Initializing project") + + table = Table() + table.add_column("Step") + table.add_column("Status") + + results: list[bool] = [] + + # Step 1 — Install west + west_ok = run_cmd(["uv", "pip", "install", "west"]) + table.add_row("Install west", ok_str if west_ok else fail_str) + results.append(west_ok) + + # Step 2 — Zephyr requirements + requirements = get_env("ZEPHYR_BASE") + if requirements is None: + table.add_row("Zephyr requirements", "[red]ZEPHYR_BASE not set[/red]") + console.print(table) + sys.exit(1) + + requirements_path = Path(requirements) / "scripts" / "requirements.txt" + + deps_ok = run_cmd(["uv", "pip", "install", "-r", str(requirements_path)]) + table.add_row("Install Zephyr deps", ok_str if deps_ok else fail_str) + results.append(deps_ok) + + # Step 3 — Load config + config = load_config(root_path) + m7_root = root_path / config["build"]["m7_dir"] + m4_root = root_path / config["build"]["m4_dir"] + + # Step 4 — CMake Debug Init + debug_ok = run_cmd(["cmake", "--preset", "Debug"], cwd=m7_root) + table.add_row("CMake Debug Init", ok_str if debug_ok else fail_str) + results.append(debug_ok) + + # Step 5 — CMake Release Init + release_ok = run_cmd(["cmake", "--preset", "Release"], cwd=m7_root) + table.add_row("CMake Release Init", ok_str if release_ok else fail_str) + results.append(release_ok) + + # Step 6 - CMake Debug Build + build_ok = run_cmd(["cmake", "--build", "--preset", "Debug", "--clean-first", "--verbose"], cwd=m7_root) + table.add_row("CMake Debug Build", ok_str if build_ok else fail_str) + results.append(build_ok) + + # Step 7 - Zephyr Debug + zephyr_ok = run_cmd(["west", "build","-p", "always", "-b", "arduino_giga_r1/stm32h747xx/m4"], cwd=m4_root) + table.add_row("Zephyr Build", ok_str if zephyr_ok else fail_str) + results.append(zephyr_ok) + + # Print results table + console.print(table) + + # Final result + if all(results): + print("Init success") + else: + print("Init failed") + sys.exit(1) diff --git a/src/neoecu/common.py b/src/neoecu/common.py new file mode 100644 index 0000000..03fe129 --- /dev/null +++ b/src/neoecu/common.py @@ -0,0 +1,33 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# SPDX-License-Identifier: GPL-3.0-or-later +from pathlib import Path +import tomllib + +def find_project_root( + start: Path | None = None, + filename: str = "neoecu.toml", + max_depth: int = 10 +) -> Path | None: + current = start or Path.cwd() + + for _ in range(max_depth + 1): + if (current / filename).exists(): + return current # return the directory (project root) + + if current.parent == current: + break + + current = current.parent + + return None + +def load_config(root: Path) -> dict: + config_path = root / "neoecu.toml" + with open(config_path, "rb") as f: + return tomllib.load(f) + +def check_pair(config: dict) -> bool: + if (config["build"]["m7_type"] == "ST") & (config["build"]["m4_type"] == "Zephyr"): + return True + return False diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ae4b64f --- /dev/null +++ b/uv.lock @@ -0,0 +1,57 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "neoecu" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "rich" }, +] + +[package.metadata] +requires-dist = [{ name = "rich", specifier = ">=14.3.3" }] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +]