Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions extras/board_spec/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/generated/resolved/
21 changes: 14 additions & 7 deletions extras/board_spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ facts in reviewable JSON. It is intentionally outside M5GFX's build inputs.
- `targets.json`: generator-specific option-bit to board-choice mappings
- `detector_order.json`: which catalog boards each package's detector families cover
- `lib/`, `cli/`: browser-safe model logic and the Node command wrapper
- `generated/resolved/`: fully resolved revision/runtime combinations
- `generated/docs/`: revision and composition tables rendered from the catalog
- `generated/m5unified_pin_table/`: the M5Unified external pin tables, per SoC
- `editor/`, `build/`: editor sources and the dependency-free bundler
Expand All @@ -20,7 +19,9 @@ facts in reviewable JSON. It is intentionally outside M5GFX's build inputs.
```sh
node cli/spec.js validate boards/m5stack_core2.json # schema and catalog rules (all boards when no file is given)
node cli/spec.js format boards/m5stack_core2.json --write # canonical formatting (without --write: report only)
node cli/spec.js resolve boards/m5stack_core2.json # generated/resolved/ and generated/docs/ for one board
node cli/spec.js resolve boards/m5stack_core2.json # inspect all revision/runtime combinations as JSON on stdout
node cli/spec.js resolve boards/m5stack_core2.json --out /tmp/resolved # optional local JSON files
node cli/spec.js emit-docs # generated/docs/ (all boards, or pass board files)
node cli/spec.js emit-wiring # src/board_detect/m5/generated/ (detection wiring, specs, detector order)
node cli/spec.js emit-pintable # generated/m5unified_pin_table/<soc>.{json,inl}
node cli/spec.js compare-pintable --m5unified <path> # compile the generated tables against M5Unified's src/M5Unified.inl
Expand All @@ -31,10 +32,16 @@ npm test # test/*.test.js (the C

`check` validates the connector types, parts, accessories and every board, requires
every board file to be in canonical format, and fails when any generated output
(resolved snapshots, docs, pin tables, detection wiring, the editor bundle) is
missing, stale or unexpected. So after editing a board: `resolve` it, run
`emit-wiring`, `emit-pintable` and `build/bundle.js`, then `check` and `npm test`,
and commit the generated files together with the source.
(docs, pin tables, detection wiring, the editor bundle) is missing, stale or
unexpected. Every revision/runtime combination is expanded and validated in
memory; resolved exports are not build inputs or freshness-check fixtures.
After editing a board, run `emit-docs`, `emit-wiring`, `emit-pintable` and
`build/bundle.js`, then `check` and `npm test`, and commit those generated files
with the source. Do not commit resolved exports.

Inspect resolved boards with `resolve` (one JSON array of `{combination, board}`
entries on stdout), or select a revision in the editor's read-only view.
The `--out` directory is arbitrary; its exports are not read by any tool.

Boards use `devices.<id>.choices` for replaceable parts and `revisions[]` for
known product revisions. Runtime-selected slots are expanded as additional
Expand Down Expand Up @@ -242,7 +249,7 @@ Measure each of these states on real hardware before writing a value.

1. Set `pins.<gpio>.detect_class` in the board JSON. Validation (`node cli/spec.js
validate`, also shown in the editor) rejects it on pins without internal pulls.
2. Regenerate from `extras/board_spec`: `node cli/spec.js resolve boards/<board>.json`,
2. Regenerate from `extras/board_spec`: `node cli/spec.js emit-docs boards/<board>.json`,
`node cli/spec.js emit-wiring`, and `node build/bundle.js`; then run
`node cli/spec.js check` and `npm test`. Commit the generated files; do not edit them
by hand.
Expand Down
75 changes: 45 additions & 30 deletions extras/board_spec/cli/spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ async function formatFiles(files, write) {
}
}

async function resolveFile(filename) {
async function loadValidatedBoard(filename) {
const schema = await loadSchema();
const connectorTypes = await loadConnectorTypes();
const parts = await loadParts();
Expand All @@ -313,25 +313,43 @@ async function resolveFile(filename) {
printErrors(filename, errors);
throw new Error(`${failureCount(errors)} validation error(s)`);
}
const outputDir = path.join(root, "generated/resolved");
return { board, ctx };
}

async function resolveFile(filename, outputDir) {
const { board, ctx } = await loadValidatedBoard(filename);
const outputs = resolveAll(board, ctx.connectorTypes, resolveCatalogs(ctx));
// Resolved boards are inspection views, not build inputs. Only --out writes
// local exports; stdout preserves combination names for programmatic users.
if (!outputDir) {
const views = outputs.map((output) => ({
combination: output.filename.slice(0, -".json".length),
board: JSON.parse(formatBoard(output.board)),
}));
process.stdout.write(`${JSON.stringify(views, null, 2)}\n`);
return;
}
await fs.mkdir(outputDir, { recursive: true });
const outputs = resolveAll(board, connectorTypes, resolveCatalogs(ctx));
for (const output of outputs) await fs.writeFile(safeOutputPath(outputDir, output.filename), formatBoard(output.board));
const revisionDoc = renderRevisionDoc(board, ctx.target);
if (revisionDoc) {
const docsDir = path.join(root, "generated/docs");
await fs.mkdir(docsDir, { recursive: true });
await fs.writeFile(safeOutputPath(docsDir, `${board.id}_revisions.md`), revisionDoc);
}
const compositionDoc = renderCompositionDoc(board, ctx.composition, accessories);
if (compositionDoc) {
const docsDir = path.join(root, "generated/docs");
await fs.mkdir(docsDir, { recursive: true });
await fs.writeFile(safeOutputPath(docsDir, `${board.id}_composition.md`), compositionDoc);
}
console.log(`resolved ${outputs.length} combination(s)`);
}

async function writeDocs(files) {
for (const filename of files) {
const { board, ctx } = await loadValidatedBoard(filename);
const docs = [
[`${board.id}_revisions.md`, renderRevisionDoc(board, ctx.target)],
[`${board.id}_composition.md`, renderCompositionDoc(board, ctx.composition, ctx.accessories)],
];
for (const [name, text] of docs) if (text) {
const directory = path.join(root, "generated/docs");
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(safeOutputPath(directory, name), text);
}
}
console.log(`generated docs for ${files.length} board(s)`);
}

async function check() {
const schema = await loadSchema();
const connectorTypes = await loadConnectorTypes();
Expand All @@ -356,17 +374,14 @@ async function check() {
throw new Error(`${failureCount(errors)} validation error(s)`);
}

// validateResolvedVariants above checks every expansion in memory; there is
// no resolved-file cache to maintain or compare with the source catalog.
let combinations = 0;
for (let index = 0; index < boards.length; index += 1) {
const source = await fs.readFile(files[index], "utf8");
if (source !== formatBoard(boards[index])) throw new Error(`${path.relative(root, files[index])} is not formatted`);
const ctx = contexts.get(boards[index].id);
for (const output of resolveAll(boards[index], connectorTypes, resolveCatalogs(ctx))) {
const filename = path.join(root, "generated/resolved", output.filename);
let actual;
try { actual = await fs.readFile(filename, "utf8"); }
catch { throw new Error(`missing resolved snapshot: ${path.relative(root, filename)}`); }
if (actual !== formatBoard(output.board)) throw new Error(`stale resolved snapshot: ${path.relative(root, filename)}`);
}
combinations += resolveAll(boards[index], connectorTypes, resolveCatalogs(ctx)).length;
const revisionDoc = renderRevisionDoc(boards[index], ctx.target);
if (revisionDoc) {
const filename = path.join(root, "generated/docs", `${boards[index].id}_revisions.md`);
Expand All @@ -384,10 +399,6 @@ async function check() {
if (actual !== compositionDoc) throw new Error(`stale composition doc: ${path.relative(root, filename)}`);
}
}
const expectedSnapshots = new Set(boards.flatMap((board) => resolveAll(board, connectorTypes, resolveCatalogs(contexts.get(board.id))).map((output) => output.filename)));
const actualSnapshots = (await fs.readdir(path.join(root, "generated/resolved"))).filter((name) => name.endsWith(".json"));
const unexpected = actualSnapshots.filter((name) => !expectedSnapshots.has(name));
if (unexpected.length) throw new Error(`unexpected resolved snapshot(s): ${unexpected.join(", ")}`);
const expectedDocs = new Set([
...boards.filter((board) => board.revisions?.length).map((board) => `${board.id}_revisions.md`),
...boards.filter((board) => contexts.get(board.id).composition).map((board) => `${board.id}_composition.md`),
Expand Down Expand Up @@ -421,7 +432,7 @@ async function check() {
try { dist = await fs.readFile(distFilename, "utf8"); }
catch { throw new Error(`missing editor bundle: ${path.relative(root, distFilename)}`); }
if (dist !== await renderBundle()) throw new Error(`stale editor bundle: ${path.relative(root, distFilename)}`);
console.log(`check passed: ${boards.length} board(s), ${boards.reduce((sum, board) => sum + resolveAll(board, connectorTypes, resolveCatalogs(contexts.get(board.id))).length, 0)} resolved snapshot(s)`);
console.log(`check passed: ${boards.length} board(s), ${combinations} resolved combination(s)`);
}

const PIN_TABLE_START = "static constexpr const uint8_t _pin_table_i2c_ex_in[][5] = {";
Expand Down Expand Up @@ -553,17 +564,21 @@ async function main() {
return formatFiles(files, write);
}
if (command === "resolve") {
if (args.length !== 1) throw new Error("resolve requires exactly one board file");
return resolveFile(path.resolve(args[0]));
if ((args.length !== 1 && args.length !== 3) || args[0]?.startsWith("--")
|| (args.length === 3 && (args[1] !== "--out" || args[2].startsWith("--")))) {
throw new Error("resolve requires one board file and optional --out <dir>");
}
return resolveFile(path.resolve(args[0]), args.length === 3 ? path.resolve(args[2]) : undefined);
}
if (command === "emit-docs") return writeDocs(args.length ? args.map((file) => path.resolve(file)) : await boardFiles());
if (command === "emit-pintable") return writePinTableOutputs();
if (command === "emit-wiring") return writeWiringOutput();
if (command === "compare-pintable") {
if (args.length !== 2 || args[0] !== "--m5unified") throw new Error("compare-pintable requires --m5unified <path>");
return comparePinTable(path.resolve(args[1]));
}
if (command === "check") return check();
throw new Error("usage: spec.js validate <file...> | format <file...> [--write] | resolve <file> | emit-pintable | emit-wiring | compare-pintable --m5unified <path> | check");
throw new Error("usage: spec.js validate <file...> | format <file...> [--write] | resolve <file> [--out <dir>] | emit-docs [file...] | emit-pintable | emit-wiring | compare-pintable --m5unified <path> | check");
}

main().catch((failure) => {
Expand Down
Loading
Loading