diff --git a/lib/Endpoint.js b/lib/Endpoint.js index 89f564a..5cde4e0 100644 --- a/lib/Endpoint.js +++ b/lib/Endpoint.js @@ -104,43 +104,51 @@ class Endpoint extends EventEmitter { */ async handleFrame(clusterId, frame, meta) { const rawFrame = frame; + frame = Endpoint.parseFrame(frame); - if (rawFrame[0] & 0x4) { - frame = ZCLMfgSpecificHeader.fromBuffer(rawFrame); - } else frame = ZCLStandardHeader.fromBuffer(rawFrame); - - // NOTE: we do not respond with a default response if: - // 1. The frame we received is a default response (frame.cmdId = 11) - // 2. Another command is sent in response to the received frame - // 3. The frame has the disableDefaultResponse flag set - // See ZCL specification 2.5.12.2. - const response = ( - frame.frameControl.disableDefaultResponse - || (meta && meta.groupId) - || frame.cmdId === 11 - ) ? null : this._makeErrorResponse(frame); - + let clusterSpecificResponse = null; + let clusterSpecificError = null; try { - const result = await this.handleZCLFrame(clusterId, frame, meta, rawFrame); - if (!response) return; - if (result) { - const [cmdId, data] = result; - response.data = data.toBuffer(); - response.cmdId = cmdId; - } else { - // Set status to success - response.data[1] = 0; - } - } catch (e) { - debug(`${this.getLogId(clusterId)}, error while handling frame`, e.message, { meta, frame }); + clusterSpecificResponse = await this.handleZCLFrame(clusterId, frame, meta, rawFrame); + } catch (err) { + clusterSpecificError = err; + debug(`${this.getLogId(clusterId)}, error while handling frame`, err.message, { meta, frame }); } - // If desired (disableDefaultResponse: false) try to respond to the incoming frame - if (response) { - try { - await this.sendFrame(clusterId, response.toBuffer()); - } catch (err) { - debug(`${this.getLogId(clusterId)}, error while responding with \`send frame\` to \`handle frame\``, err, { response }); - } + + // Don't respond to this frame if it is a default response or a group cast (ZCL spec 2.5.12.2) + if (frame.cmdId === 11 || (meta && typeof meta.groupId === 'number')) return; + + // If cluster specific error, respond with a default response error frame + if (clusterSpecificError) { + const defaultResponseErrorFrame = this.makeDefaultResponseFrame(frame, false); + this.sendFrame(clusterId, defaultResponseErrorFrame.toBuffer()).catch(err => { + debug(`${this.getLogId(clusterId)}, error while sending default error response`, err, { response: defaultResponseErrorFrame }); + }); + + // No further handling for this frame + return; + } + + // Create response frame and set status to success + const responseFrame = this.makeDefaultResponseFrame(frame, true); + + // If a cluster specific response was generated, set the response data + // and cmdId in the response frame. + if (clusterSpecificResponse) { + const [cmdId, data] = clusterSpecificResponse; + responseFrame.data = data.toBuffer(); + responseFrame.cmdId = cmdId; + } + + // If there was no cluster specific response and the default response is disabled, don't + // send a response. + if (!clusterSpecificResponse && frame.frameControl.disableDefaultResponse) return; + + // Send either cluster specific, or default response frame + try { + await this.sendFrame(clusterId, responseFrame.toBuffer()); + } catch (err) { + debug(`${this.getLogId(clusterId)}, error while sending cluster specific or default success response`, err, { response: responseFrame }); } } @@ -173,25 +181,38 @@ class Endpoint extends EventEmitter { return response; } - _makeErrorResponse(frame) { - let result; - if (frame instanceof ZCLStandardHeader) { - result = new ZCLStandardHeader(); + /** + * Returns a default response frame with an error status code. + * @param {*} receivedFrame + * @param {boolean} success + * @returns {ZCLStandardHeader|ZCLMfgSpecificHeader} + */ + makeDefaultResponseFrame(receivedFrame, success) { + let responseFrame; + if (receivedFrame instanceof ZCLStandardHeader) { + responseFrame = new ZCLStandardHeader(); } else { - result = new ZCLMfgSpecificHeader(); - result.manufacturerId = frame.manufacturerId; + responseFrame = new ZCLMfgSpecificHeader(); + responseFrame.manufacturerId = receivedFrame.manufacturerId; } // TODO: flip proper bits - result.frameControl = frame.frameControl.copy(); + responseFrame.frameControl = receivedFrame.frameControl.copy(); + + responseFrame.frameControl.disableDefaultResponse = true; + responseFrame.frameControl.clusterSpecific = false; + responseFrame.frameControl.directionToClient = !receivedFrame.frameControl.directionToClient; - result.frameControl.disableDefaultResponse = true; - result.frameControl.clusterSpecific = false; - result.frameControl.directionToClient = !frame.frameControl.directionToClient; + responseFrame.trxSequenceNumber = receivedFrame.trxSequenceNumber; + responseFrame.cmdId = 0x0B; + responseFrame.data = Buffer.from([receivedFrame.cmdId, success ? 0 : 1]); + return responseFrame; + } - result.trxSequenceNumber = frame.trxSequenceNumber; - result.cmdId = 0x0B; - result.data = Buffer.from([frame.cmdId, 0x01]); - return result; + static parseFrame(frame) { + if (frame[0] & 0x4) { + return ZCLMfgSpecificHeader.fromBuffer(frame); + } + return ZCLStandardHeader.fromBuffer(frame); } } diff --git a/package-lock.json b/package-lock.json index ef1e5d0..2af9291 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@athombv/jsdoc-template": "^1.6.1", + "@types/sinon": "^17.0.3", "concurrently": "^5.2.0", "eslint": "^6.8.0", "eslint-config-athom": "^2.1.0", @@ -21,6 +22,7 @@ "jsdoc-ts-utils": "^2.0.0", "mocha": "^10.1.0", "serve": "^14.0.1", + "sinon": "^19.0.2", "watch": "^1.0.2" }, "engines": { @@ -330,6 +332,55 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true, + "license": "(Unlicense OR Apache-2.0)" + }, "node_modules/@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", @@ -406,6 +457,23 @@ "dev": true, "peer": true }, + "node_modules/@types/sinon": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz", + "integrity": "sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@webassemblyjs/ast": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", @@ -3269,6 +3337,13 @@ "json5": "lib/cli.js" } }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, + "license": "MIT" + }, "node_modules/klaw": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", @@ -3391,6 +3466,13 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -3988,6 +4070,30 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/nise": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", + "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.1.0" + } + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/node-releases": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.8.tgz", @@ -4984,6 +5090,58 @@ "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", "dev": true }, + "node_modules/sinon": { + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", + "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.2", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/slice-ansi": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", @@ -5357,6 +5515,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", @@ -6062,6 +6230,49 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1" + } + }, + "@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + }, + "dependencies": { + "type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true + } + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true + }, "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", @@ -6138,6 +6349,21 @@ "dev": true, "peer": true }, + "@types/sinon": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz", + "integrity": "sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==", + "dev": true, + "requires": { + "@types/sinonjs__fake-timers": "*" + } + }, + "@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true + }, "@webassemblyjs/ast": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", @@ -8295,6 +8521,12 @@ "minimist": "^1.2.0" } }, + "just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, "klaw": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", @@ -8394,6 +8626,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -8839,6 +9077,27 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "nise": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", + "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.1.0" + }, + "dependencies": { + "path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true + } + } + }, "node-releases": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.8.tgz", @@ -9595,6 +9854,43 @@ "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", "dev": true }, + "sinon": { + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", + "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.2", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "dependencies": { + "diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "slice-ansi": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", @@ -9879,6 +10175,12 @@ "prelude-ls": "~1.1.2" } }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, "type-fest": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", diff --git a/package.json b/package.json index 7bf4487..d485fea 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "homepage": "https://github.com/athombv/node-zigbee-clusters#readme", "devDependencies": { "@athombv/jsdoc-template": "^1.6.1", + "@types/sinon": "^17.0.3", "concurrently": "^5.2.0", "eslint": "^6.8.0", "eslint-config-athom": "^2.1.0", @@ -39,6 +40,7 @@ "jsdoc-ts-utils": "^2.0.0", "mocha": "^10.1.0", "serve": "^14.0.1", + "sinon": "^19.0.2", "watch": "^1.0.2" }, "dependencies": { diff --git a/test/testBasicCluster.js b/test/testBasicCluster.js deleted file mode 100644 index eb8e6eb..0000000 --- a/test/testBasicCluster.js +++ /dev/null @@ -1,162 +0,0 @@ -// eslint-disable-next-line max-classes-per-file,lines-around-directive -'use strict'; - -const assert = require('assert'); - -let { debug } = require('./util'); -const BoundCluster = require('../lib/BoundCluster'); -require('../lib/clusters/basic'); - -debug = debug.extend('test-cluster-basic'); - -describe('basicCluster', function() { - let node; - let basic; - before(function() { - // eslint-disable-next-line global-require - const { loopbackNode } = require('./util'); - node = loopbackNode([ - { - endpointId: 1, - inputClusters: [0], - }, - ]); - basic = node.endpoints[1].clusters['basic']; - }); - it('should fail for unbound cluster', async function() { - try { - await basic.configureReporting({ - zclVersion: { - minInterval: 1234, - maxInterval: 4321, - }, - }); - } catch (e) { - return; - } - throw new Error('didn\'t throw'); - }); - - it('should fail for unimplemented command', async function() { - node.endpoints[1].bind('basic', new BoundCluster()); - - try { - await basic.factoryReset(); - } catch (e) { - return; - } - throw new Error('didn\'t throw'); - }); - - it('should invoke command', async function() { - node.endpoints[1].bind('basic', new class extends BoundCluster { - - async factoryReset() { - debug('factory reset'); - } - - }()); - - await basic.factoryReset(); - }); - - it('should configure reporting', async function() { - node.endpoints[1].bind('basic', new class extends BoundCluster { - - async configureReporting({ reports }) { - assert.equal(reports.length, 1, 'exactly 1 report'); - } - - }()); - - await basic.configureReporting({ - zclVersion: { - minInterval: 10, - maxInterval: 4321, - }, - }); - }); - - it('should read attributes', async function() { - node.endpoints[1].bind('basic', new class extends BoundCluster { - - constructor() { - super(); - this.dateCode = '1234'; - } - - get modelId() { - return 'test'; - } - - get manufacturerName() { - return 'Athom'; - } - - }()); - - const res = await basic.readAttributes(['modelId', 'manufacturerName', 'clusterRevision', 'zclVersion', 'dateCode']); - assert.equal(res.modelId, 'test', 'modelId should be test'); - assert.equal(res.manufacturerName, 'Athom', 'manufacturerName should be test'); - assert.equal(res.clusterRevision, 1, 'clusterRevision should be test'); - assert.equal(res.zclVersion, undefined, 'zclVersion should not be present'); - assert.equal(res.dateCode, '1234', 'dateCode should not be present'); - }); - - it('should write attributes', async function() { - node.endpoints[1].bind('basic', new class extends BoundCluster { - - get modelId() { - return 'test'; - } - - get manufacturerName() { - return 'Athom'; - } - - set modelId(val) { - assert.equal(val, 'test1'); - } - - }()); - - await basic.writeAttributes({ - modelId: 'test1', - }); - }); - - it('should discover attributes', async function() { - node.endpoints[1].bind('basic', new class extends BoundCluster { - - get modelId() { - return 'test'; - } - - get manufacturerName() { - return 'Athom'; - } - - set modelId(val) { - assert.equal(val, 'test1'); - } - - }()); - - const attrs = await basic.discoverAttributes(); - ['modelId', 'manufacturerName'].forEach(a => { - assert(attrs.includes(a), `${a} is missing`); - }); - }); - - it('should discover received commands', async function() { - node.endpoints[1].bind('basic', new class extends BoundCluster { - - async factoryReset() { - debug('factory reset'); - } - - }()); - const cmds = await basic.discoverCommandsReceived(); - assert(cmds.includes('factoryReset')); - }); -}); diff --git a/test/testNode.js b/test/testNode.js new file mode 100644 index 0000000..0ef1455 --- /dev/null +++ b/test/testNode.js @@ -0,0 +1,299 @@ +// eslint-disable-next-line max-classes-per-file,lines-around-directive +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); + +let { debug } = require('./util'); +const Node = require('../lib/Node'); +const BoundCluster = require('../lib/BoundCluster'); +const Endpoint = require('../lib/Endpoint'); +require('../lib/clusters/basic'); +require('../lib/clusters/onOff'); + +debug = debug.extend('test-node'); + +const sandbox = sinon.createSandbox(); + +describe('Node', function() { + let loopbackNode; + let receivingNode; + let sendingNode; + before(function() { + // eslint-disable-next-line global-require + const { loopbackNode: loopbackNodeBuilder } = require('./util'); + loopbackNode = loopbackNodeBuilder([ + { + endpointId: 1, + inputClusters: [0], + }, + ]); + sendingNode = new Node({ + // Forward frames to receiving node + sendFrame: (...args) => receivingNode.handleFrame(...args), + endpointDescriptors: [ + { + endpointId: 1, + inputClusters: [0, 6], + }, + ], + }); + // Override log id for sending node + const sendingNodeLog = (...args) => `sending-node:${args.join(':')}`; + sendingNode.getLogId = sendingNodeLog; + sendingNode.endpoints[1].getLogId = sendingNodeLog; + receivingNode = new Node({ + // Forward frames to sending node + sendFrame: (...args) => sendingNode.handleFrame(...args), + endpointDescriptors: [ + { + endpointId: 1, + inputClusters: [0, 6], + }, + ], + }); + // Override log id for receiving node + const receivingNodeLog = (...args) => `receiving-node:${args.join(':')}`; + receivingNode.getLogId = receivingNodeLog; + receivingNode.endpoints[1].getLogId = receivingNodeLog; + }); + afterEach(function() { + sandbox.restore(); + }); + it('should fail for unbound cluster', async function() { + try { + await loopbackNode.endpoints[1].clusters['basic'].configureReporting({ + zclVersion: { + minInterval: 1234, + maxInterval: 4321, + }, + }); + } catch (e) { + return; + } + throw new Error('didn\'t throw'); + }); + + it('should fail for unimplemented command', async function() { + loopbackNode.endpoints[1].bind('basic', new BoundCluster()); + + try { + await loopbackNode.endpoints[1].clusters['basic'].factoryReset(); + } catch (e) { + return; + } + throw new Error('didn\'t throw'); + }); + + it('should invoke command', async function() { + loopbackNode.endpoints[1].bind('basic', new class extends BoundCluster { + + async factoryReset() { + debug('factory reset'); + } + + }()); + + await loopbackNode.endpoints[1].clusters['basic'].factoryReset(); + }); + + it('should configure reporting', async function() { + loopbackNode.endpoints[1].bind('basic', new class extends BoundCluster { + + async configureReporting({ reports }) { + assert.equal(reports.length, 1, 'exactly 1 report'); + } + + }()); + + await loopbackNode.endpoints[1].clusters['basic'].configureReporting({ + zclVersion: { + minInterval: 10, + maxInterval: 4321, + }, + }); + }); + + it('should read attributes', async function() { + loopbackNode.endpoints[1].bind('basic', new class extends BoundCluster { + + constructor() { + super(); + this.dateCode = '1234'; + } + + get modelId() { + return 'test'; + } + + get manufacturerName() { + return 'Athom'; + } + + }()); + + const res = await loopbackNode.endpoints[1].clusters['basic'].readAttributes(['modelId', 'manufacturerName', 'clusterRevision', 'zclVersion', 'dateCode']); + assert.equal(res.modelId, 'test', 'modelId should be test'); + assert.equal(res.manufacturerName, 'Athom', 'manufacturerName should be test'); + assert.equal(res.clusterRevision, 1, 'clusterRevision should be test'); + assert.equal(res.zclVersion, undefined, 'zclVersion should not be present'); + assert.equal(res.dateCode, '1234', 'dateCode should not be present'); + }); + + it('should respond with read attribute response when disableDefaultResponse is true', async function() { + receivingNode.endpoints[1].bind('basic', new class extends BoundCluster { + + get modelId() { + return 'test'; + } + + }()); + + const readAttributesResponse = await sendingNode.endpoints[1].clusters['basic'].readAttributes(['modelId'], { disableDefaultResponse: true }); + assert.equal(readAttributesResponse.modelId, 'test'); + }); + + it('should not respond with a default response when disableDefaultResponse is true', async function() { + receivingNode.endpoints[1].bind('basic', new class extends BoundCluster { + + get modelId() { + return 'test'; + } + + }()); + + const sendingNodeSendFrameSpy = sandbox.spy(sendingNode, 'sendFrame'); + const receivingNodeSendFrameSpy = sandbox.spy(receivingNode, 'sendFrame'); + + await sendingNode.endpoints[1].clusters['basic'].readAttributes(['modelId'], { disableDefaultResponse: true }); + + // 1 frame sent by sending node, the read attributes command + assert.equal(sendingNodeSendFrameSpy.callCount, 1); + // 1 frame sent by receiving node, the read attributes response + assert.equal(receivingNodeSendFrameSpy.callCount, 1); + + // Parse the received frame + const frame = Endpoint.parseFrame(receivingNodeSendFrameSpy.getCall(0).args[2]); + + // 0x01 is read attributes response + assert.equal(frame.cmdId, 1); + }); + + it('should not respond to a default response frame', async function() { + receivingNode.endpoints[1].bind('onOff', new class extends BoundCluster { + + toggle() { + debug('toggled'); + } + + }()); + + const sendingNodeSendFrameSpy = sandbox.spy(sendingNode, 'sendFrame'); + const receivingNodeSendFrameSpy = sandbox.spy(receivingNode, 'sendFrame'); + + await sendingNode.endpoints[1].clusters['onOff'].toggle(); + // 1 frame sent by sending node, the toggle command + assert.equal(sendingNodeSendFrameSpy.callCount, 1); + // 1 frame sent by receiving node, the toggle command default response + assert.equal(receivingNodeSendFrameSpy.callCount, 1); + + // Parse the received frame + const frame = Endpoint.parseFrame(receivingNodeSendFrameSpy.getCall(0).args[2]); + + // 0x01 is read attributes response + assert.equal(frame.cmdId, 11); + }); + + it('should respond with default response error when frame could not be handled', async function() { + receivingNode.endpoints[1].bind('onOff', new class extends BoundCluster {}()); + + const sendingNodeHandleFrameSpy = sandbox.spy(sendingNode, 'handleFrame'); + + // Execute toggle and expect a failure as the command is not implemented + await assert.rejects(sendingNode.endpoints[1].clusters['onOff'].toggle(), { message: 'FAILURE' }); + + // 1 frame received by sending node, the default response error + assert.equal(sendingNodeHandleFrameSpy.callCount, 1); + + // Parse the received frame + const frame = Endpoint.parseFrame(sendingNodeHandleFrameSpy.getCall(0).args[2]); + assert.equal(frame.cmdId, 0x0B); + assert.equal(frame.data[1], 0x01); // Error status + }); + + it('should not respond to a group cast frame', async function() { + const toggledSpy = sandbox.spy(); + receivingNode.endpoints[1].bind('onOff', new class extends BoundCluster { + + toggle() { + toggledSpy(); + } + + }()); + + const receivingNodeSendFrameSpy = sandbox.spy(receivingNode, 'sendFrame'); + + // Send toggle command to onOff cluster with group id + await receivingNode.handleFrame(1, 6, Buffer.from([0x01, 0x01, 0x02]), { groupId: 0x01 }); + assert.equal(toggledSpy.callCount, 1); + // 0 frames sent by receiving node, no response to group commands + assert.equal(receivingNodeSendFrameSpy.callCount, 0); + }); + + it('should write attributes', async function() { + loopbackNode.endpoints[1].bind('basic', new class extends BoundCluster { + + get modelId() { + return 'test'; + } + + get manufacturerName() { + return 'Athom'; + } + + set modelId(val) { + assert.equal(val, 'test1'); + } + + }()); + + await loopbackNode.endpoints[1].clusters['basic'].writeAttributes({ + modelId: 'test1', + }); + }); + + it('should discover attributes', async function() { + loopbackNode.endpoints[1].bind('basic', new class extends BoundCluster { + + get modelId() { + return 'test'; + } + + get manufacturerName() { + return 'Athom'; + } + + set modelId(val) { + assert.equal(val, 'test1'); + } + + }()); + + const attrs = await loopbackNode.endpoints[1].clusters['basic'].discoverAttributes(); + ['modelId', 'manufacturerName'].forEach(a => { + assert(attrs.includes(a), `${a} is missing`); + }); + }); + + it('should discover received commands', async function() { + loopbackNode.endpoints[1].bind('basic', new class extends BoundCluster { + + async factoryReset() { + debug('factory reset'); + } + + }()); + const cmds = await loopbackNode.endpoints[1].clusters['basic'].discoverCommandsReceived(); + assert(cmds.includes('factoryReset')); + }); +});