Skip to content

feat(custom-clusters): generate cluster definitions from ZAP XML - #970

Draft
lboue wants to merge 1 commit into
matter-js:mainfrom
lboue:feature/zap-cluster-xml-generator
Draft

feat(custom-clusters): generate cluster definitions from ZAP XML#970
lboue wants to merge 1 commit into
matter-js:mainfrom
lboue:feature/zap-cluster-xml-generator

Conversation

@lboue

@lboue lboue commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Type of change

  • 🐛 Fix — corrects a defect or wrong behavior
  • Feature — adds new functionality or capability

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/*.ts file, so
adding a new vendor's custom cluster no longer requires writing the
TypeScript decorator boilerplate by hand.

npm run generate-cluster -w @matter-server/custom-clusters -- path/to/cluster.xml

The script writes the generated file, adds its export to
src/clusters/index.ts, and prints a suggested row for the README's
cluster table. --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 datatypes with no known mapping are left out
of 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.xml referenced in the issue.

  • Ran against a real third-party cluster (Silicon Labs' weather
    station sample
    ):
    the inferred types (SINGLE/INT32Usingle/uint32) and
    attribute IDs match that sample's generated C++ accessors
    (AccessorsExtension.h) exactly, and the generated class builds and
    registers 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

  • I understand the code I am submitting and can explain how it works (AI policy)
  • Tests added or updated to cover the change
  • npm test passes (full monorepo: only the pre-existing mDNS discovery integration flakiness documented in CLAUDE.md fails, unrelated to this change; the new @matter-server/custom-clusters suite passes 7/7 on its own)
  • npm run format-verify and npm run lint pass
  • CHANGELOG updated (if applicable)

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 5, 2026 07:55
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>
@lboue
lboue force-pushed the feature/zap-cluster-xml-generator branch from dbb753b to 01e7827 Compare August 5, 2026 07:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/*.ts with @cluster/@attribute/@command decorators.
  • Add a CLI script (npm run generate-cluster -w @matter-server/custom-clusters -- <xml>) that writes the generated file and updates src/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.

Comment on lines +100 to +107
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;
}
Comment on lines +219 to +233
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",
});
Comment on lines +243 to +265
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,
};
Comment on lines +45 to +52
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);
}
Comment on lines +175 to +186
/** 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";
}
Copilot AI review requested due to automatic review settings August 5, 2026 07:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • parseXml pops 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

  • extractClusters calls parseCode(attrEl.attrs.code) without checking that code= is present. If the XML omits code=, this will throw a generic runtime error (raw.trim on 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

  • extractClusters assumes every <command> has name= and code=, and every <arg> has name= and type=. If any are missing, parseCode(cmdEl.attrs.code) will throw (or name will be undefined), 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

  • --out is resolved with resolve(packageRoot, outFile), which allows paths like --out ../../somewhere (or an absolute path) to write outside packages/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");

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants