#!/usr/bin/env python3 # SPDX-License-Identifier: MIT """ZephCore board manifests: the one list of boards and what we publish for them. Every board directory boards/// carries a zephcore.yml; each native-Linux preset boards/linux_native/.conf carries a sibling .zephcore.yml. Schema (all keys optional unless marked): target: rak4631 # REQUIRED west -b string, with qualifiers capabilities: wifi: true # WiFi companion: RAM for WiFi + BLE together light_sleep: true # DIO1 on an RTC-wake-capable GPIO AND # validated on hardware (ESP32 repeaters) release: # absent = not published (bring-up) roles: [companion, repeater] # REQUIRED in release variants: # extra published builds of this board - id: noscreen # artifact infix conf: no_display.conf # relative to the board dir subtitle: No screen # catalog role-row subtitle catalog: # absent = published but not in the catalog device: RAK WisBlock / WisMesh (RAK 4631) # REQUIRED in catalog maker: rak # REQUIRED in catalog, key of MAKERS new: true # own tile instead of folding into MeshCore's img: lora.svg | own_img: x.jpg subtitle: 30 dBm linux: # native-Linux presets only host: arm | aarch64 cross_compile: /usr/bin/arm-linux-gnueabihf- Readers: - cmake/zephcore_board.cmake reads the capabilities with a line match (it runs before Zephyr has set up Python), so `check` enforces the literal two-space-indented ` : true|false` form. - build.sh: board_manifest.py matrix [companions|repeaters] - gen_provider_catalog.py imports load_boards(). - docs: board_manifest.py docs (rewrites the generated lists in docs/supported_boards.md and the board-count table in README.md) `board_manifest.py check` validates every manifest; CI should run it. """ import os import re import sys import yaml APP_DIR = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) BOARDS_DIR = os.path.join(APP_DIR, "boards") PLATFORM_DIRS = {"nrf52840": "nrf52", "nrf54l": "nrf54l", "esp32": "esp32", "mg24": "mg24", "stm32wl": "stm32wl"} ROLES = ("companion", "repeater", "room_server", "observer") KEYS = {"target", "capabilities", "release", "catalog", "linux"} CAPS = {"light_sleep", "wifi"} CATALOG_KEYS = {"device", "maker", "new", "img", "own_img", "subtitle"} CAP_LINE = re.compile(r"^ (\w+): (true|false)\s*$", re.M) class Board: def __init__(self, name, platform, manifest_path, data, board_dir=None, conf=None): self.name = name # board dir name or linux preset name self.platform = platform # nrf52 | nrf54l | esp32 | mg24 | stm32wl | linux self.manifest_path = manifest_path self.board_dir = board_dir # None for linux presets self.conf = conf # linux preset conf, relative to APP_DIR self.target = data.get("target") self.capabilities = data.get("capabilities") or {} self.release = data.get("release") self.catalog = data.get("catalog") self.linux = data.get("linux") or {} @property def chip(self): """ESP32 chip name from the target (esp32, esp32s3, esp32c3, esp32c6).""" m = re.search(r"(esp32[^/]*)", self.target or "") return m.group(1) if m else None @property def stem(self): """Artifact filename stem, exactly as build.sh has always named files.""" if self.platform == "linux": return f"zephcore_linux_{self.name}" return self.target.replace("/", "-") @property def sysbuild(self): """--sysbuild / --no-sysbuild / None (west default) for release builds.""" if self.platform == "esp32": return True if self.chip != "esp32" else None # classic ESP32: simple boot if self.platform == "nrf54l": return False return None def _load(path): with open(path, encoding="utf-8") as f: return yaml.safe_load(f) or {} def load_boards(): boards = [] for plat_dir, platform in PLATFORM_DIRS.items(): root = os.path.join(BOARDS_DIR, plat_dir) if not os.path.isdir(root): continue for name in sorted(os.listdir(root)): bdir = os.path.join(root, name) path = os.path.join(bdir, "zephcore.yml") if os.path.isfile(path): boards.append(Board(name, platform, path, _load(path), board_dir=bdir)) lroot = os.path.join(BOARDS_DIR, "linux_native") if os.path.isdir(lroot): for fn in sorted(os.listdir(lroot)): if fn.endswith(".zephcore.yml"): name = fn[: -len(".zephcore.yml")] boards.append(Board(name, "linux", os.path.join(lroot, fn), _load(os.path.join(lroot, fn)), conf=f"boards/linux_native/{name}.conf")) return boards def check(boards, makers=None): errors = [] for b in boards: where = os.path.relpath(b.manifest_path, APP_DIR) data = _load(b.manifest_path) for k in set(data) - KEYS: errors.append(f"{where}: unknown key '{k}'") if not b.target: errors.append(f"{where}: missing target") elif b.platform != "linux" and not re.match(rf"^{re.escape(b.name)}(@|/|$)", b.target): errors.append(f"{where}: target '{b.target}' does not name board '{b.name}'") for k, v in b.capabilities.items(): if k not in CAPS: errors.append(f"{where}: unknown capability '{k}'") if not isinstance(v, bool): errors.append(f"{where}: capability '{k}' must be true/false") if b.capabilities: text = open(b.manifest_path, encoding="utf-8").read() block = re.search(r"^capabilities:\n((?: .*\n?)*)", text, re.M) found = {m.group(1) for m in CAP_LINE.finditer(block.group(1))} if block else set() if found != set(b.capabilities): errors.append(f"{where}: write capabilities as two-space-indented " f"' : true|false' lines (CMake reads them by line)") if b.release is not None: roles = b.release.get("roles") or [] if not roles or any(r not in ROLES for r in roles): errors.append(f"{where}: release.roles must be a non-empty subset of {ROLES}") for v in b.release.get("variants") or []: if not v.get("id") or not v.get("conf"): errors.append(f"{where}: variant needs id and conf") elif b.board_dir and not os.path.isfile(os.path.join(b.board_dir, v["conf"])): errors.append(f"{where}: variant conf '{v['conf']}' not found") if b.catalog is not None: for k in set(b.catalog) - CATALOG_KEYS: errors.append(f"{where}: unknown catalog key '{k}'") if not b.catalog.get("device") or not b.catalog.get("maker"): errors.append(f"{where}: catalog needs device and maker") if makers is not None and b.catalog.get("maker") not in makers: errors.append(f"{where}: catalog maker '{b.catalog.get('maker')}' not in MAKERS") if b.release is None: errors.append(f"{where}: catalog entry without a release") if b.platform == "linux" and not {"host", "cross_compile"} <= set(b.linux): errors.append(f"{where}: linux presets need linux.host and linux.cross_compile") return errors def matrix(boards, group, role_filter=None): """Release builds for one build.sh group: target|role|variant|confs|stem|sysbuild|host|cross.""" want = {"nrf": "nrf52"}.get(group, group) if role_filter: role_filter = {"companions": "companion", "repeaters": "repeater"}[role_filter] rows = [] for b in boards: if b.platform != want or b.release is None: continue sb = {True: "sysbuild", False: "no-sysbuild", None: ""}[b.sysbuild] base_confs = [b.conf] if b.conf else [] builds = [("", base_confs)] for v in b.release.get("variants") or []: rel = os.path.relpath(os.path.join(b.board_dir, v["conf"]), APP_DIR).replace(os.sep, "/") builds.append((v["id"], base_confs + [rel])) for role in b.release["roles"]: if role_filter and role != role_filter: continue for variant, confs in builds: c = list(confs) if role != "companion": # Role conf before the variant conf, as build.sh always ordered it. c = ([c[0]] if b.conf else []) + [f"boards/common/{role}.conf"] + c[1 if b.conf else 0:] rows.append("|".join([b.target, role, variant, ";".join(c), b.stem, sb, b.linux.get("host", ""), b.linux.get("cross_compile", "")])) return rows DOCS_GROUPS = [("nrf52", "nRF52840"), ("esp32", "ESP32"), ("stm32wl", "STM32WL"), ("mg24", "MG24"), ("nrf54l", "nRF54L")] SUMMARY_GROUPS = [("nrf52", "nRF52840"), ("esp32", "ESP32 family"), ("nrf54l", "nRF54L"), ("mg24", "EFR32MG24"), ("stm32wl", "STM32WL"), ("linux", "Native Linux (presets)")] def summary(boards): """The '' table: boards per platform, and how many are published.""" rows = ["| Platform | Boards | With published firmware |", "|---|---|---|"] for platform, label in SUMMARY_GROUPS: group = [b for b in boards if b.platform == platform] rows.append("| %s | %d | %d |" % (label, len(group), sum(b.release is not None for b in group))) return "\n%s\n" % "\n".join(rows) def docs(boards, path): """Rewrite each '' ... '' block.""" text = open(path, encoding="utf-8").read() text = re.sub(r".*?", lambda _m: summary(boards), text, flags=re.S) for platform, _ in DOCS_GROUPS: group = [b for b in boards if b.platform == platform] width = max((len(b.target) for b in group), default=0) lines = [b.target if b.release is not None else f"{b.target:<{width}} # source-only, no published firmware" for b in group] block = "\n```\n%s\n```\n" % (platform, "\n".join(lines)) text = re.sub(r".*?" % platform, lambda _m: block, text, flags=re.S) open(path, "w", encoding="utf-8", newline="\n").write(text) def main(argv): boards = load_boards() cmd = argv[1] if len(argv) > 1 else "check" if cmd == "check": errs = check(boards) for e in errs: print(e, file=sys.stderr) print(f"{len(boards)} manifests, {len(errs)} errors") return 1 if errs else 0 if cmd == "matrix": for row in matrix(boards, argv[2], argv[3] if len(argv) > 3 else None): print(row) return 0 if cmd == "docs": root = os.path.join(APP_DIR, "..") for path in argv[2:] or [os.path.join(root, "docs", "supported_boards.md"), os.path.join(root, "README.md")]: docs(boards, path) return 0 print(__doc__) return 2 if __name__ == "__main__": sys.exit(main(sys.argv))