Skip to content

Commit cf66cda

Browse files
NerivecKoenkk
andauthored
fix: Enhance ZCL specification (#10983)
Co-authored-by: Koen Kanters <[email protected]>
1 parent 069547d commit cf66cda

42 files changed

Lines changed: 1912 additions & 752 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"dependencies": {
4646
"iconv-lite": "^0.7.0",
4747
"semver": "^7.7.3",
48-
"zigbee-herdsman": "^7.0.4"
48+
"zigbee-herdsman": "^8.0.0"
4949
},
5050
"exports": {
5151
".": "./dist/index.js",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/zap.ts

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
// EXEC: tsx scripts/zap.ts
2+
import {promises as fs} from "node:fs";
3+
import path from "node:path";
4+
import ts from "typescript";
5+
6+
const ROOT_DIR = path.resolve(__dirname, "..");
7+
const SRC_DIR = path.resolve(ROOT_DIR, "src");
8+
const INDENT_UNIT = " ";
9+
10+
const SPECIAL_TYPE_MAP: Record<string, TypeClassification> = {
11+
CLUSTER_ID: {category: "unsigned", bits: 16},
12+
ATTR_ID: {category: "unsigned", bits: 16},
13+
UTC: {category: "unsigned", bits: 32},
14+
};
15+
16+
type TypeCategory = "unsigned" | "signed";
17+
18+
type TypeClassification = {
19+
category: TypeCategory;
20+
bits: number;
21+
};
22+
23+
type PendingInsertion = {
24+
pos: number;
25+
text: string;
26+
};
27+
28+
type CommandsKey = "commands" | "commandsResponse";
29+
30+
void (async () => {
31+
try {
32+
const files = await collectTsFiles(SRC_DIR);
33+
let totalInsertions = 0;
34+
const changedFiles: string[] = [];
35+
36+
for (const file of files) {
37+
const changes = await processFile(file);
38+
if (changes.insertions > 0) {
39+
totalInsertions += changes.insertions;
40+
changedFiles.push(path.relative(ROOT_DIR, file));
41+
}
42+
}
43+
44+
if (changedFiles.length === 0) {
45+
console.log("No changes were necessary.");
46+
} else {
47+
console.log(`Updated ${changedFiles.length} files with ${totalInsertions} insertions.`);
48+
changedFiles.forEach((file) => {
49+
console.log(` • ${file}`);
50+
});
51+
}
52+
} catch (error) {
53+
console.error(error);
54+
process.exitCode = 1;
55+
}
56+
})();
57+
58+
async function collectTsFiles(dir: string): Promise<string[]> {
59+
const entries = await fs.readdir(dir, {withFileTypes: true});
60+
const files: string[] = [];
61+
62+
for (const entry of entries) {
63+
const entryPath = path.join(dir, entry.name);
64+
if (entry.isDirectory()) {
65+
files.push(...(await collectTsFiles(entryPath)));
66+
} else if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
67+
files.push(entryPath);
68+
}
69+
}
70+
71+
return files;
72+
}
73+
74+
async function processFile(filePath: string): Promise<{insertions: number}> {
75+
const sourceText = await fs.readFile(filePath, "utf8");
76+
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
77+
const pendingInsertions: PendingInsertion[] = [];
78+
79+
const visit = (node: ts.Node): void => {
80+
if (ts.isCallExpression(node) && isDeviceAddCustomCluster(node)) {
81+
const clusterDef = node.arguments[1];
82+
if (clusterDef && ts.isObjectLiteralExpression(clusterDef)) {
83+
handleClusterDefinition(clusterDef, sourceFile, sourceText, pendingInsertions);
84+
}
85+
}
86+
87+
node.forEachChild(visit);
88+
};
89+
90+
visit(sourceFile);
91+
92+
if (pendingInsertions.length === 0) {
93+
return {insertions: 0};
94+
}
95+
96+
const updatedText = applyInsertions(sourceText, pendingInsertions);
97+
await fs.writeFile(filePath, updatedText);
98+
99+
return {insertions: pendingInsertions.length};
100+
}
101+
102+
function isDeviceAddCustomCluster(node: ts.CallExpression): boolean {
103+
const expression = node.expression;
104+
105+
if (ts.isIdentifier(expression)) {
106+
return expression.text === "deviceAddCustomCluster";
107+
}
108+
109+
return ts.isPropertyAccessExpression(expression) && expression.name.text === "deviceAddCustomCluster";
110+
}
111+
112+
function handleClusterDefinition(
113+
clusterDef: ts.ObjectLiteralExpression,
114+
sourceFile: ts.SourceFile,
115+
sourceText: string,
116+
pendingInsertions: PendingInsertion[],
117+
): void {
118+
const attributes = getPropertyAssignment(clusterDef, "attributes");
119+
if (attributes && ts.isObjectLiteralExpression(attributes.initializer)) {
120+
for (const attribute of attributes.initializer.properties) {
121+
if (!ts.isPropertyAssignment(attribute) || !ts.isObjectLiteralExpression(attribute.initializer)) continue;
122+
const additions = buildAttributeAdditions(attribute.initializer, sourceFile);
123+
enqueueInsertion(additions, attribute.initializer, sourceFile, sourceText, pendingInsertions);
124+
}
125+
}
126+
127+
for (const commandsKey of ["commands", "commandsResponse"] as CommandsKey[]) {
128+
const commandsProp = getPropertyAssignment(clusterDef, commandsKey);
129+
if (!commandsProp || !ts.isObjectLiteralExpression(commandsProp.initializer)) continue;
130+
131+
for (const command of commandsProp.initializer.properties) {
132+
if (!ts.isPropertyAssignment(command) || !ts.isObjectLiteralExpression(command.initializer)) continue;
133+
const parametersProp = getPropertyAssignment(command.initializer, "parameters");
134+
if (!parametersProp || !ts.isArrayLiteralExpression(parametersProp.initializer)) continue;
135+
136+
for (const parameter of parametersProp.initializer.elements) {
137+
if (!ts.isObjectLiteralExpression(parameter)) continue;
138+
const additions = buildParameterAdditions(parameter, sourceFile);
139+
enqueueInsertion(additions, parameter, sourceFile, sourceText, pendingInsertions);
140+
}
141+
}
142+
}
143+
}
144+
145+
function buildAttributeAdditions(obj: ts.ObjectLiteralExpression, sourceFile: ts.SourceFile): string[] {
146+
const additions: string[] = [];
147+
if (!hasProperty(obj, "write")) {
148+
additions.push("write: true");
149+
}
150+
151+
const classification = getTypeClassification(obj, sourceFile);
152+
if (!classification) {
153+
return additions;
154+
}
155+
156+
if (classification.category === "unsigned" && !hasProperty(obj, "max")) {
157+
additions.push(`max: ${createHexLiteral(classification.bits)}`);
158+
} else if (classification.category === "signed" && !hasProperty(obj, "min")) {
159+
additions.push(`min: ${createSignedMinLiteral(classification.bits)}`);
160+
}
161+
162+
return additions;
163+
}
164+
165+
function buildParameterAdditions(obj: ts.ObjectLiteralExpression, sourceFile: ts.SourceFile): string[] {
166+
const classification = getTypeClassification(obj, sourceFile);
167+
if (!classification) {
168+
return [];
169+
}
170+
171+
if (classification.category === "unsigned" && !hasProperty(obj, "max")) {
172+
return [`max: ${createHexLiteral(classification.bits)}`];
173+
}
174+
175+
if (classification.category === "signed" && !hasProperty(obj, "min")) {
176+
return [`min: ${createSignedMinLiteral(classification.bits)}`];
177+
}
178+
179+
return [];
180+
}
181+
182+
function getTypeClassification(obj: ts.ObjectLiteralExpression, sourceFile: ts.SourceFile): TypeClassification | undefined {
183+
const typeProp = getPropertyAssignment(obj, "type");
184+
if (!typeProp) return undefined;
185+
186+
const typeName = extractTypeName(typeProp.initializer, sourceFile);
187+
if (!typeName) return undefined;
188+
189+
return classify(typeName.toUpperCase());
190+
}
191+
192+
function classify(typeName: string): TypeClassification | undefined {
193+
if (typeName in SPECIAL_TYPE_MAP) {
194+
return SPECIAL_TYPE_MAP[typeName];
195+
}
196+
197+
const unsignedMatch = /^(UINT|ENUM)(\d+)$/.exec(typeName);
198+
if (unsignedMatch) {
199+
return {category: "unsigned", bits: Number(unsignedMatch[2])};
200+
}
201+
202+
const signedMatch = /^INT(\d+)$/.exec(typeName);
203+
if (signedMatch) {
204+
return {category: "signed", bits: Number(signedMatch[1])};
205+
}
206+
207+
return undefined;
208+
}
209+
210+
function extractTypeName(expression: ts.Expression, sourceFile: ts.SourceFile): string | undefined {
211+
if (ts.isPropertyAccessExpression(expression)) {
212+
return expression.name.getText(sourceFile);
213+
}
214+
215+
if (ts.isElementAccessExpression(expression)) {
216+
const argument = expression.argumentExpression;
217+
if (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument)) {
218+
return argument.text;
219+
}
220+
}
221+
222+
if (ts.isIdentifier(expression)) {
223+
return expression.getText(sourceFile);
224+
}
225+
226+
return undefined;
227+
}
228+
229+
function hasProperty(obj: ts.ObjectLiteralExpression, propertyName: string): boolean {
230+
return obj.properties.some((property) => ts.isPropertyAssignment(property) && getPropertyName(property.name) === propertyName);
231+
}
232+
233+
function getPropertyAssignment(obj: ts.ObjectLiteralExpression, propertyName: string): ts.PropertyAssignment | undefined {
234+
return obj.properties.find(
235+
(property): property is ts.PropertyAssignment => ts.isPropertyAssignment(property) && getPropertyName(property.name) === propertyName,
236+
);
237+
}
238+
239+
function getPropertyName(name: ts.PropertyName): string | undefined {
240+
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
241+
return name.text;
242+
}
243+
244+
return undefined;
245+
}
246+
247+
function enqueueInsertion(
248+
additions: string[],
249+
target: ts.ObjectLiteralExpression,
250+
sourceFile: ts.SourceFile,
251+
sourceText: string,
252+
pendingInsertions: PendingInsertion[],
253+
): void {
254+
if (additions.length === 0) return;
255+
256+
const closingBrace = target.getLastToken(sourceFile);
257+
if (!closingBrace) return;
258+
259+
const insertPos = closingBrace.getStart(sourceFile);
260+
const inline = !sourceText.slice(target.getStart(sourceFile), insertPos).includes("\n");
261+
const indent = inline ? "" : getLineIndent(sourceText, insertPos);
262+
const text = formatInsertion(additions, {inline, indent});
263+
pendingInsertions.push({pos: insertPos, text});
264+
}
265+
266+
function formatInsertion(additions: string[], context: {inline: boolean; indent: string}): string {
267+
if (context.inline) {
268+
return additions.map((addition) => `, ${addition}`).join("");
269+
}
270+
271+
const propertyIndent = `${context.indent}${INDENT_UNIT}`;
272+
return additions.map((addition) => `\n${propertyIndent}${addition},`).join("");
273+
}
274+
275+
function getLineIndent(text: string, pos: number): string {
276+
let lineStart = text.lastIndexOf("\n", pos - 1);
277+
if (lineStart === -1) {
278+
lineStart = 0;
279+
} else {
280+
lineStart += 1;
281+
}
282+
283+
let indent = "";
284+
for (let i = lineStart; i < text.length; i += 1) {
285+
const char = text[i];
286+
if (char === " " || char === "\t") {
287+
indent += char;
288+
} else {
289+
break;
290+
}
291+
}
292+
293+
return indent;
294+
}
295+
296+
function createHexLiteral(bits: number): string {
297+
if (bits <= 0) {
298+
return "0x0";
299+
}
300+
301+
const value = (1n << BigInt(bits)) - 1n;
302+
return `0x${value.toString(16)}`;
303+
}
304+
305+
function createSignedMinLiteral(bits: number): string {
306+
if (bits <= 0) {
307+
return "0";
308+
}
309+
310+
const value = -(1n << BigInt(bits - 1));
311+
return value.toString();
312+
}
313+
314+
function applyInsertions(text: string, insertions: PendingInsertion[]): string {
315+
const sorted = [...insertions].sort((a, b) => b.pos - a.pos);
316+
let updated = text;
317+
318+
for (const insertion of sorted) {
319+
updated = `${updated.slice(0, insertion.pos)}${insertion.text}${updated.slice(insertion.pos)}`;
320+
}
321+
322+
return updated;
323+
}

0 commit comments

Comments
 (0)