Release v2.1.0: merge dev into main

This commit is contained in:
2026-05-03 18:32:06 +02:00
9 changed files with 282 additions and 119 deletions

101
README.md
View File

@@ -1,3 +1,104 @@
# NeoECU Tooling # NeoECU Tooling
Unified CLI tooling for building and flashing NeoECU firmware Unified CLI tooling for building and flashing NeoECU firmware
## Configuration
Run commands from anywhere inside a project that contains `neoecu.toml`. The tool walks up the directory tree until it finds that file.
`neoecu.toml` uses a `[build]` table. A core is considered configured only when both its directory and type are present:
```toml
[build]
m7_dir = "path/to/cm7/project"
m7_type = "ST"
m4_dir = "path/to/cm4/project"
m4_type = "Zephyr"
```
Supported core types are `ST` for CM7 and `Zephyr` for CM4. If either the CM7 or CM4 keys are missing or incomplete, that core is skipped and the tool runs as a single-core project for the core that is fully configured.
Examples:
```toml
[build]
m7_dir = "STM32"
m7_type = "ST"
```
```toml
[build]
m4_dir = "Zephyr"
m4_type = "Zephyr"
```
## Commands
### `neoecu build`
Builds the configured firmware cores. With both cores configured, this builds CM7 first with CMake and CM4 second with West/Zephyr. With only one fully configured core, only that core is built.
Options:
- `--debug`: build CM7 with the `Debug` CMake preset. If omitted, CM7 uses `Release`.
- `--release`: build CM7 with the `Release` CMake preset.
- `--clean`: clean before building. CM7 uses CMake `--clean-first`; CM4 uses West pristine rebuild.
- `--CM7`: build only the CM7 core.
- `--CM4`: build only the CM4 core.
Examples:
```sh
neoecu build
neoecu build --debug
neoecu build --release --clean
neoecu build --CM7
neoecu build --CM4 --clean
```
### `neoecu flash`
Flashes the configured firmware cores with J-Link. With both cores configured, CM4 is flashed first and CM7 second. With only one fully configured core, only that core is flashed.
Options:
- `--CM7`: flash only the CM7 core.
- `--CM4`: flash only the CM4 core.
Examples:
```sh
neoecu flash
neoecu flash --CM7
neoecu flash --CM4
```
### `neoecu init`
Initializes the configured project cores. For CM7, it runs the CMake `Debug` and `Release` presets and performs an initial debug build. For CM4, it installs West and Zephyr Python requirements, then runs an initial West build.
Example:
```sh
neoecu init
```
### `neoecu clean`
Removes generated build directories for the configured cores while protecting the project root and configured source directories.
Example:
```sh
neoecu clean
```
### `neoecu envcheck`
Checks whether expected build tools, Zephyr environment variables, and Arm toolchain dependencies are available.
Example:
```sh
neoecu envcheck
```

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "neoecu" name = "neoecu"
version = "2.0.0" version = "2.1.0"
description = "NeoECU build and flash tooling" description = "NeoECU build and flash tooling"
authors = [{ name = "Hector van der Aa", email = "hector@h3cx.dev" }] authors = [{ name = "Hector van der Aa", email = "hector@h3cx.dev" }]
readme = "README.md" readme = "README.md"

View File

@@ -12,7 +12,7 @@ def main():
subparsers = parser.add_subparsers(dest="command") subparsers = parser.add_subparsers(dest="command")
build_parser = subparsers.add_parser("build") build_parser = subparsers.add_parser("build")
subparsers.add_parser("flash") flash_parser = subparsers.add_parser("flash")
subparsers.add_parser("envcheck") subparsers.add_parser("envcheck")
subparsers.add_parser("init") subparsers.add_parser("init")
subparsers.add_parser("clean") subparsers.add_parser("clean")
@@ -20,6 +20,10 @@ def main():
build_parser.add_argument("--debug", action="store_true") build_parser.add_argument("--debug", action="store_true")
build_parser.add_argument("--release", action="store_true") build_parser.add_argument("--release", action="store_true")
build_parser.add_argument("--clean", action="store_true") build_parser.add_argument("--clean", action="store_true")
build_parser.add_argument("--CM7", action="store_true", help="Build only the CM7 core")
build_parser.add_argument("--CM4", action="store_true", help="Build only the CM4 core")
flash_parser.add_argument("--CM7", action="store_true", help="Flash only the CM7 core")
flash_parser.add_argument("--CM4", action="store_true", help="Flash only the CM4 core")
args = parser.parse_args() args = parser.parse_args()
@@ -33,7 +37,7 @@ def main():
pair = neoecu.common.check_pair(config) pair = neoecu.common.check_pair(config)
if not pair: if not pair:
print("neoecu currently only support building ST for M7 and Zephyr for M4, update your neoecu.toml") print("neoecu supports ST for CM7 and Zephyr for CM4. Configure at least one supported core in neoecu.toml")
sys.exit(1) sys.exit(1)
if args.command == "build": if args.command == "build":
@@ -41,7 +45,7 @@ def main():
run(project_root, args) run(project_root, args)
elif args.command == "flash": elif args.command == "flash":
from .commands.flash import run from .commands.flash import run
run(project_root) run(project_root, args)
elif args.command == "envcheck": elif args.command == "envcheck":
from .commands.envcheck import run from .commands.envcheck import run
run() run()

