ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
ra8_apps.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""App discovery and resolution helper for ra8-firmware."""
5
6import argparse
7import os
8import re
9import sys
10import tempfile
11from collections import defaultdict
12from collections.abc import Iterator
13from pathlib import Path
14from typing import TypedDict
15from unittest.mock import patch
16
17REPO_ROOT = Path(__file__).resolve().parents[2]
18MIN_LIST_COLUMN_WIDTH = 40
19
20
21class AppRecord(TypedDict):
22 """One discovered firmware application's canonical metadata."""
23
24 name: str
25 group: str
26 dir: str
27 rel_dir: str
28 desc: str
29 toolchain: str
30
31
32class BuildConfig(TypedDict):
33 """One canonical cross-build configuration for a firmware application."""
34
35 id: str
36 app: AppRecord
37 variant: str
38 cmake_args: tuple[str, ...]
39 build_suffix: str
40
41
42DEFAULT_BUILD_VARIANT = "default"
43EREADER_NS_XIP_VARIANT = "ns-xip"
44
45
46def _parse_desc(dirpath: str | Path) -> str:
47 """Extract DESCRIPTION text from CMakeLists.txt or src/main.c @brief."""
48 app_dir = Path(dirpath)
49 cmake_path = app_dir / "CMakeLists.txt"
50 if cmake_path.is_file():
51 content = cmake_path.read_text(encoding="utf-8", errors="ignore")
52 match = re.search(r'DESCRIPTION\s+"([^"]+)"', content, re.MULTILINE)
53 if match:
54 return match.group(1).strip()
55
56 main_path = app_dir / "src" / "main.c"
57 if main_path.is_file():
58 content = main_path.read_text(encoding="utf-8", errors="ignore")
59 match = re.search(r"@brief\s+([^\n*]+)", content)
60 if match:
61 desc = match.group(1).strip()
62 if desc and desc != "Main entry point.":
63 return desc
64 return ""
65
66
67def _relative_group(app_dir: Path, root: Path) -> str:
68 """Return the slash-separated parent group below a discovery root."""
69 parent = app_dir.relative_to(root).parent
70 return "" if parent == Path() else parent.as_posix()
71
72
73def _iter_cmake_dirs(root: Path) -> Iterator[Path]:
74 """Yield every directory under ``root`` that holds a ``CMakeLists.txt``.
75
76 Build output is pruned from the descent. A ``build`` directory holds only
77 CMake's own generated listfiles -- never a discoverable application -- and
78 scripts/builders/all_examples.sh runs a pool of concurrent per-app builds
79 that create and delete directories underneath exactly those trees while
80 discovery is walking them. A plain ``rglob`` therefore raced: ``os.scandir``
81 raised ``FileNotFoundError`` on a directory that had just vanished and
82 aborted discovery for the whole tree, failing one arbitrary app per
83 cross-build run. Pruning removes the race without changing the result set.
84
85 Directory names are sorted so the walk order is stable across runs.
86 """
87 for dirpath, dirnames, filenames in os.walk(root):
88 dirnames[:] = sorted(name for name in dirnames if name != "build")
89 if "CMakeLists.txt" in filenames:
90 yield Path(dirpath)
91
92
93def _get_example_apps() -> list[AppRecord]:
94 """Discover firmware examples with real CMake and main source files."""
95 apps: list[AppRecord] = []
96 examples_dir = REPO_ROOT / "examples"
97 if not examples_dir.is_dir():
98 return apps
99 for app_dir in _iter_cmake_dirs(examples_dir):
100 if not (app_dir / "src" / "main.c").is_file():
101 continue
102 group = _relative_group(app_dir, examples_dir)
103 if group.startswith("shared") or not group:
104 continue
105 toolchain = (
106 "cmake/toolchain-ra8p1.cmake" if "ra8p1" in group else "cmake/toolchain-ra8d2.cmake"
107 )
108 apps.append(
109 {
110 "name": app_dir.name,
111 "group": group,
112 "dir": str(app_dir),
113 "rel_dir": str(app_dir.relative_to(REPO_ROOT)),
114 "desc": _parse_desc(app_dir),
115 "toolchain": toolchain,
116 }
117 )
118 return apps
119
120
121def _get_board_apps() -> list[AppRecord]:
122 """Discover standalone board applications."""
123 apps: list[AppRecord] = []
124 board_dir = REPO_ROOT / "apps" / "board" / "stand_alone"
125 if not board_dir.is_dir():
126 return apps
127 for app_dir in _iter_cmake_dirs(board_dir):
128 if not (app_dir / "src" / "main.c").is_file():
129 continue
130 name = "ra8d2-ereader" if app_dir.name == "ereader" else app_dir.name
131 apps.append(
132 {
133 "name": name,
134 "group": "board/stand_alone",
135 "dir": str(app_dir),
136 "rel_dir": str(app_dir.relative_to(REPO_ROOT)),
137 "desc": _parse_desc(app_dir) or "E-reader product firmware",
138 "toolchain": "cmake/toolchain-ra8d2.cmake",
139 }
140 )
141 return apps
142
143
144def get_apps() -> list[AppRecord]:
145 """Discover all firmware applications in examples/ and apps/board/."""
146 apps = _get_example_apps() + _get_board_apps()
147 apps.sort(key=lambda app: (app["group"], app["name"]))
148 return apps
149
150
151def app_id(app: AppRecord) -> str:
152 """Return the stable namespaced identifier for one discovered app."""
153 return f"{app['group'].replace('/', '::')}::{app['name']}"
154
155
156def build_configs(app: AppRecord) -> list[BuildConfig]:
157 """Return every supported cross-build configuration for ``app``."""
158 configs: list[BuildConfig] = [
159 {
160 "id": app_id(app),
161 "app": app,
162 "variant": DEFAULT_BUILD_VARIANT,
163 "cmake_args": (),
164 "build_suffix": "build",
165 }
166 ]
167 if app["rel_dir"] == "apps/board/stand_alone/ereader":
168 configs.append(
169 {
170 "id": f"{app_id(app)}@{EREADER_NS_XIP_VARIANT}",
171 "app": app,
172 "variant": EREADER_NS_XIP_VARIANT,
173 "cmake_args": ("-DRA8_EREADER_NS_XIP=ON",),
174 "build_suffix": "build-ns-xip",
175 }
176 )
177 return configs
178
179
180def get_build_configs() -> list[BuildConfig]:
181 """Return the stable canonical cross-build configuration matrix."""
182 return sorted(
183 (config for app in get_apps() for config in build_configs(app)),
184 key=lambda config: config["id"],
185 )
186
187
188def find_build_config(selector: str) -> BuildConfig | None:
189 """Resolve an app selector with an optional ``@variant`` suffix."""
190 app_selector, separator, variant = selector.rpartition("@")
191 if not separator:
192 app_selector = selector
193 variant = DEFAULT_BUILD_VARIANT
194 app = find_app(app_selector)
195 if app is None:
196 return None
197 return next((config for config in build_configs(app) if config["variant"] == variant), None)
198
199
200def find_app(name: str) -> AppRecord | None:
201 """Find an app by unique short name, exact identifier, or board alias.
202
203 A short name that becomes ambiguous deliberately resolves to nothing. The
204 caller must then use the namespaced identifier; silently choosing the first
205 walk result would build or flash the wrong firmware.
206 """
207 apps = get_apps()
208 query = name.replace("/", "::")
209 exact_ids = [app for app in apps if app_id(app) == query]
210 if len(exact_ids) == 1:
211 return exact_ids[0]
212 if query == "ereader":
213 return next((app for app in apps if app["name"] == "ra8d2-ereader"), None)
214 short_matches = [app for app in apps if app["name"] == query]
215 if len(short_matches) == 1:
216 return short_matches[0]
217 suffix_matches = [app for app in apps if app_id(app).endswith(f"::{query}")]
218 return suffix_matches[0] if len(suffix_matches) == 1 else None
219
220
221def _filter_apps(apps: list[AppRecord], query: str, group: str | None) -> list[AppRecord]:
222 """Filter applications by query or group while retaining stable order."""
223 if query:
224 normalized = query.lower().replace("/", "::")
225 return [
226 app
227 for app in apps
228 if normalized in app_id(app).lower()
229 or normalized.replace("::", "/") in app["group"].lower()
230 or normalized in app["desc"].lower()
231 or normalized in app["name"].lower()
232 ]
233 if group:
234 group_slashes = group.lower().replace("::", "/")
235 return [
236 app
237 for app in apps
238 if group_slashes in app["group"].lower()
239 or group.lower() in app["group"].replace("/", "::").lower()
240 ]
241 return apps
242
243
244def _app_count(count: int) -> str:
245 """Format an application count without hardcoded singular/plural wording."""
246 return f"{count} {'app' if count == 1 else 'apps'}"
247
248
249def _print_category_summary(apps: list[AppRecord]) -> None:
250 """Print the stable category help summary."""
251 groups: defaultdict[str, int] = defaultdict(int)
252 for app in apps:
253 groups[app["group"]] += 1
254
255 print(f"FIRMWARE APPS ({len(apps)} discovered)\n")
256 print("USAGE:")
257 print(" just search <keyword> Search apps by keyword or name")
258 print(f" just apps::example::list List all {len(apps)} firmware apps\n")
259 print("CATEGORY FILTERS:")
260 categories = (
261 ("just apps::filter::hil", "Automated HIL hardware tests", "ek_ra8d2/hw_validated/hil"),
262 (
263 "just apps::filter::manual",
264 "Interactive board demos & LCD screens",
265 "ek_ra8d2/hw_validated/manual",
266 ),
267 (
268 "just apps::filter::c6",
269 "ESP32-C6 wireless co-processor demos",
270 "ek_ra8d2/hw_validated/c6",
271 ),
272 (
273 "just apps::filter::stand_alone",
274 "Standalone E-Reader product firmware",
275 "board/stand_alone",
276 ),
277 (
278 "just apps::filter::ra8p1",
279 "RA8P1 Cortex-M85 + NPU foundation apps",
280 "ra8p1_foundation",
281 ),
282 (
283 "just apps::filter::pending",
284 "Hardware validation pending queue",
285 "ek_ra8d2/hw_pending",
286 ),
287 (
288 "just apps::filter::pending_c6",
289 "ESP32-C6 bring-up pending queue",
290 "ek_ra8d2/hw_pending/c6",
291 ),
292 (
293 "just apps::filter::pending_manual",
294 "Manual validation pending queue",
295 "ek_ra8d2/hw_pending/manual",
296 ),
297 (
298 "just apps::filter::revalidation",
299 "HIL revalidation queue",
300 "ek_ra8d2/hil_needs_revalidation",
301 ),
302 ("just apps::filter::unsupported", "Unsupported legacy apps", "_unsupported"),
303 )
304 for command, description, group in categories:
305 print(f" {command:<35} {description} ({_app_count(groups[group])})")
306
307
308def cmd_list(args: argparse.Namespace) -> int:
309 """List all applications or a filtered selection."""
310 apps = get_apps()
311 query = (args.query or args.filter or "").strip()
312 if query and query.lower() == "all":
313 args.all = True
314 query = ""
315 apps = _filter_apps(apps, query, args.group)
316
317 if not args.all and not query and not args.group:
318 _print_category_summary(apps)
319 return 0
320
321 lines = [
322 f"== FIRMWARE APPS ({len(apps)}) -- build: just apps::build <app> | "
323 "flash: just apps::hardware::flash <app> | "
324 "run: just apps::emulator::run <app>\n"
325 ]
326 formatted = [(app_id(app), app["desc"]) for app in apps]
327 max_len = max((len(identifier) for identifier, _ in formatted), default=0)
328 column_width = max(max_len + 2, MIN_LIST_COLUMN_WIDTH)
329 lines.extend(
330 f" {identifier.ljust(column_width)} {description}" for identifier, description in formatted
331 )
332 print("\n".join(lines))
333 return 0
334
335
336def cmd_dir(args: argparse.Namespace) -> int:
337 """Print the repository-relative directory for one application."""
338 config = find_build_config(args.app)
339 if not config:
340 print(f"Error: app '{args.app}' not found", file=sys.stderr)
341 return 1
342 print(config["app"]["rel_dir"])
343 return 0
344
345
346def cmd_toolchain(args: argparse.Namespace) -> int:
347 """Print the CMake toolchain file for one application."""
348 config = find_build_config(args.app)
349 if not config:
350 print(f"Error: app '{args.app}' not found", file=sys.stderr)
351 return 1
352 print(config["app"]["toolchain"])
353 return 0
354
355
356def cmd_name(args: argparse.Namespace) -> int:
357 """Print the canonical artifact basename for an app identifier."""
358 config = find_build_config(args.app)
359 if not config:
360 print(f"Error: app '{args.app}' not found or is ambiguous", file=sys.stderr)
361 return 1
362 print(config["app"]["name"])
363 return 0
364
365
366def cmd_id(args: argparse.Namespace) -> int:
367 """Print the stable namespaced identifier for an app selector."""
368 config = find_build_config(args.app)
369 if not config:
370 print(f"Error: app '{args.app}' not found or is ambiguous", file=sys.stderr)
371 return 1
372 print(config["id"])
373 return 0
374
375
376def cmd_build_dir(args: argparse.Namespace) -> int:
377 """Print the repository-relative build directory for one configuration."""
378 config = find_build_config(args.app)
379 if not config:
380 print(f"Error: app configuration '{args.app}' not found", file=sys.stderr)
381 return 1
382 print(Path(config["app"]["rel_dir"]) / config["build_suffix"])
383 return 0
384
385
386def cmd_cmake_args(args: argparse.Namespace) -> int:
387 """Write one configuration's additional CMake arguments as NUL records."""
388 config = find_build_config(args.app)
389 if not config:
390 print(f"Error: app configuration '{args.app}' not found", file=sys.stderr)
391 return 1
392 for argument in config["cmake_args"]:
393 sys.stdout.buffer.write(argument.encode("ascii") + b"\0")
394 return 0
395
396
397def cmd_matrix(args: argparse.Namespace) -> int:
398 """Print every canonical cross-build configuration."""
399 separator = b"\0" if args.nul else b"\n"
400 for config in get_build_configs():
401 sys.stdout.buffer.write(config["id"].encode("ascii") + separator)
402 return 0
403
404
405def _selftest_write_app(root: Path, relative: str, *, main: bool = True) -> None:
406 """Create one minimal discoverable application fixture."""
407 app_dir = root / relative
408 app_dir.mkdir(parents=True, exist_ok=True)
409 (app_dir / "CMakeLists.txt").write_text(
410 'set(RA8_APP_NAME fixture)\nset(DESCRIPTION "Fixture app")\n',
411 encoding="ascii",
412 )
413 if main:
414 src = app_dir / "src"
415 src.mkdir()
416 (src / "main.c").write_text("int main(void) { return 0; }\n", encoding="ascii")
417
418
419def _selftest_discovery(root: Path, failures: list[str]) -> None:
420 """Check complete discovery, build variants, and ignored partial trees."""
421 _selftest_write_app(root, "examples/tier/alpha")
422 _selftest_write_app(root, "examples/tier/partial", main=False)
423 _selftest_write_app(root, "examples/tier/alpha/build/ghost")
424 _selftest_write_app(root, "apps/board/stand_alone/ereader")
425 identifiers = [app_id(app) for app in get_apps()]
426 expected = ["board::stand_alone::ra8d2-ereader", "tier::alpha"]
427 if identifiers != expected:
428 failures.append(f"discovery returned {identifiers!r}, expected {expected!r}")
429 configs = [config["id"] for config in get_build_configs()]
430 expected_configs = [
431 "board::stand_alone::ra8d2-ereader",
432 "board::stand_alone::ra8d2-ereader@ns-xip",
433 "tier::alpha",
434 ]
435 if configs != expected_configs:
436 failures.append(f"configuration matrix returned {configs!r}")
437
438
439def _selftest_resolution(root: Path, failures: list[str]) -> None:
440 """Check unique selectors resolve and ambiguous or invalid ones fail closed."""
441 _selftest_write_app(root, "examples/other/alpha")
442 if find_app("tier::alpha") is None or find_build_config("ereader@ns-xip") is None:
443 failures.append("namespaced or board-alias selector did not resolve")
444 if find_app("alpha") is not None:
445 failures.append("ambiguous short selector did not fail closed")
446 if find_build_config("tier::alpha@missing") is not None:
447 failures.append("unknown build variant did not fail closed")
448 if find_build_config("does-not-exist") is not None:
449 failures.append("unknown application did not fail closed")
450
451
452def _selftest_pruned_walk(root: Path, failures: list[str]) -> None:
453 """Prove transient build directories are pruned before descent."""
454 pruned = False
455
456 def scripted_walk(_root: Path) -> Iterator[tuple[str, list[str], list[str]]]:
457 nonlocal pruned
458 dirnames = ["build", "stable"]
459 yield str(root), dirnames, []
460 pruned = "build" not in dirnames
461 yield str(root / "stable"), [], ["CMakeLists.txt"]
462
463 with patch.object(os, "walk", scripted_walk):
464 found = list(_iter_cmake_dirs(root))
465 if not pruned:
466 failures.append("build output was not pruned before descent")
467 if found != [root / "stable"]:
468 failures.append(f"pruned walk returned {found!r}")
469
470
471def selftest() -> int:
472 """Exercise discovery and selector contracts in both directions."""
473 failures: list[str] = []
474 with tempfile.TemporaryDirectory(prefix="ra8-apps-") as temp:
475 root = Path(temp)
476 with patch(f"{__name__}.REPO_ROOT", root):
477 _selftest_discovery(root, failures)
478 _selftest_resolution(root, failures)
479 _selftest_pruned_walk(root, failures)
480 if failures:
481 for failure in failures:
482 print(f" [FAIL] {failure}", file=sys.stderr)
483 return 1
484 print("ra8_apps.py --selftest: PASS (discovery, variants, pruning, fail-closed selectors)")
485 return 0
486
487
488def main() -> int:
489 """Dispatch the app discovery command-line interface."""
490 parser = argparse.ArgumentParser(description="ra8 app discovery helper")
491 parser.add_argument("--selftest", action="store_true")
492 subparsers = parser.add_subparsers(dest="command")
493
494 list_parser = subparsers.add_parser("list")
495 list_parser.add_argument("query", nargs="?", default="")
496 list_parser.add_argument("--all", "-a", action="store_true")
497 list_parser.add_argument("--group", "-g")
498 list_parser.add_argument("--filter", "-f")
499 list_parser.set_defaults(func=cmd_list)
500
501 for command, function in (
502 ("dir", cmd_dir),
503 ("toolchain", cmd_toolchain),
504 ("name", cmd_name),
505 ("id", cmd_id),
506 ("build-dir", cmd_build_dir),
507 ("cmake-args", cmd_cmake_args),
508 ):
509 command_parser = subparsers.add_parser(command)
510 command_parser.add_argument("app")
511 command_parser.set_defaults(func=function)
512
513 matrix_parser = subparsers.add_parser("matrix")
514 matrix_parser.add_argument("--nul", action="store_true")
515 matrix_parser.set_defaults(func=cmd_matrix)
516
517 args = parser.parse_args()
518 if args.selftest:
519 return selftest()
520 if hasattr(args, "func"):
521 return args.func(args)
522 parser.print_help()
523 return 0
524
525
526if __name__ == "__main__":
527 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298