feat(custom-clusters): generate cluster definitions from ZAP XML - #970
feat(custom-clusters): generate cluster definitions from ZAP XML#970lboue wants to merge 1 commit into
Conversation
Adds a script to convert ZAP/CHIP custom (MEI) cluster definition XML
files -- the sample-mei-cluster.xml format used by connectedhomeip and
exported by Nordic's Matter Cluster Editor -- into
packages/custom-clusters/src/clusters/*.ts files.
npm run generate-cluster -w @matter-server/custom-clusters -- cluster.xml
The script writes the generated file, adds its export to
src/clusters/index.ts, and prints a suggested README table row.
--dry-run, --out, --class-name, and --force are also supported.
Only <attribute> elements and response-less <command> elements are
translated automatically, matching the only decorator patterns this
package has an established, verified use of (attributes throughout,
simple commands in heiman.ts). Commands with a response= link,
<event> elements, and unmapped datatypes are left out of the
generated file and listed in a trailing comment instead of guessed
at.
Verified against the sample-mei-cluster.xml referenced in the issue
and against a real third-party cluster (Silicon Labs' weather station
sample): the inferred types and attribute IDs match that sample's
generated C++ accessors exactly, and the generated class registers
correctly at runtime via Schema.Required.
Fixes matter-js#324
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dbb753b to
01e7827
Compare
There was a problem hiding this comment.
Pull request overview
This PR adds a generator workflow to @matter-server/custom-clusters that converts ZAP/CHIP custom cluster XML into decorator-based TypeScript cluster definitions, aligning with the package’s existing custom cluster authoring patterns and adding a dedicated test suite for the generator.
Changes:
- Add a ZAP XML parser + cluster extractor and a source generator that outputs
src/clusters/*.tswith@cluster/@attribute/@commanddecorators. - Add a CLI script (
npm run generate-cluster -w @matter-server/custom-clusters -- <xml>) that writes the generated file and updatessrc/clusters/index.ts. - Add a new test suite (fixtures + unit tests) and wire the package to build/test the new
test/project reference.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/custom-clusters/tsconfig.json | Adds test project reference so the workspace builds/tests include the new suite. |
| packages/custom-clusters/test/tsconfig.json | Introduces a test TS project config extending the repo’s test baseline. |
| packages/custom-clusters/test/GenerateClusterFromZapXmlTest.ts | Adds unit tests for XML parsing, type mapping, casing, and source rendering. |
| packages/custom-clusters/test/fixtures.ts | Inlines representative ZAP XML fixtures used by generator tests. |
| packages/custom-clusters/src/zap-xml/zap-cluster.ts | Implements cluster extraction, attribute/command/event parsing, and naming/type helpers. |
| packages/custom-clusters/src/zap-xml/simple-xml.ts | Adds a minimal XML parser sufficient for ZAP cluster XML input. |
| packages/custom-clusters/src/zap-xml/generate-source.ts | Renders parsed clusters into decorator-based TypeScript source with review notes for skipped elements. |
| packages/custom-clusters/scripts/generate-cluster-from-zap-xml.ts | Adds the CLI entrypoint that reads XML, generates TS, updates exports, and prints README rows. |
| packages/custom-clusters/README.md | Documents the generator workflow, limitations, and required manual review. |
| packages/custom-clusters/package.json | Adds generate-cluster and a workspace test script. |
| packages/custom-clusters/.mocharc.cjs | Adds mocha configuration via @matter/testing for the new test suite. |
| if (source.startsWith("</", ltIndex)) { | ||
| const end = source.indexOf(">", ltIndex + 2); | ||
| if (end === -1) throw new Error("Unterminated closing tag"); | ||
| if (stack.length <= 1) throw new Error(`Unmatched closing tag: ${source.slice(ltIndex, end + 1)}`); | ||
| stack.pop(); | ||
| i = end + 1; | ||
| continue; | ||
| } |
| const zclType = attrEl.attrs.type; | ||
| if (!zclType) { | ||
| skipped.push({ kind: "attribute", name, reason: "missing type= " }); | ||
| continue; | ||
| } | ||
| attributes.push({ | ||
| id: parseCode(attrEl.attrs.code), | ||
| name, | ||
| propertyName: toCamelCase(name), | ||
| zclType, | ||
| entryType: attrEl.attrs.entryType, | ||
| writable: attrEl.attrs.writable === "true", | ||
| mandatory: attrEl.attrs.optional === "false", | ||
| nullable: attrEl.attrs.isNullable === "true" || attrEl.attrs.nullable === "true", | ||
| }); |
| for (const cmdEl of commandEls) { | ||
| const name = cmdEl.attrs.name; | ||
| const source = cmdEl.attrs.source ?? "client"; | ||
| if (source === "server") { | ||
| if (!responseShapeNames.has(name)) { | ||
| skipped.push({ | ||
| kind: "command", | ||
| name, | ||
| reason: 'source="server" command with no matching response reference', | ||
| }); | ||
| } | ||
| continue; | ||
| } | ||
| const args: ParsedCommandArg[] = findAll(cmdEl, "arg").map(argEl => ({ | ||
| name: argEl.attrs.name, | ||
| zclType: argEl.attrs.type, | ||
| })); | ||
| const command: ParsedCommand = { | ||
| id: parseCode(cmdEl.attrs.code), | ||
| name, | ||
| args, | ||
| responseName: cmdEl.attrs.response, | ||
| }; |
| for (let i = 0; i < argv.length; i++) { | ||
| const arg = argv[i]; | ||
| if (arg === "--out") options.out = argv[++i]; | ||
| else if (arg === "--class-name") options.className = argv[++i]; | ||
| else if (arg === "--dry-run") options.dryRun = true; | ||
| else if (arg === "--force") options.force = true; | ||
| else positional.push(arg); | ||
| } |
| /** TypeScript type for a decorator symbol, matching README.md's "Available Type Imports" table. */ | ||
| function jsTypeFor(symbol: string): string { | ||
| if (symbol === "bool") return "boolean"; | ||
| if (symbol === "string" || symbol === "octstr") return symbol === "octstr" ? "Bytes" : "string"; | ||
| if (symbol.startsWith("int") || symbol.startsWith("uint")) { | ||
| const width = parseInt(symbol.replace(/^u?int/, ""), 10); | ||
| // 56/64-bit values can exceed Number.MAX_SAFE_INTEGER (2^53); 48-bit and below always fit. | ||
| return width >= 56 ? "number | bigint" : "number"; | ||
| } | ||
| // single, double, enum8, enum16, map8, map16, map32, map64 | ||
| return "number"; | ||
| } |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (4)
packages/custom-clusters/src/zap-xml/simple-xml.ts:104
parseXmlpops the element stack on any closing tag without verifying it matches the currently open element. This can silently accept malformed XML (e.g.<a><b></a>) and produce an incorrect tree instead of failing fast.
if (source.startsWith("</", ltIndex)) {
const end = source.indexOf(">", ltIndex + 2);
if (end === -1) throw new Error("Unterminated closing tag");
if (stack.length <= 1) throw new Error(`Unmatched closing tag: ${source.slice(ltIndex, end + 1)}`);
stack.pop();
packages/custom-clusters/src/zap-xml/zap-cluster.ts:225
extractClusterscallsparseCode(attrEl.attrs.code)without checking thatcode=is present. If the XML omitscode=, this will throw a generic runtime error (raw.trimon undefined) rather than producing a clear skipped reason or error message.
const zclType = attrEl.attrs.type;
if (!zclType) {
skipped.push({ kind: "attribute", name, reason: "missing type= " });
continue;
}
attributes.push({
id: parseCode(attrEl.attrs.code),
packages/custom-clusters/src/zap-xml/zap-cluster.ts:261
extractClustersassumes every<command>hasname=andcode=, and every<arg>hasname=andtype=. If any are missing,parseCode(cmdEl.attrs.code)will throw (ornamewill beundefined), yielding confusing failures instead of a deterministic skip/error.
for (const cmdEl of commandEls) {
const name = cmdEl.attrs.name;
const source = cmdEl.attrs.source ?? "client";
if (source === "server") {
if (!responseShapeNames.has(name)) {
skipped.push({
kind: "command",
name,
reason: 'source="server" command with no matching response reference',
});
}
continue;
}
const args: ParsedCommandArg[] = findAll(cmdEl, "arg").map(argEl => ({
name: argEl.attrs.name,
zclType: argEl.attrs.type,
}));
const command: ParsedCommand = {
id: parseCode(cmdEl.attrs.code),
packages/custom-clusters/scripts/generate-cluster-from-zap-xml.ts:127
--outis resolved withresolve(packageRoot, outFile), which allows paths like--out ../../somewhere(or an absolute path) to write outsidepackages/custom-clusters. Even though this is a dev script, a small guard prevents accidental overwrites of unrelated files.
const outFile = options.out ?? `src/clusters/${toKebabCase(firstClassName)}.ts`;
const outPath = resolve(packageRoot, outFile);
const combinedSource = renderedClasses.join("\n");
Type of change
Description
Adds a script that converts a ZAP/CHIP custom (MEI) cluster definition
XML file — the sample-mei-cluster.xml
format used by connectedhomeip and exported by Nordic's Matter Cluster
Editor — into a
packages/custom-clusters/src/clusters/*.tsfile, soadding a new vendor's custom cluster no longer requires writing the
TypeScript decorator boilerplate by hand.
The script writes the generated file, adds its export to
src/clusters/index.ts, and prints a suggested row for the README'scluster table.
--dry-run,--out,--class-name, and--forcearealso supported.
Only
<attribute>elements and response-less<command>elements aretranslated automatically — matching the only decorator patterns this
package has an established, verified use of (attributes throughout,
simple commands in
heiman.ts). Commands with aresponse=link,<event>elements, and datatypes with no known mapping are left outof the generated file and listed in a trailing comment instead of
guessed at, so nothing is silently wrong. The README documents this
and recommends reviewing the generated file before committing it.
Backing evidence
This is a new capability, not a fix for a reported defect, so there's
no bug log to attach. Independent verification instead:
Ran against the
sample-mei-cluster.xmlreferenced in the issue.Ran against a real third-party cluster (Silicon Labs' weather
station sample):
the inferred types (
SINGLE/INT32U→single/uint32) andattribute IDs match that sample's generated C++ accessors
(
AccessorsExtension.h) exactly, and the generated class builds andregisters correctly at runtime via
Schema.Required.Related issue: [request] Script to parse MEI cluster definition file #324
This fix/change is backed by a complete log file — attached here or in the linked issue (not just a few quoted lines). (n/a — feature, see "Backing evidence" above)
Checklist
npm testpasses (full monorepo: only the pre-existing mDNS discovery integration flakiness documented inCLAUDE.mdfails, unrelated to this change; the new@matter-server/custom-clusterssuite passes 7/7 on its own)npm run format-verifyandnpm run lintpass🤖 Generated with Claude Code