View File

@@ -48,9 +48,11 @@ def run(root_path: Path, args):
print("Building NeoECU...") print("Building NeoECU...")
config = neoecu.common.load_config(root_path) config = neoecu.common.load_config(root_path)
try:
m7_root = root_path / config["build"]["m7_dir"] cores = neoecu.common.select_cores(config, args)
m4_root = root_path / config["build"]["m4_dir"] except ValueError as exc:
print(exc)
sys.exit(1)
# Validate mode # Validate mode
if args.debug and args.release: if args.debug and args.release:
@@ -59,12 +61,13 @@ def run(root_path: Path, args):
mode = "Debug" if args.debug else "Release" mode = "Debug" if args.debug else "Release"
# Ensure M7 initialized if "CM7" in cores:
m7_build_dir = m7_root / "build" m7_root = neoecu.common.core_root(root_path, config, "CM7")
if not m7_build_dir.exists(): m7_build_dir = m7_root / "build"
if not init_m7(m7_root): if not m7_build_dir.exists():
print("Failed to init STM32 project") if not init_m7(m7_root):
sys.exit(1) print("Failed to init STM32 project")
sys.exit(1)
table = Table() table = Table()
table.add_column("Step") table.add_column("Step")
@@ -75,15 +78,17 @@ def run(root_path: Path, args):
results: list[bool] = [] results: list[bool] = []
# M7 build if "CM7" in cores:
m7_ok = build_m7(m7_root, mode, args.clean) m7_root = neoecu.common.core_root(root_path, config, "CM7")
table.add_row(f"M7 {mode}", ok_str if m7_ok else fail_str) m7_ok = build_m7(m7_root, mode, args.clean)
results.append(m7_ok) table.add_row(f"CM7 {mode}", ok_str if m7_ok else fail_str)
results.append(m7_ok)
# M4 build if "CM4" in cores:
m4_ok = build_m4(m4_root, args.clean) m4_root = neoecu.common.core_root(root_path, config, "CM4")
table.add_row("M4 build", ok_str if m4_ok else fail_str) m4_ok = build_m4(m4_root, args.clean)
results.append(m4_ok) table.add_row("CM4 build", ok_str if m4_ok else fail_str)
results.append(m4_ok)
console.print(table) console.print(table)

View File

@@ -52,28 +52,26 @@ def run(root_path: Path):
console = Console() console = Console()
config = neoecu.common.load_config(root_path) config = neoecu.common.load_config(root_path)
cores = neoecu.common.configured_cores(config)
m7_root = root_path / config["build"]["m7_dir"]
m4_root = root_path / config["build"]["m4_dir"]
# Build directories # Build directories
m7_root_build_dir = m7_root / "build" build_dirs = []
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 = [ protected = [root_path]
("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 = [ if "CM7" in cores:
root_path, m7_root = neoecu.common.core_root(root_path, config, "CM7")
m7_root, protected.append(m7_root)
m4_root, build_dirs.extend([
] ("M7 root build", m7_root / "build"),
("M7 CM7 build", m7_root / "CM7" / "build"),
("M7 CM4 build", m7_root / "CM4" / "build"),
])
if "CM4" in cores:
m4_root = neoecu.common.core_root(root_path, config, "CM4")
protected.append(m4_root)
build_dirs.append(("M4 root build", m4_root / "build"))
table = Table() table = Table()
table.add_column("Directory") table.add_column("Directory")

View File

@@ -5,6 +5,8 @@
from pathlib import Path from pathlib import Path
import neoecu.common import neoecu.common
import subprocess import subprocess
import sys
def gen_flash_script(jlink_path: Path, core: str, bin_path: Path) -> Path: def gen_flash_script(jlink_path: Path, core: str, bin_path: Path) -> Path:
jlink_path.mkdir(parents=True, exist_ok=True) jlink_path.mkdir(parents=True, exist_ok=True)
@@ -29,51 +31,42 @@ def gen_flash_script(jlink_path: Path, core: str, bin_path: Path) -> Path:
new_script += "g\n" new_script += "g\n"
new_script += "exit" new_script += "exit"
script_path.write_text(new_script); script_path.write_text(new_script)
return script_path return script_path
def find_single_elf(build_path: Path, pattern: str, core: str) -> Path:
matches = list(build_path.glob(pattern))
if not matches:
raise FileNotFoundError(f"No {pattern} found in {core} build directory")
if len(matches) > 1:
raise RuntimeError(f"Multiple {core} ELF files found: {matches}")
return matches[0]
def run(root_path: Path): def run(root_path: Path, args):
config = neoecu.common.load_config(root_path) config = neoecu.common.load_config(root_path)
try:
cores = neoecu.common.select_cores(config, args)
except ValueError as exc:
print(exc)
sys.exit(1)
m7_root = root_path / config["build"]["m7_dir"] if "CM4" in cores:
m4_root = root_path / config["build"]["m4_dir"] m4_root = neoecu.common.core_root(root_path, config, "CM4")
m4_jlink_scripts = m4_root / ".jlink_scripts"
m7_jlink_scripts = m7_root / ".jlink_scripts" m4_build = m4_root / "build" / "zephyr"
m4_jlink_scripts = m4_root / ".jlink_scripts" m4_bin = find_single_elf(m4_build, "zephyr.elf", "CM4")
m4_flash_script = gen_flash_script(m4_jlink_scripts, "CM4", m4_bin)
m7_build = m7_root / "CM7" / "build" subprocess.run(["JLinkExe", "-CommanderScript", f"{m4_flash_script}"], cwd=m4_root)
matches = list(m7_build.glob("*_CM7.elf"))
if not matches:
raise FileNotFoundError("No *_CM7.elf found in build directory")
if len(matches) > 1:
raise RuntimeError(f"Multiple CM7 ELF files found: {matches}")
m7_bin = matches[0]
m4_build = m4_root / "build" / "zephyr"
matches = list(m4_build.glob("zephyr.elf"))
if not matches:
raise FileNotFoundError("No *_CM4.elf found in build directory")
if len(matches) > 1:
raise RuntimeError(f"Multiple CM4 ELF files found: {matches}")
m4_bin = matches[0]
m7_flash_script = gen_flash_script(m7_jlink_scripts, "CM7", m7_bin)
m4_flash_script = gen_flash_script(m4_jlink_scripts, "CM4", m4_bin)
subprocess.run(["JLinkExe", "-CommanderScript", f"{m4_flash_script}"], cwd=m4_root)
subprocess.run(["JLinkExe", "-CommanderScript", f"{m7_flash_script}"], cwd=m7_root)
if "CM7" in cores:
m7_root = neoecu.common.core_root(root_path, config, "CM7")
m7_jlink_scripts = m7_root / ".jlink_scripts"
m7_build = m7_root / "CM7" / "build"
m7_bin = find_single_elf(m7_build, "*_CM7.elf", "CM7")
m7_flash_script = gen_flash_script(m7_jlink_scripts, "CM7", m7_bin)
subprocess.run(["JLinkExe", "-CommanderScript", f"{m7_flash_script}"], cwd=m7_root)

View File

@@ -10,8 +10,7 @@ from pathlib import Path
from rich.table import Table from rich.table import Table
from rich.console import Console from rich.console import Console
from neoecu.commands import build import neoecu.common
from neoecu.common import load_config
def get_env(var: str) -> str | None: def get_env(var: str) -> str | None:
@@ -36,49 +35,53 @@ def run(root_path: Path):
table.add_column("Status") table.add_column("Status")
results: list[bool] = [] results: list[bool] = []
config = neoecu.common.load_config(root_path)
cores = neoecu.common.configured_cores(config)
# Step 1 — Install west if "CM4" in cores:
west_ok = run_cmd(["uv", "pip", "install", "west"]) # Step 1 — Install west
table.add_row("Install west", ok_str if west_ok else fail_str) west_ok = run_cmd(["uv", "pip", "install", "west"])
results.append(west_ok) table.add_row("Install west", ok_str if west_ok else fail_str)
results.append(west_ok)
# Step 2 — Zephyr requirements # Step 2 — Zephyr requirements
requirements = get_env("ZEPHYR_BASE") requirements = get_env("ZEPHYR_BASE")
if requirements is None: if requirements is None:
table.add_row("Zephyr requirements", "[red]ZEPHYR_BASE not set[/red]") table.add_row("Zephyr requirements", "[red]ZEPHYR_BASE not set[/red]")
console.print(table) console.print(table)
sys.exit(1) sys.exit(1)
requirements_path = Path(requirements) / "scripts" / "requirements.txt" requirements_path = Path(requirements) / "scripts" / "requirements.txt"
deps_ok = run_cmd(["uv", "pip", "install", "-r", str(requirements_path)]) 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) table.add_row("Install Zephyr deps", ok_str if deps_ok else fail_str)
results.append(deps_ok) results.append(deps_ok)
# Step 3 — Load config if "CM7" in cores:
config = load_config(root_path) m7_root = neoecu.common.core_root(root_path, config, "CM7")
m7_root = root_path / config["build"]["m7_dir"]
m4_root = root_path / config["build"]["m4_dir"]
# Step 4 — CMake Debug Init # Step 4 — CMake Debug Init
debug_ok = run_cmd(["cmake", "--preset", "Debug"], cwd=m7_root) debug_ok = run_cmd(["cmake", "--preset", "Debug"], cwd=m7_root)
table.add_row("CMake Debug Init", ok_str if debug_ok else fail_str) table.add_row("CMake Debug Init", ok_str if debug_ok else fail_str)
results.append(debug_ok) results.append(debug_ok)
# Step 5 — CMake Release Init # Step 5 — CMake Release Init
release_ok = run_cmd(["cmake", "--preset", "Release"], cwd=m7_root) release_ok = run_cmd(["cmake", "--preset", "Release"], cwd=m7_root)
table.add_row("CMake Release Init", ok_str if release_ok else fail_str) table.add_row("CMake Release Init", ok_str if release_ok else fail_str)
results.append(release_ok) results.append(release_ok)
# Step 6 - CMake Debug Build # Step 6 - CMake Debug Build
build_ok = run_cmd(["cmake", "--build", "--preset", "Debug", "--clean-first", "--verbose"], cwd=m7_root) 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) table.add_row("CMake Debug Build", ok_str if build_ok else fail_str)
results.append(build_ok) results.append(build_ok)
# Step 7 - Zephyr Debug if "CM4" in cores:
zephyr_ok = run_cmd(["west", "build","-p", "always", "-b", "arduino_giga_r1/stm32h747xx/m4"], cwd=m4_root) m4_root = neoecu.common.core_root(root_path, config, "CM4")
table.add_row("Zephyr Build", ok_str if zephyr_ok else fail_str)
results.append(zephyr_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 # Print results table
console.print(table) console.print(table)

View File

@@ -4,6 +4,20 @@
from pathlib import Path from pathlib import Path
import tomllib import tomllib
CORE_CONFIG = {
"CM7": {
"dir_key": "m7_dir",
"type_key": "m7_type",
"supported_type": "ST",
},
"CM4": {
"dir_key": "m4_dir",
"type_key": "m4_type",
"supported_type": "Zephyr",
},
}
def find_project_root( def find_project_root(
start: Path | None = None, start: Path | None = None,
filename: str = "neoecu.toml", filename: str = "neoecu.toml",
@@ -27,7 +41,52 @@ def load_config(root: Path) -> dict:
with open(config_path, "rb") as f: with open(config_path, "rb") as f:
return tomllib.load(f) return tomllib.load(f)
def configured_cores(config: dict) -> list[str]:
build_config = config.get("build", {})
cores: list[str] = []
for core, core_config in CORE_CONFIG.items():
if all(build_config.get(key) for key in (core_config["dir_key"], core_config["type_key"])):
cores.append(core)
return cores
def core_root(root: Path, config: dict, core: str) -> Path:
return root / config["build"][CORE_CONFIG[core]["dir_key"]]
def select_cores(config: dict, args) -> list[str]:
selected = []
if getattr(args, "CM7", False):
selected.append("CM7")
if getattr(args, "CM4", False):
selected.append("CM4")
if not selected:
selected = configured_cores(config)
configured = configured_cores(config)
missing = [core for core in selected if core not in configured]
if missing:
missing_cores = ", ".join(missing)
raise ValueError(f"{missing_cores} is not fully configured in neoecu.toml")
return selected
def check_pair(config: dict) -> bool: def check_pair(config: dict) -> bool:
if (config["build"]["m7_type"] == "ST") & (config["build"]["m4_type"] == "Zephyr"): build_config = config.get("build", {})
return True cores = configured_cores(config)
return False
if not cores:
return False
for core in cores:
core_config = CORE_CONFIG[core]
if build_config[core_config["type_key"]] != core_config["supported_type"]:
return False
return True

2
uv.lock generated
View File

@@ -25,7 +25,7 @@ wheels = [
[[package]] [[package]]
name = "neoecu" name = "neoecu"
version = "0.1.0" version = "2.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "rich" }, { name = "rich" },