diff --git a/.prettierignore b/.prettierignore index a3ed78cc..fad36131 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,3 @@ build index.d.ts *.pdf .claude - -# Literal formatting is the content here, and Prettier mangles the M* emphasis markers. -AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 56ac6c2c..5fee00da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,518 +1,59 @@ # AGENTS.md -This file provides guidance to AI agents when working with code in this repository. +Guidance for AI agents working in this repository. [README.md](README.md) documents the library, +contributing and releasing; this file covers what is not already obvious from the code. -## Commands +## Setup and commands -```bash -npm test # Run all tests (node:test) -npm run lint # ESLint (flat config in eslint.config.js) -npm run format:check # Prettier check, run in CI next to lint -npm run format:write # Apply Prettier; run before committing -npm run generate-types # Regenerate index.d.ts from the cluster definitions -npm run build # Generate JSDoc documentation into build/ -npm run docs # Build the docs and open them in the browser (macOS `open`) -``` +`.nvmrc` pins the Node version and every workflow reads that same file. Run `nvm use` before +anything else; the machine default is likely newer. -The docs are a static site that works straight from disk, no server needed. Off macOS, run -`npm run build` and open `build/index.html` in a browser. - -Run single test file: ```bash +npm test node --test test/onOff.js +npm run lint # ESLint, flat config in eslint.config.js +npm run format:check +npm run format:write # run this before committing +npm run generate-types # regenerate index.d.ts after changing cluster definitions +npm run build # JSDoc site into build/, open build/index.html in a browser ``` -## Pull Requests and Releases - -Pull request titles use Conventional Commits, `type(scope): Description` with a capital letter after the prefix. The prefix decides where the change lands in the release notes: `feat` under New features, `fix` and `perf` under Fixes, a `!` before the colon under Breaking changes, and `build`, `chore`, `ci`, `docs`, `refactor`, `style` and `test` are left out. Use `feat` or `fix` for anything someone using the library would notice. How a release is cut is described under [Releasing](README.md#releasing). - -## Architecture - -Zigbee Cluster Library (ZCL) implementation for Node.js, designed for Homey's Zigbee stack. - -### Core Classes - -- **ZCLNode** (`lib/Node.js`) - Entry point. Wraps Homey's ZigBeeNode, manages endpoints, routes incoming frames. -- **Endpoint** (`lib/Endpoint.js`) - Represents device endpoint. Contains `clusters` (client) and `bindings` (server). Routes frames to appropriate cluster. -- **Cluster** (`lib/Cluster.js`) - Base class for all clusters. Handles frame parsing, command execution, attribute operations. Commands auto-generate methods via `_addPrototypeMethods`. -- **BoundCluster** (`lib/BoundCluster.js`) - Server-side cluster for receiving commands from remote nodes. - -### Data Flow - -``` -ZCLNode.handleFrame() → Endpoint.handleFrame() → Cluster/BoundCluster.handleFrame() -Cluster.sendCommand() → Endpoint.sendFrame() → ZCLNode.sendFrame() -``` - -### Cluster Implementation Pattern - -Each cluster in `lib/clusters/` follows: -1. Define `ATTRIBUTES` object with `{id, type}` using `ZCLDataTypes` -2. Define `COMMANDS` object with `{id, args?}` -3. Extend `Cluster` with static getters: `ID`, `NAME`, `ATTRIBUTES`, `COMMANDS` -4. Call `Cluster.addCluster(MyCluster)` to register - -Example: `lib/clusters/onOff.js` - -### Key Types - -- `ZCLDataTypes` - Primitive types (uint8, bool, enum8, etc.) from `@athombv/data-types` -- `ZCLStruct` - Composite types for command arguments -- `CLUSTER` constant - Maps cluster names to `{ID, NAME, ATTRIBUTES, COMMANDS}` - -### Cluster ID References - -Prefer `Cluster.ID` over hardcoded numbers: -```javascript -// Good -const OnOffCluster = require('../lib/clusters/onOff'); -inputClusters: [OnOffCluster.ID] - -// Avoid -inputClusters: [6] -inputClusters: [0x0006] -``` - -### Test Pattern - -Tests run on `node:test`, so `describe`, `it` and `mock` are imported, not globals. A -callback-style test takes the test context first: `it('...', function(t, done) { ... })`. -With `function(done)` node:test sees a synchronous test, passes it immediately and never -runs the assertions. - -Tests use mock nodes from `test/util/mockNode.js`: - -**Single node with loopback** (command → same node's BoundCluster): -```javascript -const { describe, it } = require('node:test'); -const { createMockNode } = require('./util'); -const OnOffCluster = require('../lib/clusters/onOff'); - -const node = createMockNode({ - loopback: true, - endpoints: [{ endpointId: 1, inputClusters: [OnOffCluster.ID] }], -}); - -node.endpoints[1].bind('onOff', new (class extends BoundCluster { - async setOn() { /* handle command */ } -})()); - -await node.endpoints[1].clusters.onOff.setOn(); // loops back to BoundCluster -``` - -**Connected node pair** (node A sends → node B receives): -```javascript -const { createConnectedNodePair } = require('./util'); - -const [sender, receiver] = createConnectedNodePair( - { endpoints: [{ endpointId: 1, inputClusters: [6] }] }, - { endpoints: [{ endpointId: 1, inputClusters: [6] }] }, -); - -receiver.endpoints[1].bind('onOff', new BoundCluster()); -await sender.endpoints[1].clusters.onOff.toggle(); -``` - -**Server-to-client notifications** (device → controller) still require manual frames: -```javascript -const { ZCLStandardHeader } = require('../lib/zclFrames'); - -node.endpoints[1].clusters.iasZone.onZoneStatusChangeNotification = data => { ... }; - -const frame = new ZCLStandardHeader(); -frame.cmdId = IASZoneCluster.COMMANDS.zoneStatusChangeNotification.id; -frame.frameControl.directionToClient = true; -frame.frameControl.clusterSpecific = true; -frame.data = Buffer.from([...]); - -node.handleFrame(1, IASZoneCluster.ID, frame.toBuffer(), {}); -``` - -**Preset mock devices** for common sensor types: -```javascript -const { MOCK_DEVICES } = require('./util'); - -const sensor = MOCK_DEVICES.motionSensor(); -const boundCluster = sensor.endpoints[1].bindings.iasZone; -``` - -## Key Files - -- `index.js` - Public exports -- `lib/clusters/index.js` - All cluster exports + `CLUSTER` constant -- `lib/zclTypes.js` - ZCL data types -- `lib/zclFrames.js` - ZCL frame parsing/serialization - ---- - -## Adding/Updating Cluster Definitions - -Reusable guide for adding new clusters or updating existing cluster definitions. - -### Source Reference - -- **Spec PDF**: `docs/zigbee-cluster-specification-r8.pdf` -- **Extract text**: `pdftotext docs/zigbee-cluster-specification-r8.pdf -` -- **Cluster files**: `lib/clusters/*.js` - ---- - -### File Structure Template - -```javascript -'use strict'; - -const Cluster = require('../Cluster'); -const { ZCLDataTypes } = require('../zclTypes'); - -// Reusable enum definitions (if needed) -const EXAMPLE_ENUM = ZCLDataTypes.enum8({ - value1: 0, - value2: 1, -}); - -// ============================================================================ -// Server Attributes -// ============================================================================ -const ATTRIBUTES = { - // Section Name (0x0000 - 0x000F) - - // Description from spec, copied 1-on-1. - // Multi-line if needed, wrapped at 100 chars. - attrName: { id: 0x0000, type: ZCLDataTypes.uint8 }, // Mandatory -}; - -// ============================================================================ -// Commands -// ============================================================================ -const COMMANDS = { - // --- Client to Server Commands --- - - // Description from spec. - // Mandatory - commandName: { - id: 0x0000, - args: { - argName: ZCLDataTypes.uint8, - }, - response: { - id: 0x0000, - args: { status: ZCLDataTypes.uint8 }, - }, - }, - - // --- Server to Client Commands --- - - // Description from spec. - // Optional - notificationName: { - id: 0x0020, // 32 - direction: Cluster.DIRECTION_SERVER_TO_CLIENT, - args: { - argName: ZCLDataTypes.uint8, - }, - }, -}; - -class ExampleCluster extends Cluster { - static get ID() { - return 0x0000; // Add decimal comment if > 9, e.g.: return 0x0101; // 257 - } - - static get NAME() { - return 'example'; - } +`npm test` globs `test/*.js` only, so a test file must sit directly in `test/`; `test/util/` holds +the harness. - static get ATTRIBUTES() { - return ATTRIBUTES; - } +`index.d.ts` is generated from the cluster definitions and committed. Never edit it by hand; run +`npm run generate-types`, which CI does too. - static get COMMANDS() { - return COMMANDS; - } -} +There is no `CHANGELOG.md`. Release notes are generated from the titles of merged pull requests, so +the title of a pull request is what users end up reading, see +[Release notes](README.md#release-notes). -Cluster.addCluster(ExampleCluster); -module.exports = ExampleCluster; -``` - ---- - -### Comment Format Rules - -1. **Description placement**: ABOVE the attribute/command -2. **M/O marker placement**: - - Single-line: at END of line (`attrName: { ... }, // Mandatory`) - - Multi-line: on its own line ABOVE the key (`// Mandatory` then `attrName: {`), - below the description if there is one. Not on the opening brace: Prettier pulls a - comment there inside the braces, where it reads as if it belongs to `id`. - - NEVER on closing brace -3. **Copy exactly**: Text from spec, 1-on-1 -4. **Skip if >5 sentences**: Skip and only refer to section in spec -5. **Line wrap**: Respect 100 char limit (Prettier `printWidth`); run `npm run format:write` -6. **M/O source for attrs**: Server-side column in spec table -7. **M/O source for cmds**: - - Server "receives" → server-side M/O - - Server "generates" → client-side M/O -8. **Conditional (M*)**: Use `// Conditional¹` with footnote for attrs/cmds marked "M*" in spec - - Reason on new line below: `// ¹ Reason here` - - Shared reasons use same footnote number - -#### Example - -```javascript -currentLevel: { id: 0x0000, type: ZCLDataTypes.uint8 }, // Mandatory -pirOccupiedToUnoccupiedDelay: { id: 0x0010, type: ZCLDataTypes.uint16 }, // 16, Conditional¹ -pirUnoccupiedToOccupiedDelay: { id: 0x0011, type: ZCLDataTypes.uint16 }, // 17, Conditional¹ -// ¹ PIR sensor type supported -``` - ---- - -### Attribute Definition Rules - -| Field | Format | Notes | -|-------|--------|-------| -| `id` | Hex (`0x0000`) | Always 4-digit format (0x0000), lowercase digits; add decimal comment if > 9 | -| `type` | `ZCLDataTypes.*` | See type reference below | -| M/O | Inline comment | `// Mandatory`, `// Optional`, or `// Conditional` | - -#### Hex with Decimal Comments - -For hex values > 9 (where hex differs from decimal), add decimal in comment: -```javascript -id: 0x000a, // 10 -id: 0x0010, // 16 -id: 0x0100, // 256 -``` - -For values 0-9, no decimal comment needed: -```javascript -id: 0x0000, -id: 0x0009, -``` - -For multi-line definitions, decimal goes on the `id:` line, M/O above the key: -```javascript -// Optional -operatingMode: { - id: 0x0025, // 37 - type: ZCLDataTypes.enum8({ - normal: 0, - vacation: 1, - }), -}, -``` - -#### Section Comments - -Group attrs by function with section headers: -```javascript -// Section Name (0x0000 - 0x000F) -attr1: { ... }, -attr2: { ... }, - -// Another Section (0x0010 - 0x001F) -attr3: { ... }, -``` - ---- - -### Command Definition Rules - -| Field | Required | Notes | -|-------|----------|-------| -| `id` | Yes | Always 4-digit hex format (0x0000), lowercase digits; add decimal comment if > 9 | -| `args` | If has params | Object with typed fields | -| `response` | If expects response | Has own `id` and `args` | -| `encodeMissingFieldsBehavior` | Controls the behavior of missing fields when encoding the struct | Set to `skip` if some fields are optional, omit if all fields are mandatory | -| `direction` | For server→client | `Cluster.DIRECTION_SERVER_TO_CLIENT` | - -#### Command Sections - -```javascript -// --- Client to Server Commands --- -lockDoor: { id: 0x0000, ... }, // Mandatory - -// --- Server to Client Commands --- -// Optional -operationEventNotification: { - id: 0x0020, // 32 - direction: Cluster.DIRECTION_SERVER_TO_CLIENT, - ... -}, -``` - -#### Command Direction Rules - -Focus on **client→server commands** with inline `response:` when applicable: -```javascript -lockDoor: { - id: 0x0000, - args: { pinCode: ZCLDataTypes.octstr }, - response: { - id: 0x0000, - args: { status: ZCLDataTypes.uint8 }, - }, -}, -``` - -**Server→client commands** should only be added as standalone commands when they can be sent **unsolicited** (not just as a response to a request). Examples: -- Unsolicited notifications (e.g., `operationEventNotification` for door locks) — add as standalone -- Responses that the server can also send unsolicited (e.g., `upgradeEndResponse` for synchronized upgrades) — add as standalone -- Responses that are **only** sent in reply to a request (e.g., `queryNextImageResponse`) — do **NOT** add as standalone; they are already covered by the inline `response:` on the request command -- These require `direction: Cluster.DIRECTION_SERVER_TO_CLIENT` - -#### Optional fields -Some commands contain optional fields. If this is the case add `encodeMissingFieldsBehavior: 'skip'` to the command definition. - -```javascript -lockDoor: { - id: 0x0000, - encodeMissingFieldsBehavior: 'skip', - args: { pinCode: ZCLDataTypes.octstr }, - response: { - id: 0x0000, - args: { status: ZCLDataTypes.uint8 }, - }, -} -``` - -#### Command variants -Some commands might change their structure based on a status field. If this is the case, add `encodeMissingFieldsBehavior: 'skip'`. And add a comment above each field -when the field should be present. - -**IMPORTANT**: This applies to `response:` structs too, not just the top-level command. If a response has variant fields (e.g., different fields depending on a status value), add `encodeMissingFieldsBehavior: 'skip'` to the response and include all variant fields with comments indicating when each group is present. - -```javascript -imageBlockRequest: { - id: 0x0003, - args: { ... }, - response: { - id: 0x0005, - encodeMissingFieldsBehavior: 'skip', - args: { - status: ZCLDataTypes.enum8Status, - // When status is SUCCESS - manufacturerCode: ZCLDataTypes.uint16, - fileOffset: ZCLDataTypes.uint32, - imageData: ZCLDataTypes.buffer, - // When status is WAIT_FOR_DATA - currentTime: ZCLDataTypes.uint32, - requestTime: ZCLDataTypes.uint32, - }, - }, -} -``` - ---- - -### ZCLDataTypes Reference - -#### Primitives -- `bool`, `uint8`, `uint16`, `uint24`, `uint32`, `uint48`, `uint64` -- `int8`, `int16`, `int24`, `int32` -- `string`, `octstr` - -#### Enums -```javascript -ZCLDataTypes.enum8({ - valueName: 0, - anotherValue: 1, -}) -``` - -#### Bitmaps -```javascript -ZCLDataTypes.map8('bit0', 'bit1', 'bit2') -ZCLDataTypes.map16('bit0', 'bit1', ...) -ZCLDataTypes.map64(...) -``` - -#### Arrays -```javascript -ZCLDataTypes.Array0(ZCLDataTypes.uint8) -ZCLDataTypes.Array8(...) -``` - -#### Reusable Enums -Define at module level if used multiple times: -```javascript -const USER_STATUS_ENUM = ZCLDataTypes.enum8({ - available: 0, - occupied: 1, -}); - -// Then use in commands: -args: { userStatus: USER_STATUS_ENUM } -``` - -#### Reusable Bitmaps -Same pattern for bitmaps used multiple times: -```javascript -const ALARM_MASK = ZCLDataTypes.map8( - 'generalHardwareFault', - 'generalSoftwareFault', - 'reserved2', - 'reserved3', -); - -// Then use in attributes/commands: -alarmMask: { id: 0x0010, type: ALARM_MASK }, -``` - ---- +## Architecture -### Workflow: Adding/Updating a Cluster +A Zigbee Cluster Library (ZCL) implementation for Homey's Zigbee stack. -#### 1. Extract Spec Section -```bash -pdftotext docs/zigbee-cluster-specification-r8.pdf - | grep -A 500 "X.Y.Z Cluster Name" ``` - -#### 2. Identify Elements -From spec tables, extract: -- Cluster ID and Name -- All attributes (ID, name, type, M/O) -- All commands (ID, name, args, direction, M/O) -- Descriptions (≤5 sentences) - -#### 3. Create/Update File -- Use template above -- Follow naming: `lib/clusters/clusterName.js` -- Export in `lib/clusters/index.js` - -#### 4. Validate -```bash -npm run lint -npm test -npm run build +in: ZCLNode.handleFrame() -> Endpoint.handleFrame() -> Cluster/BoundCluster.handleFrame() +out: generated command method -> Cluster.sendFrame() -> Endpoint.sendFrame() -> ZCLNode.sendFrame() ``` ---- +`ZCLNode` (`lib/Node.js`) wraps Homey's ZigBeeNode and owns the endpoints. An `Endpoint` +(`lib/Endpoint.js`) holds both `clusters`, the client side that sends commands, and `bindings`, the +server side that receives them. `Cluster` (`lib/Cluster.js`) turns every entry in a cluster's +`COMMANDS` into a prototype method through `_addPrototypeMethods`, which is why cluster definitions +are plain data rather than code, and why no `sendCommand` method exists to grep for. +`BoundCluster` (`lib/BoundCluster.js`) is the server counterpart. -### Checklist for Each Cluster +Cluster definitions live in `lib/clusters/*.js`, register themselves with `Cluster.addCluster()` and +are re-exported from `lib/clusters/index.js` together with the `CLUSTER` constant. Adding or +updating one is described in [docs/adding-clusters.md](docs/adding-clusters.md). -- [ ] Cluster ID correct (hex in class, matches spec) -- [ ] Cluster NAME matches spec (camelCase) -- [ ] All mandatory attrs present with `// Mandatory` -- [ ] All mandatory cmds present with `// Mandatory` -- [ ] Conditional attrs/cmds marked `// Conditional` (M* in spec) -- [ ] Optional attrs/cmds marked `// Optional` -- [ ] Descriptions copied from spec (≤5 sentences) -- [ ] Section comments group related attrs -- [ ] Client/server cmd sections separated -- [ ] Server→client cmds have `direction` field -- [ ] Responses defined where applicable -- [ ] Reusable enums/bitmaps extracted if used 2+ times -- [ ] Hex IDs used consistently (with decimal comments if > 9) -- [ ] Lint passes -- [ ] Tests pass +Refer to a cluster id as `OnOffCluster.ID`, never as `6` or `0x0006`. ---- +## Tests -### Reference Examples +Tests run on `node:test`, so `describe`, `it` and `mock` are imported, not globals. -- **Best documented**: `lib/clusters/doorLock.js` -- **Simple attrs only**: `lib/clusters/metering.js` -- **Color/enum heavy**: `lib/clusters/colorControl.js` +Every test that needs a node uses `createVirtualDevice()` from `test/util/virtualDevice.js`. +Read [docs/testing.md](docs/testing.md) before writing a test, above all its rules: an assertion +inside a `BoundCluster` or cluster handler becomes an error Default Response instead of a failure. diff --git a/README.md b/README.md index 38fd07aa..7f5b45a5 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,8 @@ The workflow commits updated types back to that branch if changes are detected. Great if you'd like to contribute to this project, a few things to take note of before submitting a PR: - This project enforces ESLint and Prettier: check with `npm run lint` and `npm run format:check`, and fix formatting with `npm run format:write`. -- Tests use the built-in node:test runner, see the [test](https://github.com/athombv/node-zigbee-clusters/blob/production/test) directory. +- Tests use the built-in node:test runner and one virtual device harness, see [Writing tests](docs/testing.md). +- Adding or updating a cluster definition follows its own conventions, see [Adding or updating a cluster definition](docs/adding-clusters.md). - Pull request titles follow [Conventional Commits](https://www.conventionalcommits.org/), for example `fix(onOff): Handle a missing attribute`. The title becomes a line in the release notes, so write it for someone using the library. See [Release notes](#release-notes). - [GitHub Action workflows](https://github.com/athombv/node-zigbee-clusters/blob/production/.github/workflows) run ESLint, Prettier and the tests, and publish releases. diff --git a/docs/adding-clusters.md b/docs/adding-clusters.md new file mode 100644 index 00000000..d3d6d031 --- /dev/null +++ b/docs/adding-clusters.md @@ -0,0 +1,135 @@ +# Adding or updating a cluster definition + +A cluster definition in `lib/clusters/*.js` is data, not code: ids, types, and the specification +text that explains them. `Cluster._addPrototypeMethods` turns each entry in `COMMANDS` into a method +at registration time, so there is nothing to implement beyond the definition itself. + +This page describes the conventions those definitions follow. See +[Contributing](../README.md#contributing) for the checks every pull request has to pass. + +## Spec source + +Three revisions are checked in as `docs/zigbee-cluster-specification-r{6,7,8}.pdf`. Write +definitions against r8. r6 and r7 are there to look up what an older revision said about a +definition that devices in the field still implement. + +Each cluster has its own numbered section. From its tables you need the cluster id and name, every +attribute (id, name, type, mandatory or optional) and every command (id, name, arguments, direction, +mandatory or optional). + +## Writing the file + +The clearest way in is an existing cluster: + +- `lib/clusters/doorLock.js` carries spec comments, mandatory and optional markers and conditional + footnotes, so it shows what a fully documented cluster looks like. +- `lib/clusters/ota.js` shows optional and variant command fields. +- `lib/clusters/onOff.js` is a minimal definition with no spec comments, which is what most of the + older clusters look like. + +Name the file after the cluster in camelCase, call `Cluster.addCluster()` at the bottom, and export +it from `lib/clusters/index.js`. + +Data types come from `@athombv/data-types`; `lib/zclTypes.js` re-exports them as `ZCLDataTypes` and +adds `enum8Status`. The set is wider than the handful most clusters use: + +```bash +node -e "console.log(Object.keys(require('./lib/zclTypes').ZCLDataTypes).join(' '))" +``` + +Define an enum or bitmap at module level as soon as two definitions share it. + +## Ids + +Both `static get ID()` and the attribute and command ids are written in a mix of hex and decimal +across the repo. Leave a file's existing style alone rather than reformatting around your change. + +New definitions use four-digit lowercase hex for attribute and command ids, with the decimal in a +comment where the two differ: + +```javascript +id: 0x0000, +id: 0x000a, // 10 +id: 0x0100, // 256 +``` + +For a multi-line definition the decimal goes on the `id:` line. + +## Spec comments + +The description from the specification goes above the attribute or command, copied as written and +wrapped to the Prettier width. When it runs past five sentences, name the spec section instead. + +Each entry carries a literal `// Mandatory`, `// Optional` or `// Conditional` comment. The marker +goes at the end of the line for a single-line definition, and on its own line directly above the key +for a multi-line one. Not on the opening brace, where Prettier pulls it inside and it reads as if it +belongs to `id`, and not on the closing brace. + +For attributes the marker comes from the server-side column of the spec table. For commands, server +"receives" is the server-side value and server "generates" the client-side one. An entry the spec +marks `M*` is conditional, with the condition as a numbered footnote below the group: + +```javascript +numberOfPINUsersSupported: { id: 0x0012, type: ZCLDataTypes.uint16 }, // 18, Conditional¹ +numberOfRFIDUsersSupported: { id: 0x0013, type: ZCLDataTypes.uint16 }, // 19, Conditional² +// ¹ PIN credential support +// ² RFID credential support +``` + +Most of the older clusters carry no markers at all, so adding one attribute to such a file does not +mean backfilling the rest. + +## Commands + +Client-to-server commands take an inline `response` where the spec has one: + +```javascript +lockDoor: { + id: 0x0000, + args: { pinCode: ZCLDataTypes.octstr }, + response: { + id: 0x0000, + args: { status: ZCLDataTypes.uint8 }, + }, +}, +``` + +A server-to-client command is defined standalone, carrying +`direction: Cluster.DIRECTION_SERVER_TO_CLIENT`, only when the server can send it unsolicited: a +notification such as `operationEventNotification`, or a response that doubles as one such as +`upgradeEndResponse`. A response that only ever follows a request, such as +`queryNextImageResponse`, is already covered by the inline `response` and must not be repeated. + +### Optional and variant fields + +`encodeMissingFieldsBehavior: 'skip'` belongs on any struct where some fields are optional, and is +left off when every field is mandatory. This holds for a `response` struct as much as for the +command itself. When the fields present depend on another field's value, list them all and comment +each group: + +```javascript +args: { + status: ZCLDataTypes.enum8Status, + // When status is SUCCESS + fileOffset: ZCLDataTypes.uint32, + imageData: ZCLDataTypes.buffer, + // When status is WAIT_FOR_DATA + currentTime: ZCLDataTypes.uint32, + requestTime: ZCLDataTypes.uint32, +}, +``` + +`IMAGE_BLOCK_RESPONSE` in `lib/clusters/ota.js` is the worked version, with every field the spec +lists. It sits at module level because `imageBlockRequest`, `imagePageRequest` and the standalone +`imageBlockResponse` all share it, the same rule as for enums and bitmaps above. + +## Testing the definition + +A definition is worth a test when it encodes or decodes something a plain attribute list does not: +command arguments, a variant payload, a notification. `test/onOff.js` is the smallest example, +`test/ota.js` covers variant fields. [Writing tests](testing.md) explains the test harness. + +## Before opening a pull request + +Run `npm run generate-types` on top of the usual lint, format and test commands. `index.d.ts` is +generated from the cluster definitions and committed, so changing a definition changes it too. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 00000000..672edeab --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,48 @@ +# Writing tests + +## The idea + +A test plays both sides of a Zigbee conversation. `createVirtualDevice()` in +[`test/util/virtualDevice.js`](../test/util/virtualDevice.js) creates two nodes linked in memory: + +- `zclNode`, the code under test, as a Homey driver gets it. +- `virtualDevice`, the device it talks to, scripted by the test with `BoundCluster`s. + +Both run the library's real encoding, parsing and response matching; only the radio is replaced. +The link behaves like Homey's stack where it matters for this library: frames arrive after +`sendFrame` resolves, so response timeouts run, and `zclNode` cannot send more than 82 bytes. The +link also logs every frame and lets a test drop, rewrite or inject frames, which is how a test makes +a device misbehave in ways real hardware rarely does. + +The options and return values are documented on the functions in +[`test/util/virtualDevice.js`](../test/util/virtualDevice.js). + +## Examples + +| To test | See | +| ----------------------------------------------------------- | ------------------------------------ | +| A command or attribute read | `test/onOff.js` | +| The bytes on the wire against the specification | `test/colorControl.js` | +| A report or notification from the device | `test/attributeReport.js` | +| A device that answers wrongly, or never; known bugs as todo | `test/virtualDevice.js` | +| Colliding TSNs, duplicate responses, failed sends | `test/testClusterResponseTimeout.js` | +| Group and broadcast frames | `test/testNode.js` | +| Homey serving the device, such as OTA | `test/ota.js` | +| A cluster with its own data type | `test/xiaomiLifeline.js` | + +## Rules + +A test is only worth having if it fails when the code it covers breaks. + +- **Never assert inside a handler.** The endpoint turns a thrown assertion into an error Default + Response, so the test may pass anyway. Store what the handler got and assert after the `await`. +- **No `done` callbacks.** Await the command, or `settle()`. +- **`await settle()`** before asserting that something did not happen, or on what a frame from the + device triggered. +- **Fake time** with `t.mock.timers`, never wait for a real timeout. +- **One device per test**, created inside the `it`. +- **Assert specific errors and real bytes.** A round trip through the same definition passes when + the definition is wrong. +- **Prove the failure path.** Break the library code under test and check the test fails. +- **A known bug** is a test for the correct behavior marked `{ todo: '...' }`. Check it fails on the + bug, not on the test; the fix removes the `todo`. diff --git a/docs/zigbee-cluster-specification-r6.pdf b/docs/zigbee-cluster-specification-r6.pdf new file mode 100644 index 00000000..41906dd1 Binary files /dev/null and b/docs/zigbee-cluster-specification-r6.pdf differ diff --git a/docs/zigbee-cluster-specification-r7.pdf b/docs/zigbee-cluster-specification-r7.pdf new file mode 100644 index 00000000..6cda958e Binary files /dev/null and b/docs/zigbee-cluster-specification-r7.pdf differ diff --git a/lib/Cluster.js b/lib/Cluster.js index d59953fc..14277f45 100644 --- a/lib/Cluster.js +++ b/lib/Cluster.js @@ -15,6 +15,10 @@ const { } = require('./zclFrames'); const { ZCLStruct, ZCLDataTypes } = require('./zclTypes'); +// 30 ids fit a request frame. The response often does not: the device cuts it short, and the ids +// it left out are asked for again in the next request. +const MAX_ATTRIBUTE_IDS_PER_READ = 30; + const GLOBAL_ATTRIBUTES = { clusterRevision: { id: 0xfffd, type: ZCLDataTypes.uint16 }, attributeReportingStatus: { @@ -474,18 +478,20 @@ class Cluster extends EventEmitter { const resultObj = {}; while (attrIds.size) { + const requestIds = [...attrIds].slice(0, MAX_ATTRIBUTE_IDS_PER_READ); + // Check if command should get manufacturerSpecific flag - const manufacturerId = this._checkForManufacturerSpecificAttributes(Array.from(attrIds)); + const manufacturerId = this._checkForManufacturerSpecificAttributes(requestIds); debug( this.logId, 'read attributes', - [...attrIds], + requestIds, manufacturerId ? `manufacturer specific id ${manufacturerId}` : '', ); const response = await super.readAttributes( { - attributes: [...attrIds], + attributes: requestIds, manufacturerId, }, opts, @@ -494,17 +500,25 @@ class Cluster extends EventEmitter { debug(this.logId, 'read attributes result', response); const { attributeArrayStatusDataType } = this.constructor; const result = attributeArrayStatusDataType.fromBuffer(response.attributes, 0); - if (!result.length) break; - - result.forEach((a) => { - attrIds.delete(a.id); - if (a.status === 'SUCCESS') { - resultObj[idToName[a.id]] = a.value; - if (numericIds.has(a.id) && idToName[a.id] !== a.id) { - resultObj[a.id] = a.value; + + const remaining = attrIds.size; + result.forEach((record) => { + // Devices do answer with attributes that were not asked for. Dropping those keeps them + // out of the result, where they would land under an undefined key. + if (!attrIds.delete(record.id)) return; + if (record.status === 'SUCCESS') { + resultObj[idToName[record.id]] = record.value; + if (numericIds.has(record.id) && idToName[record.id] !== record.id) { + resultObj[record.id] = record.value; } } }); + + // Asking again would repeat the request that just made no progress. + if (attrIds.size === remaining) { + debug(this.logId, 'read attributes gave up, unanswered:', [...attrIds]); + break; + } } return resultObj; @@ -946,7 +960,7 @@ class Cluster extends EventEmitter { attributes = this.constructor.attributeArrayDataType.fromBuffer(attributes, 0); // Only expose fields chosen here, not the stack's frame meta const reportMeta = { transactionSequenceNumber: frame.trxSequenceNumber }; - attributes.forEach((attr) => this.emit(`attr.${attr.name}`, attr.value, reportMeta)); + attributes.forEach((attr) => this.emit(`attr.${attr.name ?? attr.id}`, attr.value, reportMeta)); } /** diff --git a/lib/Endpoint.js b/lib/Endpoint.js index fa273bae..ddb2dc87 100644 --- a/lib/Endpoint.js +++ b/lib/Endpoint.js @@ -81,7 +81,7 @@ class Endpoint extends EventEmitter { */ unbind(clusterName) { const OutputCluster = Cluster.getCluster(clusterName); - this.bindings[OutputCluster.NAME] = new OutputCluster(this, OutputCluster); + delete this.bindings[OutputCluster.NAME]; } /** diff --git a/lib/constants.js b/lib/constants.js index dcafbf53..9bf9973f 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -90,8 +90,15 @@ const IAS_ZONE_TYPE = { INVALID_ZONE_TYPE: 0xffff, }; +/** + * Longest ZCL frame, header included, that Homey's Zigbee stack sends; it rejects a longer one. + * Raise it here when a stack allows larger frames. + */ +const MAX_ZCL_FRAME_BYTES = 82; + module.exports = { ZIGBEE_PROFILE_ID, ZIGBEE_DEVICE_ID, IAS_ZONE_TYPE, + MAX_ZCL_FRAME_BYTES, }; diff --git a/package-lock.json b/package-lock.json index 74f01c18..ed6156a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,20 @@ "taffydb": "^2.7.3" } }, + "node_modules/@athombv/jsdoc-template/node_modules/taffydb": { + "name": "@jsdoc/salty", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.12.tgz", + "integrity": "sha512-TuB0x50EoAvEX/UEWITd8Mkn3WhiTjSvbTMCLj0BhsQEl5iUzjXdA0bETEVpTk+5TGTLR6QktI9H4hLviVeaAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.18.1" + }, + "engines": { + "node": ">=v12.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -2398,13 +2412,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/taffydb": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/taffydb/-/taffydb-2.7.3.tgz", - "integrity": "sha512-GQ3gtYFSOAxSMN/apGtDKKkbJf+8izz5YfbGqIsUc7AMiQOapARZ76dhilRY2h39cynYxBFdafQo5HUL5vgkrg==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", diff --git a/package.json b/package.json index 709ac691..a8b44fc4 100644 --- a/package.json +++ b/package.json @@ -49,5 +49,8 @@ "dependencies": { "@athombv/data-types": "^1.3.0", "debug": "^4.1.1" + }, + "overrides": { + "taffydb": "npm:@jsdoc/salty@^0.2.12" } } diff --git a/test/attributeReport.js b/test/attributeReport.js index 79a86e77..455b9bf5 100644 --- a/test/attributeReport.js +++ b/test/attributeReport.js @@ -1,36 +1,34 @@ 'use strict'; -const { describe, it } = require('node:test'); +const { describe, it, mock } = require('node:test'); const assert = require('node:assert'); const OnOffCluster = require('../lib/clusters/onOff'); -const { createMockNode } = require('./util'); -const { ZCLStandardHeader } = require('../lib/zclFrames'); +const { createVirtualDevice, frameToClient } = require('./util/virtualDevice'); describe('Attribute report', function () { - it('should emit the transaction sequence number with the attribute value', function (t, done) { - const node = createMockNode({ - endpoints: [ - { - endpointId: 1, - inputClusters: [OnOffCluster.ID], - }, - ], + it('should emit the transaction sequence number with the attribute value', async function () { + const { zclNode, virtualDevice, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [OnOffCluster.ID] }], }); + const onOnOff = mock.fn(); + zclNode.endpoints[1].clusters.onOff.on('attr.onOff', onOnOff); - node.endpoints[1].clusters.onOff.on('attr.onOff', (value, meta) => { - assert.strictEqual(value, true); - // Only whitelisted fields, not the stack's frame meta - assert.deepStrictEqual(meta, { transactionSequenceNumber: 42 }); - done(); + const report = frameToClient({ + cmdId: 0x0a, // reportAttributes + trxSequenceNumber: 42, + // attributeId 0x0000 (onOff), dataType 0x10 (bool), value true + data: Buffer.from([0x00, 0x00, 0x10, 0x01]), }); + await virtualDevice.sendFrame(1, OnOffCluster.ID, report, { + dstEndpoint: 1, + linkQuality: 200, + }); + await settle(); - const frame = new ZCLStandardHeader(); - frame.cmdId = 0x0a; // reportAttributes - frame.trxSequenceNumber = 42; - frame.frameControl.directionToClient = true; - // attributeId 0x0000 (onOff), dataType 0x10 (bool), value true - frame.data = Buffer.from([0x00, 0x00, 0x10, 0x01]); - - node.handleFrame(1, OnOffCluster.ID, frame.toBuffer(), { dstEndpoint: 1, linkQuality: 200 }); + // Only whitelisted fields, not the stack's frame meta + assert.deepStrictEqual( + onOnOff.mock.calls.map((call) => call.arguments), + [[true, { transactionSequenceNumber: 42 }]], + ); }); }); diff --git a/test/colorControl.js b/test/colorControl.js index c84caa4c..f987278b 100644 --- a/test/colorControl.js +++ b/test/colorControl.js @@ -1,18 +1,16 @@ 'use strict'; -const { describe, it, afterEach, mock } = require('node:test'); +const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const ColorControlCluster = require('../lib/clusters/colorControl'); -const Endpoint = require('../lib/Endpoint'); -const { createMockNode } = require('./util'); +const { createVirtualDevice } = require('./util/virtualDevice'); // Sends colorLoopSet on a fresh node and returns the parsed outgoing frame, so tests can assert // the payload bytes against the ZCL spec instead of round-tripping through our own definition async function sendColorLoopSet(args) { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice, frames } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -21,7 +19,7 @@ async function sendColorLoopSet(args) { ], }); - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'colorControl', new (class extends BoundCluster { async colorLoopSet() { @@ -30,20 +28,15 @@ async function sendColorLoopSet(args) { })(), ); - const sendFrameSpy = mock.method(node, 'sendFrame'); - await node.endpoints[1].clusters.colorControl.colorLoopSet(args); + await zclNode.endpoints[1].clusters.colorControl.colorLoopSet(args); - return Endpoint.parseFrame(sendFrameSpy.mock.calls[0].arguments[2]); + assert.strictEqual(frames[0].from, 'zclNode'); + return frames[0].frame; } describe('Color Control', function () { - afterEach(function () { - mock.restoreAll(); - }); - it('should receive moveToColor', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -53,7 +46,7 @@ describe('Color Control', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'colorControl', new (class extends BoundCluster { async moveToColor(data) { @@ -62,7 +55,7 @@ describe('Color Control', function () { })(), ); - await node.endpoints[1].clusters.colorControl.moveToColor({ + await zclNode.endpoints[1].clusters.colorControl.moveToColor({ colorX: 0x5000, colorY: 0x3000, transitionTime: 20, @@ -74,8 +67,7 @@ describe('Color Control', function () { }); it('should receive moveToColorTemperature', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -85,7 +77,7 @@ describe('Color Control', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'colorControl', new (class extends BoundCluster { async moveToColorTemperature(data) { @@ -94,7 +86,7 @@ describe('Color Control', function () { })(), ); - await node.endpoints[1].clusters.colorControl.moveToColorTemperature({ + await zclNode.endpoints[1].clusters.colorControl.moveToColorTemperature({ colorTemperature: 370, transitionTime: 15, }); @@ -104,8 +96,7 @@ describe('Color Control', function () { }); it('should receive moveToHueAndSaturation', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -115,7 +106,7 @@ describe('Color Control', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'colorControl', new (class extends BoundCluster { async moveToHueAndSaturation(data) { @@ -124,7 +115,7 @@ describe('Color Control', function () { })(), ); - await node.endpoints[1].clusters.colorControl.moveToHueAndSaturation({ + await zclNode.endpoints[1].clusters.colorControl.moveToHueAndSaturation({ hue: 180, saturation: 200, transitionTime: 10, @@ -136,8 +127,7 @@ describe('Color Control', function () { }); it('should receive colorLoopSet', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -147,7 +137,7 @@ describe('Color Control', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'colorControl', new (class extends BoundCluster { async colorLoopSet(data) { @@ -156,7 +146,7 @@ describe('Color Control', function () { })(), ); - await node.endpoints[1].clusters.colorControl.colorLoopSet({ + await zclNode.endpoints[1].clusters.colorControl.colorLoopSet({ updateFlags: ['updateAction', 'updateDirection', 'updateTime', 'updateStartHue'], action: 'activateFromColorLoopStartEnhancedHue', direction: 'incrementHue', @@ -176,7 +166,7 @@ describe('Color Control', function () { assert.strictEqual(receivedData.startHue, 0x2300); }); - // The loopback test above encodes and decodes with the same definition, so it passes even when + // The round-trip test above encodes and decodes with the same definition, so it passes even when // that definition is wrong on the wire. These bytes come from the spec, not from our types: // ZCL r8 Figure 5-17 (payload layout), Figure 5-18 (updateFlags bits), Table 5.25 (action // values), Table 5.26 (direction values), Table 5.13 (command id). diff --git a/test/defaultResponse.js b/test/defaultResponse.js index ea8474ee..3dd13d54 100644 --- a/test/defaultResponse.js +++ b/test/defaultResponse.js @@ -3,32 +3,33 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); -const { Endpoint, BoundCluster } = require('..'); +const { BoundCluster } = require('..'); const DoorLockCluster = require('../lib/clusters/doorLock'); +const { createVirtualDevice } = require('./util/virtualDevice'); // Door Lock setWeekDaySchedule is cluster-specific command 0x0B, same id as global Default Response const SET_WEEK_DAY_SCHEDULE_REQUEST = Buffer.from([ 0x01, 0x05, 0x0b, 0x01, 0x02, 0x00, 0x02, 0x08, 0x00, 0x17, 0x00, ]); -function createEndpoint() { - const sentFrames = []; - const endpoint = new Endpoint( - { - sendFrame: async (_endpointId, _clusterId, frame) => { - sentFrames.push(frame.toString('hex')); - }, - }, - { endpointId: 1, inputClusters: [DoorLockCluster.ID], outputClusters: [] }, - ); - return { endpoint, sentFrames }; +/** Sends `request` to a lock served by `doorLock` and returns what the lock sent back, as hex. */ +async function lockAnswersTo(request, doorLock) { + const { zclNode, frames, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [DoorLockCluster.ID], clusters: { doorLock } }], + }); + + await zclNode.sendFrame(1, DoorLockCluster.ID, request); + await settle(); + + return frames + .filter(({ from }) => from === 'virtualDevice') + .map(({ raw }) => raw.toString('hex')); } describe('Default Response', function () { it('sends the cluster-specific response to a cluster-specific command 0x0B', async function () { - const { endpoint, sentFrames } = createEndpoint(); - endpoint.bind( - 'doorLock', + const answers = await lockAnswersTo( + SET_WEEK_DAY_SCHEDULE_REQUEST, new (class extends BoundCluster { async setWeekDaySchedule() { return { status: 0 }; @@ -36,31 +37,34 @@ describe('Default Response', function () { })(), ); - await endpoint.handleFrame(DoorLockCluster.ID, SET_WEEK_DAY_SCHEDULE_REQUEST); - - assert.deepEqual(sentFrames, ['19050b00']); + assert.deepEqual(answers, ['19050b00']); }); it('sends an error Default Response to an unhandled cluster-specific command 0x0B', async function () { - const { endpoint, sentFrames } = createEndpoint(); - endpoint.bind('doorLock', new BoundCluster()); - - await endpoint.handleFrame(DoorLockCluster.ID, SET_WEEK_DAY_SCHEDULE_REQUEST); - // The error response is sent without awaiting, let it flush - await new Promise(setImmediate); + const answers = await lockAnswersTo(SET_WEEK_DAY_SCHEDULE_REQUEST, new BoundCluster()); // Default Response (0x0B) for command 0x0B with any error status; the exact status is not // what this test is about - assert.equal(sentFrames.length, 1); - assert.match(sentFrames[0], /^18050b0b(?!00)[0-9a-f]{2}$/); + assert.equal(answers.length, 1); + assert.match(answers[0], /^18050b0b(?!00)[0-9a-f]{2}$/); }); it('does not respond to a global Default Response', async function () { - const { endpoint, sentFrames } = createEndpoint(); + const { virtualDevice, frames, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [DoorLockCluster.ID] }], + }); - await endpoint.handleFrame(DoorLockCluster.ID, Buffer.from([0x08, 0x05, 0x0b, 0x00, 0x00])); - await new Promise(setImmediate); + // A stray Default Response without Disable Default Response set, as some devices send + await virtualDevice.sendFrame( + 1, + DoorLockCluster.ID, + Buffer.from([0x08, 0x05, 0x0b, 0x00, 0x00]), + ); + await settle(); - assert.deepEqual(sentFrames, []); + assert.deepEqual( + frames.filter(({ from }) => from === 'zclNode'), + [], + ); }); }); diff --git a/test/doorLock.js b/test/doorLock.js index 842233c2..00acc8b0 100644 --- a/test/doorLock.js +++ b/test/doorLock.js @@ -4,13 +4,11 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const DoorLockCluster = require('../lib/clusters/doorLock'); -const { createMockNode } = require('./util'); -const { ZCLStandardHeader } = require('../lib/zclFrames'); +const { createVirtualDevice, frameToClient } = require('./util/virtualDevice'); describe('Door Lock', function () { it('should receive lockDoor', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -20,7 +18,7 @@ describe('Door Lock', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'doorLock', new (class extends BoundCluster { async lockDoor(data) { @@ -29,7 +27,7 @@ describe('Door Lock', function () { })(), ); - await node.endpoints[1].clusters.doorLock.lockDoor({ + await zclNode.endpoints[1].clusters.doorLock.lockDoor({ pinCode: Buffer.from([0x31, 0x32, 0x33, 0x34]), }); @@ -37,8 +35,7 @@ describe('Door Lock', function () { }); it('should receive lockDoor without arguments', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -47,26 +44,24 @@ describe('Door Lock', function () { ], }); - let called = false; - node.endpoints[1].bind( + let receivedData = null; + virtualDevice.endpoints[1].bind( 'doorLock', new (class extends BoundCluster { async lockDoor(data) { - called = true; - // pinCode should be empty buffer when not provided - assert.deepStrictEqual(data.pinCode, Buffer.from([])); + receivedData = data; } })(), ); // Call without any arguments - should not throw - await node.endpoints[1].clusters.doorLock.lockDoor(); - assert.strictEqual(called, true); + await zclNode.endpoints[1].clusters.doorLock.lockDoor(); + // pinCode should be empty buffer when not provided + assert.deepStrictEqual(receivedData.pinCode, Buffer.from([])); }); it('should receive unlockDoor', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -76,7 +71,7 @@ describe('Door Lock', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'doorLock', new (class extends BoundCluster { async unlockDoor(data) { @@ -85,7 +80,7 @@ describe('Door Lock', function () { })(), ); - await node.endpoints[1].clusters.doorLock.unlockDoor({ + await zclNode.endpoints[1].clusters.doorLock.unlockDoor({ pinCode: Buffer.from([0x30, 0x30, 0x30, 0x30]), }); @@ -93,8 +88,7 @@ describe('Door Lock', function () { }); it('should receive setPINCode', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -104,7 +98,7 @@ describe('Door Lock', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'doorLock', new (class extends BoundCluster { async setPINCode(data) { @@ -113,7 +107,7 @@ describe('Door Lock', function () { })(), ); - await node.endpoints[1].clusters.doorLock.setPINCode({ + await zclNode.endpoints[1].clusters.doorLock.setPINCode({ userId: 1, userStatus: 'occupiedEnabled', userType: 'unrestricted', @@ -128,9 +122,8 @@ describe('Door Lock', function () { // Server-to-client notifications require manual frame construction // since they are sent BY the device TO the controller - it('should receive operationEventNotification', function (t, done) { - const node = createMockNode({ - loopback: true, + it('should receive operationEventNotification', async function () { + const { zclNode, virtualDevice, settle } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -139,30 +132,32 @@ describe('Door Lock', function () { ], }); - node.endpoints[1].clusters.doorLock.onOperationEventNotification = (data) => { - assert.strictEqual(data.operationEventSource, 1); // Keypad - assert.strictEqual(data.operationEventCode, 2); // Unlock - assert.strictEqual(data.userId, 3); - assert.deepStrictEqual(data.pin, Buffer.from([0x31, 0x32, 0x33, 0x34])); - assert.strictEqual(data.zigBeeLocalTime, 0x12345678); - assert.deepStrictEqual(data.data, Buffer.from([])); - done(); + let data; + zclNode.endpoints[1].clusters.doorLock.onOperationEventNotification = (received) => { + data = received; }; - const frame = new ZCLStandardHeader(); - frame.cmdId = DoorLockCluster.COMMANDS.operationEventNotification.id; - frame.frameControl.directionToClient = true; - frame.frameControl.clusterSpecific = true; - frame.data = Buffer.from([ - 0x01, 0x02, 0x03, 0x00, 0x04, 0x31, 0x32, 0x33, 0x34, 0x78, 0x56, 0x34, 0x12, 0x00, - ]); + const frame = frameToClient({ + cmdId: DoorLockCluster.COMMANDS.operationEventNotification.id, + clusterSpecific: true, + data: Buffer.from([ + 0x01, 0x02, 0x03, 0x00, 0x04, 0x31, 0x32, 0x33, 0x34, 0x78, 0x56, 0x34, 0x12, 0x00, + ]), + }); - node.handleFrame(1, DoorLockCluster.ID, frame.toBuffer(), {}); + await virtualDevice.sendFrame(1, DoorLockCluster.ID, frame); + await settle(); + + assert.strictEqual(data.operationEventSource, 1); // Keypad + assert.strictEqual(data.operationEventCode, 2); // Unlock + assert.strictEqual(data.userId, 3); + assert.deepStrictEqual(data.pin, Buffer.from([0x31, 0x32, 0x33, 0x34])); + assert.strictEqual(data.zigBeeLocalTime, 0x12345678); + assert.deepStrictEqual(data.data, Buffer.from([])); }); - it('should receive programmingEventNotification', function (t, done) { - const node = createMockNode({ - loopback: true, + it('should receive programmingEventNotification', async function () { + const { zclNode, virtualDevice, settle } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -171,27 +166,30 @@ describe('Door Lock', function () { ], }); - node.endpoints[1].clusters.doorLock.onProgrammingEventNotification = (data) => { - assert.strictEqual(data.programEventSource, 2); // RF - assert.strictEqual(data.programEventCode, 3); // PIN added - assert.strictEqual(data.userId, 7); - assert.deepStrictEqual(data.pin, Buffer.from([0x35, 0x36, 0x37, 0x38])); - assert.strictEqual(data.userType, 'unrestricted'); - assert.strictEqual(data.userStatus, 'occupiedEnabled'); - assert.strictEqual(data.zigBeeLocalTime, 0xaabbccdd); - assert.deepStrictEqual(data.data, Buffer.from([])); - done(); + let data; + zclNode.endpoints[1].clusters.doorLock.onProgrammingEventNotification = (received) => { + data = received; }; - const frame = new ZCLStandardHeader(); - frame.cmdId = DoorLockCluster.COMMANDS.programmingEventNotification.id; - frame.frameControl.directionToClient = true; - frame.frameControl.clusterSpecific = true; - frame.data = Buffer.from([ - 0x02, 0x03, 0x07, 0x00, 0x04, 0x35, 0x36, 0x37, 0x38, 0x00, 0x01, 0xdd, 0xcc, 0xbb, 0xaa, - 0x00, - ]); + const frame = frameToClient({ + cmdId: DoorLockCluster.COMMANDS.programmingEventNotification.id, + clusterSpecific: true, + data: Buffer.from([ + 0x02, 0x03, 0x07, 0x00, 0x04, 0x35, 0x36, 0x37, 0x38, 0x00, 0x01, 0xdd, 0xcc, 0xbb, 0xaa, + 0x00, + ]), + }); - node.handleFrame(1, DoorLockCluster.ID, frame.toBuffer(), {}); + await virtualDevice.sendFrame(1, DoorLockCluster.ID, frame); + await settle(); + + assert.strictEqual(data.programEventSource, 2); // RF + assert.strictEqual(data.programEventCode, 3); // PIN added + assert.strictEqual(data.userId, 7); + assert.deepStrictEqual(data.pin, Buffer.from([0x35, 0x36, 0x37, 0x38])); + assert.strictEqual(data.userType, 'unrestricted'); + assert.strictEqual(data.userStatus, 'occupiedEnabled'); + assert.strictEqual(data.zigBeeLocalTime, 0xaabbccdd); + assert.deepStrictEqual(data.data, Buffer.from([])); }); }); diff --git a/test/iasZone.js b/test/iasZone.js index 4fad1823..cda200ef 100644 --- a/test/iasZone.js +++ b/test/iasZone.js @@ -4,51 +4,45 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const IASZoneCluster = require('../lib/clusters/iasZone'); -const { createMockNode, MOCK_DEVICES } = require('./util'); -const { ZCLStandardHeader } = require('../lib/zclFrames'); +const { createVirtualDevice, frameToClient } = require('./util/virtualDevice'); -describe('IAS Zone', function () { - // Server-to-client notification - requires manual frame - it('should receive onZoneEnrollRequest', function (t, done) { - const node = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [IASZoneCluster.ID], - }, - ], - }); +/** Has an IAS zone sensor send the cluster-specific `cmdId` with `data` to the node under test. */ +async function receiveFromSensor(cmdId, data, handlerName) { + const { zclNode, virtualDevice, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [IASZoneCluster.ID] }], + }); + + let received; + zclNode.endpoints[1].clusters.iasZone[handlerName] = (payload) => { + received = payload; + }; + + const frame = frameToClient({ cmdId, data, clusterSpecific: true }); + await virtualDevice.sendFrame(1, IASZoneCluster.ID, frame); + await settle(); - node.endpoints[1].clusters.iasZone.onZoneEnrollRequest = (data) => { - assert.strictEqual(data.zoneType, 'keypad'); - assert.strictEqual(data.manufacturerCode, 4117); - done(); - }; + return received; +} - const frame = new ZCLStandardHeader(); - frame.cmdId = IASZoneCluster.COMMANDS.zoneEnrollRequest.id; - frame.frameControl.directionToClient = true; - frame.frameControl.clusterSpecific = true; - frame.data = Buffer.from([0x1d, 0x02, 0x15, 0x10]); +describe('IAS Zone', function () { + it('should receive onZoneEnrollRequest', async function () { + const data = await receiveFromSensor( + IASZoneCluster.COMMANDS.zoneEnrollRequest.id, + Buffer.from([0x1d, 0x02, 0x15, 0x10]), + 'onZoneEnrollRequest', + ); - node.handleFrame(1, IASZoneCluster.ID, frame.toBuffer(), {}); + assert.strictEqual(data.zoneType, 'keypad'); + assert.strictEqual(data.manufacturerCode, 4117); }); - // Client-to-server command - can use loopback it('should send zoneEnrollResponse', async function () { - const node = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [IASZoneCluster.ID], - }, - ], + const { zclNode, virtualDevice } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [IASZoneCluster.ID] }], }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'iasZone', new (class extends BoundCluster { async zoneEnrollResponse(data) { @@ -57,7 +51,7 @@ describe('IAS Zone', function () { })(), ); - await node.endpoints[1].clusters.iasZone.zoneEnrollResponse({ + await zclNode.endpoints[1].clusters.iasZone.zoneEnrollResponse({ enrollResponseCode: 'success', zoneId: 10, }); @@ -66,64 +60,16 @@ describe('IAS Zone', function () { assert.strictEqual(receivedData.zoneId, 10); }); - // Server-to-client notification - requires manual frame - it('should receive zoneStatusChangeNotification', function (t, done) { - const node = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [IASZoneCluster.ID], - }, - ], - }); - - node.endpoints[1].clusters.iasZone.onZoneStatusChangeNotification = (data) => { - assert.strictEqual(data.zoneStatus.supervisionReports, true); - assert.strictEqual(data.extendedStatus, 0); - assert.strictEqual(data.zoneId, 10); - assert.strictEqual(data.delay, 108); - done(); - }; - - const frame = new ZCLStandardHeader(); - frame.cmdId = IASZoneCluster.COMMANDS.zoneStatusChangeNotification.id; - frame.frameControl.directionToClient = true; - frame.frameControl.clusterSpecific = true; - frame.data = Buffer.from([0x10, 0x00, 0x00, 0x0a, 0x6c, 0x00]); - - node.handleFrame(1, IASZoneCluster.ID, frame.toBuffer(), {}); - }); - - describe('Mock Device Factory', function () { - it('should create a motion sensor with correct zone type', function () { - const sensor = MOCK_DEVICES.motionSensor(); - const boundCluster = sensor.endpoints[1].bindings.iasZone; - assert.strictEqual(boundCluster.zoneType, 0x000d, 'Should be motion sensor type'); - assert.strictEqual(boundCluster.zoneState, 1, 'Should be enrolled'); - }); - - it('should create a contact sensor with correct zone type', function () { - const sensor = MOCK_DEVICES.contactSensor(); - const boundCluster = sensor.endpoints[1].bindings.iasZone; - assert.strictEqual(boundCluster.zoneType, 0x0015, 'Should be contact switch type'); - assert.strictEqual(boundCluster.zoneState, 1, 'Should be enrolled'); - }); - - it('should allow attribute overrides', function () { - const sensor = MOCK_DEVICES.motionSensor({ - iasZone: { zoneStatus: 0x0001 }, // Alarm1 active - }); - const boundCluster = sensor.endpoints[1].bindings.iasZone; - assert.strictEqual(boundCluster.zoneStatus, 0x0001, 'Should have alarm1 bit set'); - }); + it('should receive zoneStatusChangeNotification', async function () { + const data = await receiveFromSensor( + IASZoneCluster.COMMANDS.zoneStatusChangeNotification.id, + Buffer.from([0x10, 0x00, 0x00, 0x0a, 0x6c, 0x00]), + 'onZoneStatusChangeNotification', + ); - it('should create temp/humidity sensor with measurement values', function () { - const sensor = MOCK_DEVICES.tempHumiditySensor(); - const tempCluster = sensor.endpoints[1].bindings.temperatureMeasurement; - const humCluster = sensor.endpoints[1].bindings.relativeHumidity; - assert.strictEqual(tempCluster.measuredValue, 2150, 'Should be 21.50°C raw'); - assert.strictEqual(humCluster.measuredValue, 6500, 'Should be 65.00% raw'); - }); + assert.strictEqual(data.zoneStatus.supervisionReports, true); + assert.strictEqual(data.extendedStatus, 0); + assert.strictEqual(data.zoneId, 10); + assert.strictEqual(data.delay, 108); }); }); diff --git a/test/levelControl.js b/test/levelControl.js index a1f6aded..fd44e632 100644 --- a/test/levelControl.js +++ b/test/levelControl.js @@ -4,12 +4,11 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const LevelControlCluster = require('../lib/clusters/levelControl'); -const { createMockNode } = require('./util'); +const { createVirtualDevice } = require('./util/virtualDevice'); describe('Level Control', function () { it('should receive moveToLevel', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -19,7 +18,7 @@ describe('Level Control', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'levelControl', new (class extends BoundCluster { async moveToLevel(data) { @@ -28,7 +27,7 @@ describe('Level Control', function () { })(), ); - await node.endpoints[1].clusters.levelControl.moveToLevel({ + await zclNode.endpoints[1].clusters.levelControl.moveToLevel({ level: 128, transitionTime: 10, }); @@ -38,8 +37,7 @@ describe('Level Control', function () { }); it('should receive step', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -49,7 +47,7 @@ describe('Level Control', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'levelControl', new (class extends BoundCluster { async step(data) { @@ -58,7 +56,7 @@ describe('Level Control', function () { })(), ); - await node.endpoints[1].clusters.levelControl.step({ + await zclNode.endpoints[1].clusters.levelControl.step({ mode: 'up', stepSize: 50, transitionTime: 5, @@ -70,8 +68,7 @@ describe('Level Control', function () { }); it('should receive stop', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -81,7 +78,7 @@ describe('Level Control', function () { }); let called = false; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'levelControl', new (class extends BoundCluster { async stop() { @@ -90,7 +87,7 @@ describe('Level Control', function () { })(), ); - await node.endpoints[1].clusters.levelControl.stop(); + await zclNode.endpoints[1].clusters.levelControl.stop(); assert.strictEqual(called, true); }); }); diff --git a/test/manual/xiaomiHacks.js b/test/manual/xiaomiHacks.js deleted file mode 100644 index 90ef1a82..00000000 --- a/test/manual/xiaomiHacks.js +++ /dev/null @@ -1,133 +0,0 @@ -'use strict'; - -const { ZCLDataTypes, ZCLDataType } = require('../../lib/zclTypes'); -const Cluster = require('../../lib/Cluster'); -const BasicCluster = require('../../lib/clusters/basic'); - -const attributes = { - 1: { name: 'batteryVoltage' }, // type: ZCLDataTypes.uint16 - 3: { name: 'cpuTemperature' }, // type: ZCLDataTypes.int8 - 4: { name: 'unk_4' }, // type: ZCLDataTypes.uint16 - 5: { name: 'unk_5' }, // type: ZCLDataTypes.uint16 - 6: { name: 'txCount' }, // type: ZCLDataTypes.uint40 - 10: { name: 'unk_10' }, // type: ZCLDataTypes.uint16 - 11: { name: 'illuminance' }, - 100: { name: 'state0' }, - 101: { name: 'state1' }, - 102: { name: 'state2' }, - 149: { name: 'consumption' }, - 150: { name: 'voltage' }, - 151: { name: 'unk_151' }, - 152: { name: 'power' }, - 153: { name: 'unk_153' }, - 154: { name: 'unk_154' }, -}; - -// custom data type parser with dynamic length with minimal 3 bytes, and no declared ZCL id. -const XiaomiLifelineDataRecord = new ZCLDataType( - NaN, - 'XiaomiLifelineDataRecord', - -3, - function (_buf, _v, _i) { - throw new Error('generating xiaomi structs is not implemented'); - }, - function (buf, i, returnLength) { - i = i || 0; - const startByte = i; - const res = {}; - res.id = ZCLDataTypes.uint8.fromBuffer(buf, i); - i += ZCLDataTypes.uint8.length; - - const dataTypeId = ZCLDataTypes.uint8.fromBuffer(buf, i); - i += ZCLDataTypes.uint8.length; - - const DataType = - (attributes[res.id] && attributes[res.id].type) || - Object.values(ZCLDataTypes).find((type) => type && type.id === dataTypeId); - if (!DataType) throw new TypeError(`Invalid Type for Attribute: ${res.id}`); - - res.name = (attributes[res.id] && attributes[res.id].name) || `unknown_attr_${res.id}`; - - const entry = DataType.fromBuffer(buf, i, true); - if (DataType.length > 0) { - i += DataType.length; - res.value = entry; - } else { - res.value = entry.result; - i += entry.length; - } - if (returnLength) { - return { result: res, length: i - startByte }; - } - return res; - }, -); - -const XiaomiLifelineDataRecordArray = new ZCLDataType( - NaN, - 'XiaomiLifelineDataRecordArray', - -1, - function (_buf, _v, _i) { - throw new Error('not_supported'); - }, - function (buf, i, returnLength) { - let { result, length } = ZCLDataTypes.buffer8.fromBuffer(buf, i, true); - result = ZCLDataTypes.Array0(XiaomiLifelineDataRecord).fromBuffer(result, 0); - result = result.reduce((r, { name, value }) => Object.assign(r, { [name]: value }), {}); - if (returnLength) { - return { result, length }; - } - return result; - }, -); - -class XiaomiHackedBasicCluster extends BasicCluster { - static get ATTRIBUTES() { - return { - ...super.ATTRIBUTES, - xiaomiLifeline: { id: 0xff01, type: XiaomiLifelineDataRecordArray }, - }; - } -} - -// Replace basic cluster with our patched version -Cluster.addCluster(XiaomiHackedBasicCluster); - -// //Demo non-meshdriver zigbee API: -// const OnOffCluster = require('homey-zcl/zcl/onOff'); - -// node.endpoints[1].basic.on('attr.xiaomiLifeline', (attributes, rawFrame) => { -// console.log('xiaomi custom state:', attributes.state1); -// }) - -// const {modelId, dateCode} = await node.endpoints[1].basic.readAttributes([ -// 'modelId', 'dateCode' -// ]); -// node.endpoint[1].clusters.basic.configureReporting({ -// powerSource: {minInterval: 1234, maxInterval: 4321, minChange: 10}}); //enables reporting - -// TODO: invert group commands -// class LocalOnOffCluster { -// async on() { -// //binding said on, update attribute -// this.onOff = true; -// } - -// async off() { -// //binding said off -// this.onOff = false; -// } - -// async toggle() { -// //binding said toggle -// this.onOff = !this.onOff; -// } -// } - -// node.endpoint[1].bind('onOff', new LocalOnOffCluster()); // binds the device+endpoint+cluster -// to us -// node.endpoint[1].unbind('onOff'); //removes a previous binding to us -// node.endpoint[1].bindings.onOff.onOff // returns boolean -// const groupsResponse = await node.endpoints[1].touchlink.getGroups(); -// const {groupId} = groupsResponse.groups[0]; -// diff --git a/test/onOff.js b/test/onOff.js index ba8bd77d..043f408a 100644 --- a/test/onOff.js +++ b/test/onOff.js @@ -4,12 +4,11 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const OnOffCluster = require('../lib/clusters/onOff'); -const { createMockNode } = require('./util'); +const { createVirtualDevice } = require('./util/virtualDevice'); describe('On/Off', function () { it('should receive setOn', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -19,7 +18,7 @@ describe('On/Off', function () { }); let called = false; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'onOff', new (class extends BoundCluster { async setOn() { @@ -28,13 +27,12 @@ describe('On/Off', function () { })(), ); - await node.endpoints[1].clusters.onOff.setOn(); + await zclNode.endpoints[1].clusters.onOff.setOn(); assert.strictEqual(called, true); }); it('should receive setOff', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -44,7 +42,7 @@ describe('On/Off', function () { }); let called = false; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'onOff', new (class extends BoundCluster { async setOff() { @@ -53,13 +51,12 @@ describe('On/Off', function () { })(), ); - await node.endpoints[1].clusters.onOff.setOff(); + await zclNode.endpoints[1].clusters.onOff.setOff(); assert.strictEqual(called, true); }); it('should receive toggle', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -69,7 +66,7 @@ describe('On/Off', function () { }); let called = false; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'onOff', new (class extends BoundCluster { async toggle() { @@ -78,13 +75,12 @@ describe('On/Off', function () { })(), ); - await node.endpoints[1].clusters.onOff.toggle(); + await zclNode.endpoints[1].clusters.onOff.toggle(); assert.strictEqual(called, true); }); it('should receive onWithTimedOff', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -94,7 +90,7 @@ describe('On/Off', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'onOff', new (class extends BoundCluster { async onWithTimedOff(data) { @@ -103,7 +99,7 @@ describe('On/Off', function () { })(), ); - await node.endpoints[1].clusters.onOff.onWithTimedOff({ + await zclNode.endpoints[1].clusters.onOff.onWithTimedOff({ onOffControl: 0x01, onTime: 100, offWaitTime: 50, diff --git a/test/ota.js b/test/ota.js index cb96e97a..78661711 100644 --- a/test/ota.js +++ b/test/ota.js @@ -3,8 +3,8 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); -const Node = require('../lib/Node'); const OTACluster = require('../lib/clusters/ota'); +const { createVirtualDevice } = require('./util/virtualDevice'); // ZCL Standard Header: frameControl(1), trxSequenceNumber(1), cmdId(1), data(rest) const ZCL_HEADER_SIZE = 3; @@ -14,39 +14,27 @@ const CMD_QUERY_NEXT_IMAGE_REQUEST = OTACluster.COMMANDS.queryNextImageRequest.i const CMD_QUERY_NEXT_IMAGE_RESPONSE = OTACluster.COMMANDS.queryNextImageRequest.response.id; /** - * Creates a mock node that captures raw ZCL frame buffers from every sendFrame call, - * with loopback so frames are handled by the same node. + * A device with an OTA client, as real devices have, and Homey's node serving OTA. The device asks + * for images; the node under test answers. */ -function createCapturingNode() { - const capturedFrames = []; - let node; - - const mockNode = { - sendFrame: (endpointId, clusterId, buffer) => { - capturedFrames.push(buffer); - return node.handleFrame(endpointId, clusterId, buffer, {}); - }, - endpointDescriptors: [ - { - endpointId: 1, - inputClusters: [OTACluster.ID], - outputClusters: [], - }, - ], - }; - - node = new Node(mockNode); - return { node, capturedFrames }; +function createOtaDevice() { + return createVirtualDevice({ endpoints: [{ endpointId: 1, outputClusters: [OTACluster.ID] }] }); +} + +/** The raw frame `from` sent with command `cmdId`. */ +function sentFrame(frames, from, cmdId) { + const sent = frames.find((record) => record.from === from && record.frame.cmdId === cmdId); + return sent && sent.raw; } describe('OTA', function () { // --- queryNextImageRequest: encodeMissingFieldsBehavior on the request --- it('should NOT encode hardwareVersion when omitted from queryNextImageRequest', async function () { - const { node, capturedFrames } = createCapturingNode(); + const { zclNode, virtualDevice, frames } = createOtaDevice(); let receivedData = null; - node.endpoints[1].bind( + zclNode.endpoints[1].bind( 'ota', new (class extends BoundCluster { async queryNextImageRequest(data) { @@ -56,7 +44,7 @@ describe('OTA', function () { })(), ); - await node.endpoints[1].clusters.ota.queryNextImageRequest({ + await virtualDevice.endpoints[1].clusters.ota.queryNextImageRequest({ fieldControl: 0, manufacturerCode: 0x1234, imageType: 0x0001, @@ -64,7 +52,7 @@ describe('OTA', function () { // hardwareVersion intentionally omitted }); - const requestFrame = capturedFrames.find((buf) => buf[2] === CMD_QUERY_NEXT_IMAGE_REQUEST); + const requestFrame = sentFrame(frames, 'virtualDevice', CMD_QUERY_NEXT_IMAGE_REQUEST); assert.ok(requestFrame, 'request frame not captured'); // fieldControl(1) + manufacturerCode(2) + imageType(2) + fileVersion(4) = 9 bytes @@ -84,9 +72,9 @@ describe('OTA', function () { }); it('should encode hardwareVersion when provided in queryNextImageRequest', async function () { - const { node, capturedFrames } = createCapturingNode(); + const { zclNode, virtualDevice, frames } = createOtaDevice(); - node.endpoints[1].bind( + zclNode.endpoints[1].bind( 'ota', new (class extends BoundCluster { async queryNextImageRequest() { @@ -95,7 +83,7 @@ describe('OTA', function () { })(), ); - await node.endpoints[1].clusters.ota.queryNextImageRequest({ + await virtualDevice.endpoints[1].clusters.ota.queryNextImageRequest({ fieldControl: ['hardwareVersionPresent'], manufacturerCode: 0x1234, imageType: 0x0001, @@ -103,7 +91,7 @@ describe('OTA', function () { hardwareVersion: 0x0002, }); - const requestFrame = capturedFrames.find((buf) => buf[2] === CMD_QUERY_NEXT_IMAGE_REQUEST); + const requestFrame = sentFrame(frames, 'virtualDevice', CMD_QUERY_NEXT_IMAGE_REQUEST); assert.ok(requestFrame, 'request frame not captured'); // fieldControl(1) + manufacturerCode(2) + imageType(2) + fileVersion(4) @@ -122,9 +110,9 @@ describe('OTA', function () { // --- queryNextImageResponse: encodeMissingFieldsBehavior on the response --- it('should encode all fields in queryNextImageResponse when status is SUCCESS', async function () { - const { node, capturedFrames } = createCapturingNode(); + const { zclNode, virtualDevice, frames } = createOtaDevice(); - node.endpoints[1].bind( + zclNode.endpoints[1].bind( 'ota', new (class extends BoundCluster { async queryNextImageRequest() { @@ -139,14 +127,14 @@ describe('OTA', function () { })(), ); - const response = await node.endpoints[1].clusters.ota.queryNextImageRequest({ + const response = await virtualDevice.endpoints[1].clusters.ota.queryNextImageRequest({ fieldControl: 0, manufacturerCode: 0x1234, imageType: 0x0001, fileVersion: 0x00000010, }); - const responseFrame = capturedFrames.find((buf) => buf[2] === CMD_QUERY_NEXT_IMAGE_RESPONSE); + const responseFrame = sentFrame(frames, 'zclNode', CMD_QUERY_NEXT_IMAGE_RESPONSE); assert.ok(responseFrame, 'response frame not captured'); // status(1) + manufacturerCode(2) + imageType(2) + fileVersion(4) + imageSize(4) = 13 bytes @@ -165,9 +153,9 @@ describe('OTA', function () { }); it('should encode only the status byte in queryNextImageResponse when status is NO_IMAGE_AVAILABLE', async function () { - const { node, capturedFrames } = createCapturingNode(); + const { zclNode, virtualDevice, frames } = createOtaDevice(); - node.endpoints[1].bind( + zclNode.endpoints[1].bind( 'ota', new (class extends BoundCluster { async queryNextImageRequest() { @@ -176,17 +164,17 @@ describe('OTA', function () { })(), ); - const response = await node.endpoints[1].clusters.ota.queryNextImageRequest({ + const response = await virtualDevice.endpoints[1].clusters.ota.queryNextImageRequest({ fieldControl: 0, manufacturerCode: 0x1234, imageType: 0x0001, fileVersion: 0x00000010, }); - const responseFrame = capturedFrames.find((buf) => buf[2] === CMD_QUERY_NEXT_IMAGE_RESPONSE); + const responseFrame = sentFrame(frames, 'zclNode', CMD_QUERY_NEXT_IMAGE_RESPONSE); assert.ok(responseFrame, 'response frame not captured'); - // Only status(1) — all other fields must be absent + // Only status(1): all other fields must be absent const payload = responseFrame.slice(ZCL_HEADER_SIZE); assert.strictEqual( payload.length, diff --git a/test/readAttributes.js b/test/readAttributes.js new file mode 100644 index 00000000..24a6728d --- /dev/null +++ b/test/readAttributes.js @@ -0,0 +1,67 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +const BoundCluster = require('../lib/BoundCluster'); +const OnOffCluster = require('../lib/clusters/onOff'); +const { createSingleClusterDevice } = require('./util/virtualDevice'); + +const ON_OFF = OnOffCluster.ATTRIBUTES.onOff.id; +const ON_TIME = OnOffCluster.ATTRIBUTES.onTime.id; + +/** A device whose onOff cluster answers every read with the records `answer(requestedIds)`. */ +function deviceAnswering(answer) { + return createSingleClusterDevice( + OnOffCluster, + new (class extends BoundCluster { + async readAttributes({ attributes }) { + const buf = Buffer.alloc(255); + const len = OnOffCluster.attributeArrayStatusDataType.toBuffer(buf, answer(attributes), 0); + return { attributes: buf.subarray(0, len) }; + } + })(), + ); +} + +// The device that only ever answers with unrequested ids is in test/virtualDevice.js. +describe('Cluster readAttributes', function () { + it('reads what it asked for across several responses', async function () { + const requests = []; + const { zclNode } = deviceAnswering((ids) => { + requests.push([...ids]); + // One attribute per response, as a device splitting its answer over several frames does. + const id = ids[0]; + return [{ id, status: 'SUCCESS', value: id === ON_OFF ? true : 5 }]; + }); + + const result = await zclNode.endpoints[1].clusters.onOff.readAttributes(['onOff', 'onTime']); + + assert.deepStrictEqual(requests, [[ON_OFF, ON_TIME], [ON_TIME]]); + assert.deepStrictEqual(result, { onOff: true, onTime: 5 }); + }); + + it('keeps attributes that were not asked for out of the result', async function () { + const { zclNode } = deviceAnswering(() => [ + { id: ON_OFF, status: 'SUCCESS', value: true }, + { id: ON_TIME, status: 'SUCCESS', value: 5 }, + ]); + + const result = await zclNode.endpoints[1].clusters.onOff.readAttributes(['onOff']); + + assert.deepStrictEqual(result, { onOff: true }); + }); + + it('stops on a response that holds no attributes at all', async function () { + let reads = 0; + const { zclNode } = deviceAnswering(() => { + reads++; + return []; + }); + + const result = await zclNode.endpoints[1].clusters.onOff.readAttributes(['onOff']); + + assert.strictEqual(reads, 1); + assert.deepStrictEqual(result, {}); + }); +}); diff --git a/test/testClusterResponseTimeout.js b/test/testClusterResponseTimeout.js index 5d3ccd3a..88bf5c63 100644 --- a/test/testClusterResponseTimeout.js +++ b/test/testClusterResponseTimeout.js @@ -1,86 +1,57 @@ 'use strict'; -const { describe, it, beforeEach, afterEach, mock } = require('node:test'); +const { describe, it } = require('node:test'); const assert = require('node:assert'); const Cluster = require('../lib/Cluster'); -const Node = require('../lib/Node'); const { ZCLDataTypes } = require('../lib/zclTypes'); const BoundCluster = require('../lib/BoundCluster'); const OnOffCluster = require('../lib/clusters/onOff'); const BasicCluster = require('../lib/clusters/basic'); const AlarmsCluster = require('../lib/clusters/alarms'); const IasZoneCluster = require('../lib/clusters/iasZone'); +const { createSingleClusterDevice } = require('./util/virtualDevice'); + +const togglingDevice = () => + createSingleClusterDevice( + OnOffCluster, + new (class extends BoundCluster { + toggle() {} + })(), + ); + +/** Makes the device send `colliding(tsn)` with the request's TSN right before each reply. */ +function collideBeforeReplies(intercept, colliding) { + intercept(({ from, frame, raw }) => + from === 'virtualDevice' ? [colliding(frame.trxSequenceNumber), raw] : undefined, + ); +} describe('Cluster Response Timeout', function () { - let receivingNode; - let sendingNode; - - beforeEach(function () { - sendingNode = new Node({ - // Forward frames to receiving node - sendFrame: (...args) => { - receivingNode.handleFrame(...args); - }, - endpointDescriptors: [ - { - endpointId: 1, - inputClusters: [OnOffCluster.ID], - }, - ], - }); - - receivingNode = new Node({ - // Forward frames to sending node - sendFrame: (...args) => { - sendingNode.handleFrame(...args); - }, - endpointDescriptors: [ - { - endpointId: 1, - inputClusters: [OnOffCluster.ID], - }, - ], - }); - }); - - afterEach(function () { - mock.restoreAll(); - mock.timers.reset(); - }); - - it('should only start timeout after frame is sent', async function () { - mock.timers.enable({ apis: ['setTimeout'] }); - const originalSendFrame = sendingNode.endpoints[1].sendFrame.bind(sendingNode.endpoints[1]); - mock.method(sendingNode.endpoints[1], 'sendFrame', async (...args) => { - // Delay sendFrame by 150ms (longer than timeout) + it('should only start timeout after frame is sent', async function (t) { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { zclNode } = togglingDevice(); + const endpoint = zclNode.endpoints[1]; + const originalSendFrame = endpoint.sendFrame.bind(endpoint); + // Delay sendFrame by 150ms (longer than timeout) + t.mock.method(endpoint, 'sendFrame', async (...args) => { await new Promise((resolve) => setTimeout(resolve, 150)); return originalSendFrame(...args); }); - receivingNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - toggle() { - // Respond immediately - } - })(), - ); - // Start the command with 100ms timeout, if timeout starts immediately it will fire before // sendFrame completes and cause the command to fail - const toggled = sendingNode.endpoints[1].clusters.onOff.toggle(undefined, { timeout: 100 }); + const toggled = endpoint.clusters.onOff.toggle(undefined, { timeout: 100 }); // Let the stubbed sendFrame reach its fake timer before advancing past the 100ms timeout. await Promise.resolve(); - mock.timers.tick(150); + t.mock.timers.tick(150); await toggled; }); - it('should clear timeout when response is received', async function () { - const clearTimeoutSpy = mock.method(global, 'clearTimeout'); - - receivingNode.endpoints[1].bind( - 'onOff', + it('should clear timeout when response is received', async function (t) { + const clearTimeoutSpy = t.mock.method(global, 'clearTimeout'); + const { zclNode } = createSingleClusterDevice( + OnOffCluster, new (class extends BoundCluster { async toggle() { // Respond after sendFrame resolved @@ -89,7 +60,7 @@ describe('Cluster Response Timeout', function () { })(), ); - await sendingNode.endpoints[1].clusters.onOff.toggle(undefined, { timeout: 5000 }); + await zclNode.endpoints[1].clusters.onOff.toggle(undefined, { timeout: 5000 }); // Should have called clearTimeout to prevent timeout firing assert( @@ -99,63 +70,36 @@ describe('Cluster Response Timeout', function () { }); it('should not wait for response when waitForResponse is false', async function () { - const sendingNodeSendFrameSpy = mock.method(sendingNode, 'sendFrame'); - - receivingNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - toggle() { - // Respond with default response - } - })(), - ); + const { zclNode, frames, intercept } = togglingDevice(); + // A device that never answers: waiting would time out + intercept(({ from }) => (from === 'virtualDevice' ? null : undefined)); - // Send command without waiting for response - const result = await sendingNode.endpoints[1].clusters.onOff.toggle({ waitForResponse: false }); + const result = await zclNode.endpoints[1].clusters.onOff.toggle(undefined, { + waitForResponse: false, + }); - // Should return undefined since we're not waiting assert.strictEqual(result, undefined); - - // Only one frame should be sent (the command itself) - assert.strictEqual(sendingNodeSendFrameSpy.mock.callCount(), 1); + assert.strictEqual(frames.length, 1); }); it('should not resolve with a device-initiated frame that reuses the pending TSN', async function () { - const reportedValues = []; - sendingNode.endpoints[1].clusters.onOff.on('attr.onOff', (value) => reportedValues.push(value)); - - receivingNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - async toggle(args, meta, frame) { - // Device sends an attribute report numbered from its own counter, colliding with the TSN - await sendingNode.handleFrame( - 1, - OnOffCluster.ID, - Buffer.from([0x18, frame.trxSequenceNumber, 0x0a, 0x00, 0x00, 0x10, 0x01]), - ); - } - })(), + const { zclNode, intercept } = togglingDevice(); + // Device sends an attribute report numbered from its own counter, colliding with the TSN + collideBeforeReplies(intercept, (tsn) => + Buffer.from([0x18, tsn, 0x0a, 0x00, 0x00, 0x10, 0x01]), ); + const reportedValues = []; + zclNode.endpoints[1].clusters.onOff.on('attr.onOff', (value) => reportedValues.push(value)); - const result = await sendingNode.endpoints[1].clusters.onOff.toggle(); + const result = await zclNode.endpoints[1].clusters.onOff.toggle(); assert.deepStrictEqual(reportedValues, [true]); assert.strictEqual(result, undefined); }); it('should resolve getAlarm with a Get Alarm Response alone', async function () { - const pair = [AlarmsCluster.ID]; - const sender = new Node({ - sendFrame: (...args) => receiver.handleFrame(...args), - endpointDescriptors: [{ endpointId: 1, inputClusters: pair }], - }); - const receiver = new Node({ - sendFrame: (...args) => sender.handleFrame(...args), - endpointDescriptors: [{ endpointId: 1, inputClusters: pair }], - }); - receiver.endpoints[1].bind( - 'alarms', + const { zclNode } = createSingleClusterDevice( + AlarmsCluster, new (class extends BoundCluster { getAlarm() { return { @@ -168,14 +112,15 @@ describe('Cluster Response Timeout', function () { })(), ); - const { alarms } = sender.endpoints[1].clusters; - const response = await alarms.getAlarm(undefined, { timeout: 1000 }); + const response = await zclNode.endpoints[1].clusters.alarms.getAlarm(undefined, { + timeout: 1000, + }); assert.strictEqual(response.alarmCode, 2); assert.strictEqual(response.timeStamp, 1234); }); - it('should resolve a command without declared response on a cluster-specific reply', async function () { + it('should resolve a command without declared response on a cluster-specific reply', async function (t) { class TuyaLikeCluster extends Cluster { static get ID() { return 0xef00; @@ -197,44 +142,31 @@ describe('Cluster Response Timeout', function () { } } Cluster.addCluster(TuyaLikeCluster); + t.after(() => Cluster.removeCluster(TuyaLikeCluster.ID)); - try { - const pair = [TuyaLikeCluster.ID]; - const sender = new Node({ - sendFrame: (...args) => receiver.handleFrame(...args), - endpointDescriptors: [{ endpointId: 1, inputClusters: pair }], - }); - const receiver = new Node({ - sendFrame: (...args) => sender.handleFrame(...args), - endpointDescriptors: [{ endpointId: 1, inputClusters: pair }], - }); - receiver.endpoints[1].bind( - 'tuyaLike', - new (class extends BoundCluster { - // Reply like a Tuya MCU: its own command with the request's TSN, no Default Response - async datapoint({ value }, meta, frame) { - await sender.handleFrame( - 1, - TuyaLikeCluster.ID, - Buffer.from([0x19, frame.trxSequenceNumber, 0x01, value]), - ); - } - })(), - ); + const { zclNode, virtualDevice } = createSingleClusterDevice( + TuyaLikeCluster, + new (class extends BoundCluster { + // Reply like a Tuya MCU: its own command with the request's TSN, no Default Response + async datapoint({ value }, meta, frame) { + await virtualDevice.sendFrame( + 1, + TuyaLikeCluster.ID, + Buffer.from([0x19, frame.trxSequenceNumber, 0x01, value]), + ); + } + })(), + ); - const { tuyaLike } = sender.endpoints[1].clusters; - const response = await tuyaLike.datapoint( - { value: 7 }, - { disableDefaultResponse: true, timeout: 1000 }, - ); + const response = await zclNode.endpoints[1].clusters.tuyaLike.datapoint( + { value: 7 }, + { disableDefaultResponse: true, timeout: 1000 }, + ); - assert.strictEqual(response.value, 7); - } finally { - Cluster.removeCluster(TuyaLikeCluster.ID); - } + assert.strictEqual(response.value, 7); }); - it('should treat a declared response without id as the command id', async function () { + it('should treat a declared response without id as the command id', async function (t) { class EchoCluster extends Cluster { static get ID() { return 0xfc01; @@ -260,155 +192,100 @@ describe('Cluster Response Timeout', function () { } } Cluster.addCluster(EchoCluster); + t.after(() => Cluster.removeCluster(EchoCluster.ID)); - try { - const pair = [EchoCluster.ID]; - const sender = new Node({ - sendFrame: (...args) => receiver.handleFrame(...args), - endpointDescriptors: [{ endpointId: 1, inputClusters: pair }], - }); - const receiver = new Node({ - sendFrame: (...args) => sender.handleFrame(...args), - endpointDescriptors: [{ endpointId: 1, inputClusters: pair }], - }); - receiver.endpoints[1].bind( - 'echo', - new (class extends BoundCluster { - // An unrelated device command reuses the TSN first, then the real response (id 0x05) - async ping({ value }, meta, frame) { - await sender.handleFrame( - 1, - EchoCluster.ID, - Buffer.from([0x19, frame.trxSequenceNumber, 0x06, 99]), - ); - return { value }; - } - })(), - ); + const { zclNode, intercept } = createSingleClusterDevice( + EchoCluster, + new (class extends BoundCluster { + async ping({ value }) { + return { value }; + } + })(), + ); + // An unrelated device command reuses the TSN first, then the real response (id 0x05) + collideBeforeReplies(intercept, (tsn) => Buffer.from([0x19, tsn, 0x06, 99])); - const { echo } = sender.endpoints[1].clusters; - const response = await echo.ping({ value: 7 }, { timeout: 1000 }); + const response = await zclNode.endpoints[1].clusters.echo.ping({ value: 7 }, { timeout: 1000 }); - assert.strictEqual(response.value, 7); - } finally { - Cluster.removeCluster(EchoCluster.ID); - } + assert.strictEqual(response.value, 7); }); describe('pending response matching', function () { - // Node pair where the receiving side first sends `colliding(tsn)` with the request's TSN, - // then its real reply - function createCollidingPair(clusterId, colliding) { - const descriptors = [{ endpointId: 1, inputClusters: [clusterId] }]; - const sender = new Node({ - sendFrame: (...args) => receiver.handleFrame(...args), - endpointDescriptors: descriptors, - }); - const receiver = new Node({ - sendFrame: async (endpointId, frameClusterId, frame) => { - await sender.handleFrame(endpointId, frameClusterId, colliding(frame[1])); - await sender.handleFrame(endpointId, frameClusterId, frame); - }, - endpointDescriptors: descriptors, - }); - return { sender, receiver }; - } - it('should not resolve a global command on a cluster-specific frame with its response id', async function () { - // IAS Zone Enroll Request is cluster-specific 0x01, the same id as Read Attributes Response - const { sender, receiver } = createCollidingPair(IasZoneCluster.ID, (tsn) => - Buffer.from([0x19, tsn, 0x01, 0x0d, 0x00, 0x00, 0x00]), - ); - receiver.endpoints[1].bind( - 'iasZone', + const { zclNode, intercept } = createSingleClusterDevice( + IasZoneCluster, new (class extends BoundCluster { get zoneState() { return 'enrolled'; } })(), ); + // IAS Zone Enroll Request is cluster-specific 0x01, the same id as Read Attributes Response + collideBeforeReplies(intercept, (tsn) => + Buffer.from([0x19, tsn, 0x01, 0x0d, 0x00, 0x00, 0x00]), + ); - const { iasZone } = sender.endpoints[1].clusters; - const result = await iasZone.readAttributes(['zoneState'], { timeout: 1000 }); + const result = await zclNode.endpoints[1].clusters.iasZone.readAttributes(['zoneState'], { + timeout: 1000, + }); assert.deepStrictEqual(result, { zoneState: 'enrolled' }); }); it('should not resolve on a Default Response for another command', async function () { + const { zclNode, intercept } = togglingDevice(); // Default Response with FAILURE for cmdId 0x01 (on), while toggle (0x02) is pending - const { sender, receiver } = createCollidingPair(OnOffCluster.ID, (tsn) => - Buffer.from([0x18, tsn, 0x0b, 0x01, 0x01]), - ); - receiver.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - toggle() {} - })(), - ); + collideBeforeReplies(intercept, (tsn) => Buffer.from([0x18, tsn, 0x0b, 0x01, 0x01])); - await sender.endpoints[1].clusters.onOff.toggle(undefined, { timeout: 1000 }); + await zclNode.endpoints[1].clusters.onOff.toggle(undefined, { timeout: 1000 }); }); }); it('should resolve when the response arrives before sendFrame rejects', async function () { - receivingNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - toggle() {} - })(), - ); - sendingNode.sendFrame = async (endpointId, clusterId, frame) => { - await receivingNode.handleFrame(endpointId, clusterId, frame); + const { zclNode, settle } = togglingDevice(); + // The stack reports a failed send (e.g. no APS ACK in time) after the device already answered + const sendFrame = zclNode.sendFrame; + zclNode.sendFrame = async (...args) => { + await sendFrame(...args); + await settle(); throw new Error('device not responding'); }; - await sendingNode.endpoints[1].clusters.onOff.toggle(); + await zclNode.endpoints[1].clusters.onOff.toggle(); }); it('should reject when sendFrame rejects without a response', async function () { - sendingNode.sendFrame = async () => { + const { zclNode, intercept } = togglingDevice(); + intercept(() => { throw new Error('device not responding'); - }; + }); - await assert.rejects(sendingNode.endpoints[1].clusters.onOff.toggle(), /device not responding/); + await assert.rejects(zclNode.endpoints[1].clusters.onOff.toggle(), /device not responding/); }); it('should ignore a duplicate response without sending an error back', async function () { - const sentFrames = []; - const node = new Node({ - sendFrame: async (endpointId, clusterId, frame) => { - sentFrames.push(frame); - if (sentFrames.length > 1) return; - // The device answers with a Read Attributes Response (attribute 0x0004, SUCCESS, "_TZ3000") - // and sends that same response a second time, as some devices do - const response = Buffer.from([ - 0x18, - frame[1], - 0x01, - 0x04, - 0x00, - 0x00, - 0x42, - 0x07, - ...Buffer.from('_TZ3000'), - ]); - setImmediate(async () => { - await node.handleFrame(endpointId, clusterId, response, { dstEndpoint: 1 }); - await node.handleFrame(endpointId, clusterId, response, { dstEndpoint: 1 }); - }); - }, - endpointDescriptors: [{ endpointId: 1, inputClusters: [BasicCluster.ID] }], - }); + const { zclNode, frames, intercept, settle } = createSingleClusterDevice( + BasicCluster, + new (class extends BoundCluster { + async readAttributesStructured() { + // Attribute 0x0004, SUCCESS, string "_TZ3000" + return { + attributes: Buffer.from([0x04, 0x00, 0x00, 0x42, 0x07, ...Buffer.from('_TZ3000')]), + }; + } + })(), + ); + // The device sends its response a second time, as some devices do + intercept(({ from, raw }) => (from === 'virtualDevice' ? [raw, raw] : undefined)); - const { basic } = node.endpoints[1].clusters; - const response = await basic.readAttributesStructured( + const response = await zclNode.endpoints[1].clusters.basic.readAttributesStructured( { attributes: [{ attributeId: 4, indexPath: [] }] }, { timeout: 1000 }, ); - await new Promise(setImmediate); + await settle(); assert.strictEqual(response.attributes.subarray(5).toString(), '_TZ3000'); // Only the request went out, no error Default Response for the second copy - assert.strictEqual(sentFrames.length, 1); + assert.strictEqual(frames.filter(({ from }) => from === 'zclNode').length, 1); }); }); diff --git a/test/testMfgSpecificCommands.js b/test/testMfgSpecificCommands.js index 8bf9f29e..fc24ba55 100644 --- a/test/testMfgSpecificCommands.js +++ b/test/testMfgSpecificCommands.js @@ -73,291 +73,178 @@ class IkeaSceneCluster extends SceneCluster { } Cluster.addCluster(IkeaSceneCluster); -const { createMockNode } = require('./util'); +const { createSingleClusterDevice } = require('./util/virtualDevice'); -const node = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [SceneCluster.ID], - }, - ], -}); +/** + * Sends `command` from a scenes cluster to a virtual device that answers with BoundCluster's + * defaults, and returns what went on the wire. + */ +async function send(command, bound = new BoundCluster()) { + const { zclNode, frames } = createSingleClusterDevice(SceneCluster, bound); -class IkeaBoundCluster extends BoundCluster { - constructor({ - onReadAttributes, - onWriteAttributes, - onConfigureReporting, - onReadReportingConfiguration, - onIkeaSceneStep, - onIkeaSceneMove, - onIkeaSceneMoveStop, - } = {}) { - super(); - if (onReadAttributes) this.readAttributes = onReadAttributes; - if (onWriteAttributes) this.writeAttributes = onWriteAttributes; - if (onConfigureReporting) this.configureReporting = onConfigureReporting; - if (onReadReportingConfiguration) { - this.readReportingConfiguration = onReadReportingConfiguration; - } - if (onIkeaSceneStep) this.ikeaSceneStep = onIkeaSceneStep; - if (onIkeaSceneMove) this.ikeaSceneMove = onIkeaSceneMove; - if (onIkeaSceneMoveStop) this.ikeaSceneMoveStop = onIkeaSceneMoveStop; - } + const result = command(zclNode.endpoints[1].clusters.scenes); + return { result, frames }; +} + +/** Asserts the single frame the scenes cluster sent carries `manufacturerId`. */ +async function assertSentManufacturerId(command, manufacturerId) { + const { result, frames } = await send(command); + // Only the request is under test: BoundCluster's defaults reject some of these commands. + await result.catch(() => {}); + const sent = frames.filter((frame) => frame.from === 'zclNode'); + assert.strictEqual(sent.length, 1); + assert.strictEqual(sent[0].frame.manufacturerId, manufacturerId); +} + +/** Asserts that mixing manufacturers is rejected before anything is sent. */ +async function assertRejectsMixedManufacturers(command) { + const { result, frames } = await send(command); + await assert.rejects(result, /multiple manufacturer ids/); + assert.deepStrictEqual(frames, []); } describe('manufacturer specific commands', function () { - it('should execute custom commands', function (t, done) { - let commandsExecuted = 0; - const ikeaSceneStepCommand = { - mode: 'up', - stepSize: 1, - transitionTime: 13, - }; - const ikeaSceneMoveCommand = { - mode: 'up', - transitionTime: 13, - }; - const ikeaSceneMoveStop = { - duration: 1000, - }; - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onIkeaSceneStep: (payload) => { - commandsExecuted++; - assert.deepStrictEqual({ ...payload }, ikeaSceneStepCommand); - if (commandsExecuted === 3) done(); - }, - onIkeaSceneMove: (payload) => { - commandsExecuted++; - assert.deepStrictEqual({ ...payload }, ikeaSceneMoveCommand); - if (commandsExecuted === 3) done(); - }, - onIkeaSceneMoveStop: (payload) => { - commandsExecuted++; - assert.deepStrictEqual({ ...payload }, ikeaSceneMoveStop); - if (commandsExecuted === 3) done(); - }, - }), + it('should execute custom commands', async function () { + const received = {}; + const { result, frames } = await send( + async (scenes) => { + await scenes.ikeaSceneStep({ mode: 'up', stepSize: 1, transitionTime: 13 }); + await scenes.ikeaSceneMove({ mode: 'up', transitionTime: 13 }); + await scenes.ikeaSceneMoveStop({ duration: 1000 }); + }, + new (class extends BoundCluster { + async ikeaSceneStep(payload) { + received.ikeaSceneStep = { ...payload }; + } + + async ikeaSceneMove(payload) { + received.ikeaSceneMove = { ...payload }; + } + + async ikeaSceneMoveStop(payload) { + received.ikeaSceneMoveStop = { ...payload }; + } + })(), ); + await result; - node.endpoints[1].clusters['scenes'].ikeaSceneStep(ikeaSceneStepCommand); - node.endpoints[1].clusters['scenes'].ikeaSceneMove(ikeaSceneMoveCommand); - node.endpoints[1].clusters['scenes'].ikeaSceneMoveStop(ikeaSceneMoveStop); + assert.deepStrictEqual(received, { + ikeaSceneStep: { mode: 'up', stepSize: 1, transitionTime: 13 }, + ikeaSceneMove: { mode: 'up', transitionTime: 13 }, + ikeaSceneMoveStop: { duration: 1000 }, + }); + const sent = frames.filter((frame) => frame.from === 'zclNode'); + assert.deepStrictEqual( + sent.map(({ frame }) => frame.manufacturerId), + [0x117c, 0x117c, 0x117c], + ); }); describe('global commands', function () { describe('readAttributes', function () { - it('should set correct manufacturerId', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onReadAttributes: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_1, rawPayload.manufacturerId); - done(); - }, - }), + it('should set correct manufacturerId', async function () { + await assertSentManufacturerId( + (scenes) => scenes.readAttributes(['sceneCount']), + MANUFACTURER_ID_1, ); - node.endpoints[1].clusters['scenes'].readAttributes(['sceneCount']); }); - it('should throw when different manufacturerIds are found', function (t, done) { - node.endpoints[1].clusters['scenes'] - .readAttributes(['sceneCount', 'currentScene']) - .catch((err) => { - assert(err instanceof Error); - done(); - }); + it('should throw when different manufacturerIds are found', async function () { + await assertRejectsMixedManufacturers((scenes) => + scenes.readAttributes(['sceneCount', 'currentScene']), + ); }); - it('should set correct manufacturerId for multiple attributes', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onReadAttributes: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_2, rawPayload.manufacturerId); - done(); - }, - }), + it('should set correct manufacturerId for multiple attributes', async function () { + await assertSentManufacturerId( + (scenes) => scenes.readAttributes(['currentScene', 'currentGroup']), + MANUFACTURER_ID_2, ); - - node.endpoints[1].clusters['scenes'].readAttributes(['currentScene', 'currentGroup']); }); - it('should not set manufacturerId when zcl attribute is present', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onReadAttributes: (payload, meta, rawPayload) => { - assert.strictEqual(undefined, rawPayload.manufacturerId); - done(); - }, - }), + it('should not set manufacturerId when zcl attribute is present', async function () { + await assertSentManufacturerId( + (scenes) => scenes.readAttributes(['sceneValid']), + undefined, ); - - node.endpoints[1].clusters['scenes'].readAttributes(['sceneValid']); }); }); describe('writeAttributes', function () { - it('should set correct manufacturerId', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onWriteAttributes: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_1, rawPayload.manufacturerId); - done(); - }, - }), + it('should set correct manufacturerId', async function () { + await assertSentManufacturerId( + (scenes) => scenes.writeAttributes({ sceneCount: 1 }), + MANUFACTURER_ID_1, ); - node.endpoints[1].clusters['scenes'].writeAttributes({ sceneCount: 1 }); }); - it('should throw when different manufacturerIds are found', function (t, done) { - node.endpoints[1].clusters['scenes'] - .writeAttributes({ sceneCount: 1, currentScene: 2 }) - .catch((err) => { - assert(err instanceof Error); - done(); - }); + it('should throw when different manufacturerIds are found', async function () { + await assertRejectsMixedManufacturers((scenes) => + scenes.writeAttributes({ sceneCount: 1, currentScene: 2 }), + ); }); - it('should set correct manufacturerId for multiple attributes', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onWriteAttributes: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_2, rawPayload.manufacturerId); - done(); - }, - }), + it('should set correct manufacturerId for multiple attributes', async function () { + await assertSentManufacturerId( + (scenes) => scenes.writeAttributes({ currentScene: 1, currentGroup: 2 }), + MANUFACTURER_ID_2, ); - - node.endpoints[1].clusters['scenes'].writeAttributes({ currentScene: 1, currentGroup: 2 }); }); - it('should not set manufacturerId when zcl attribute is present', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onWriteAttributes: (payload, meta, rawPayload) => { - assert.strictEqual(undefined, rawPayload.manufacturerId); - done(); - }, - }), + it('should not set manufacturerId when zcl attribute is present', async function () { + await assertSentManufacturerId( + (scenes) => scenes.writeAttributes({ sceneValid: true }), + undefined, ); - - node.endpoints[1].clusters['scenes'].writeAttributes({ sceneValid: true }); }); }); describe('configureReporting', function () { - it('should set correct manufacturerId', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onConfigureReporting: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_1, rawPayload.manufacturerId); - done(); - }, - }), + const reporting = { minInterval: 0, maxInterval: 300, minChange: 1 }; + + it('should set correct manufacturerId', async function () { + await assertSentManufacturerId( + (scenes) => scenes.configureReporting({ sceneCount: reporting }), + MANUFACTURER_ID_1, ); - node.endpoints[1].clusters['scenes'].configureReporting({ - sceneCount: { minInterval: 0, maxInterval: 300, minChange: 1 }, - }); }); - it('should throw when different manufacturerIds are found', function (t, done) { - node.endpoints[1].clusters['scenes'] - .configureReporting({ - sceneCount: { minInterval: 0, maxInterval: 300, minChange: 1 }, - currentScene: { minInterval: 0, maxInterval: 300, minChange: 1 }, - }) - .catch((err) => { - assert(err instanceof Error); - done(); - }); + it('should throw when different manufacturerIds are found', async function () { + await assertRejectsMixedManufacturers((scenes) => + scenes.configureReporting({ sceneCount: reporting, currentScene: reporting }), + ); }); - it('should set correct manufacturerId for multiple attributes', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onConfigureReporting: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_2, rawPayload.manufacturerId); - done(); - }, - }), + it('should set correct manufacturerId for multiple attributes', async function () { + await assertSentManufacturerId( + (scenes) => + scenes.configureReporting({ currentScene: reporting, currentGroup: reporting }), + MANUFACTURER_ID_2, ); - node.endpoints[1].clusters['scenes'].configureReporting({ - currentScene: { minInterval: 0, maxInterval: 300, minChange: 1 }, - currentGroup: { minInterval: 0, maxInterval: 300, minChange: 1 }, - }); }); - it('should not set manufacturerId when zcl attribute is present', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onConfigureReporting: (payload, meta, rawPayload) => { - assert.strictEqual(undefined, rawPayload.manufacturerId); - done(); - }, - }), + it('should not set manufacturerId when zcl attribute is present', async function () { + await assertSentManufacturerId( + (scenes) => scenes.configureReporting({ sceneValid: reporting, currentGroup: reporting }), + undefined, ); - - node.endpoints[1].clusters['scenes'].configureReporting({ - sceneValid: { minInterval: 0, maxInterval: 300, minChange: 1 }, - currentGroup: { minInterval: 0, maxInterval: 300, minChange: 1 }, - }); }); }); describe('readReportingConfiguration', function () { - it('should set correct manufacturerId', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onReadReportingConfiguration: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_1, rawPayload.manufacturerId); - done(); - }, - }), + it('should set correct manufacturerId', async function () { + await assertSentManufacturerId( + (scenes) => scenes.readReportingConfiguration(['sceneCount']), + MANUFACTURER_ID_1, ); - node.endpoints[1].clusters['scenes'].readReportingConfiguration(['sceneCount']); }); - it('should throw when different manufacturerIds are found', function (t, done) { - node.endpoints[1].clusters['scenes'] - .readReportingConfiguration(['sceneCount', 'currentScene']) - .catch((err) => { - assert(err instanceof Error); - done(); - }); + it('should throw when different manufacturerIds are found', async function () { + await assertRejectsMixedManufacturers((scenes) => + scenes.readReportingConfiguration(['sceneCount', 'currentScene']), + ); }); - it('should set correct manufacturerId for multiple attributes', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onReadReportingConfiguration: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_2, rawPayload.manufacturerId); - done(); - }, - }), + it('should set correct manufacturerId for multiple attributes', async function () { + await assertSentManufacturerId( + (scenes) => scenes.readReportingConfiguration(['currentGroup', 'currentScene']), + MANUFACTURER_ID_2, ); - node.endpoints[1].clusters['scenes'].readReportingConfiguration([ - 'currentGroup', - 'currentScene', - ]); }); - it('should not set manufacturerId when zcl attribute is present', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onReadReportingConfiguration: (payload, meta, rawPayload) => { - assert.strictEqual(undefined, rawPayload.manufacturerId); - done(); - }, - }), + it('should not set manufacturerId when zcl attribute is present', async function () { + await assertSentManufacturerId( + (scenes) => scenes.readReportingConfiguration(['currentGroup', 'sceneValid']), + undefined, ); - - node.endpoints[1].clusters['scenes'].readReportingConfiguration([ - 'currentGroup', - 'sceneValid', - ]); }); }); }); diff --git a/test/testNode.js b/test/testNode.js index d0a9738a..e91bc6b1 100644 --- a/test/testNode.js +++ b/test/testNode.js @@ -1,123 +1,51 @@ 'use strict'; -const { describe, it, before, afterEach, mock } = require('node:test'); +const { describe, it, mock } = require('node:test'); const assert = require('node:assert'); -const { createMockNode, createConnectedNodePair } = require('./util'); -let { debug } = require('./util'); +const { createVirtualDevice, createSingleClusterDevice } = require('./util/virtualDevice'); const BoundCluster = require('../lib/BoundCluster'); const Cluster = require('../lib/Cluster'); const { ZCLDataTypes } = require('../lib/zclTypes'); -const Endpoint = require('../lib/Endpoint'); const BasicCluster = require('../lib/clusters/basic'); const OnOffCluster = require('../lib/clusters/onOff'); -debug = debug.extend('test-node'); +// Cluster-specific, client to server, TSN 1, command 0x02 (toggle) +const TOGGLE = Buffer.from([0x01, 0x01, 0x02]); -describe('Node', function () { - let mockNode; - let receivingNode; - let sendingNode; - before(function () { - mockNode = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [BasicCluster.ID], - }, - ], - }); - - [sendingNode, receivingNode] = createConnectedNodePair( - { - endpoints: [ - { - endpointId: 1, - inputClusters: [BasicCluster.ID, OnOffCluster.ID], - }, - ], - }, - { - endpoints: [ - { - endpointId: 1, - inputClusters: [BasicCluster.ID, OnOffCluster.ID], - }, - ], - }, - ); - // Override log id for sending node - const sendingNodeLog = (...args) => `sending-node:${args.join(':')}`; - sendingNode.getLogId = sendingNodeLog; - sendingNode.endpoints[1].getLogId = sendingNodeLog; - // Override log id for receiving node - const receivingNodeLog = (...args) => `receiving-node:${args.join(':')}`; - receivingNode.getLogId = receivingNodeLog; - receivingNode.endpoints[1].getLogId = receivingNodeLog; - }); - afterEach(function () { - mock.restoreAll(); - }); - it('should fail for unbound cluster', async function () { - try { - await mockNode.endpoints[1].clusters['basic'].configureReporting({ - zclVersion: { - minInterval: 1234, - maxInterval: 4321, - }, - }); - } catch { - return; - } - throw new Error("didn't throw"); - }); - - it('should fail for unimplemented command', async function () { - mockNode.endpoints[1].bind('basic', new BoundCluster()); - - try { - await mockNode.endpoints[1].clusters['basic'].factoryReset(); - } catch { - return; - } - throw new Error("didn't throw"); +/** A remote that sends onOff commands to the node under test, which serves them with `onOff`. */ +function remoteSendingTo(onOff) { + const virtual = createVirtualDevice({ + endpoints: [{ endpointId: 1, outputClusters: [OnOffCluster.ID] }], }); + virtual.zclNode.endpoints[1].bind('onOff', onOff); + return virtual; +} - it('should invoke command', async function () { - mockNode.endpoints[1].bind( - 'basic', - new (class extends BoundCluster { - async factoryReset() { - debug('factory reset'); - } - })(), - ); - - await mockNode.endpoints[1].clusters['basic'].factoryReset(); - }); +const summary = (frames) => frames.map(({ from, frame }) => [from, frame.cmdId]); +describe('Node', function () { it('should configure reporting', async function () { - mockNode.endpoints[1].bind( - 'basic', + let received; + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { async configureReporting({ reports }) { - assert.equal(reports.length, 1, 'exactly 1 report'); + received = reports; } })(), ); - await mockNode.endpoints[1].clusters['basic'].configureReporting({ - zclVersion: { - minInterval: 10, - maxInterval: 4321, - }, + await zclNode.endpoints[1].clusters.basic.configureReporting({ + zclVersion: { minInterval: 10, maxInterval: 4321 }, }); + + assert.strictEqual(received.length, 1, 'exactly 1 report'); }); it('should read attributes', async function () { - mockNode.endpoints[1].bind( - 'basic', + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { constructor() { super(); @@ -134,7 +62,7 @@ describe('Node', function () { })(), ); - const res = await mockNode.endpoints[1].clusters['basic'].readAttributes([ + const res = await zclNode.endpoints[1].clusters.basic.readAttributes([ 'modelId', 'manufacturerName', 'clusterRevision', @@ -142,15 +70,15 @@ describe('Node', function () { '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.manufacturerName, 'Athom', 'manufacturerName should be Athom'); + assert.equal(res.clusterRevision, 1, 'clusterRevision should be 1'); assert.equal(res.zclVersion, undefined, 'zclVersion should not be present'); - assert.equal(res.dateCode, '1234', 'dateCode should not be present'); + assert.equal(res.dateCode, '1234', 'dateCode should be 1234'); }); it('should read attributes by numeric ID', async function () { - mockNode.endpoints[1].bind( - 'basic', + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { get modelId() { return 'numericTest'; @@ -159,13 +87,13 @@ describe('Node', function () { ); // modelId has attribute ID 5 - const res = await mockNode.endpoints[1].clusters['basic'].readAttributes([5]); + const res = await zclNode.endpoints[1].clusters.basic.readAttributes([5]); assert.equal(res[5], 'numericTest', 'numeric ID 5 should return modelId value'); }); it('should read attributes with mixed strings and numeric IDs', async function () { - mockNode.endpoints[1].bind( - 'basic', + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { get modelId() { return 'mixedTest'; @@ -178,17 +106,14 @@ describe('Node', function () { ); // manufacturerName by name, modelId (ID 5) by numeric ID - const res = await mockNode.endpoints[1].clusters['basic'].readAttributes([ - 'manufacturerName', - 5, - ]); + const res = await zclNode.endpoints[1].clusters.basic.readAttributes(['manufacturerName', 5]); assert.equal(res.manufacturerName, 'Athom', 'manufacturerName should resolve by name'); assert.equal(res[5], 'mixedTest', 'numeric ID 5 should return modelId value'); }); it('should return both name and numeric key when same attribute requested both ways', async function () { - mockNode.endpoints[1].bind( - 'basic', + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { get modelId() { return 'both'; @@ -196,56 +121,32 @@ describe('Node', function () { })(), ); - const res = await mockNode.endpoints[1].clusters['basic'].readAttributes(['modelId', 5]); + const res = await zclNode.endpoints[1].clusters.basic.readAttributes(['modelId', 5]); assert.equal(res.modelId, 'both', 'modelId key should exist'); assert.equal(res[5], 'both', 'numeric key 5 should also exist'); }); it('should read an unregistered attribute by numeric ID', async function () { - mockNode.endpoints[1].bind('basic', new (class extends BoundCluster {})()); + const { zclNode } = createSingleClusterDevice(BasicCluster, new BoundCluster()); // 0x1234 is not registered in the basic cluster, expect no error and empty/missing result - const res = await mockNode.endpoints[1].clusters['basic'].readAttributes([0x1234]); + const res = await zclNode.endpoints[1].clusters.basic.readAttributes([0x1234]); assert.equal(res[0x1234], undefined, 'unregistered attribute should not have a value'); }); it('should throw for invalid numeric attribute IDs', async function () { - mockNode.endpoints[1].bind('basic', new (class extends BoundCluster {})()); - - await assert.rejects(() => mockNode.endpoints[1].clusters['basic'].readAttributes([-1]), { - name: 'TypeError', - }); - await assert.rejects(() => mockNode.endpoints[1].clusters['basic'].readAttributes([0x10000]), { - name: 'TypeError', - }); - await assert.rejects(() => mockNode.endpoints[1].clusters['basic'].readAttributes([1.5]), { - name: 'TypeError', - }); - await assert.rejects(() => mockNode.endpoints[1].clusters['basic'].readAttributes([NaN]), { - name: 'TypeError', - }); - }); - - 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 { zclNode, frames } = createSingleClusterDevice(BasicCluster, new BoundCluster()); + const { basic } = zclNode.endpoints[1].clusters; - const readAttributesResponse = await sendingNode.endpoints[1].clusters['basic'].readAttributes( - ['modelId'], - { disableDefaultResponse: true }, - ); - assert.equal(readAttributesResponse.modelId, 'test'); + for (const id of [-1, 0x10000, 1.5, NaN]) { + await assert.rejects(() => basic.readAttributes([id]), { name: 'TypeError' }); + } + assert.deepStrictEqual(frames, [], 'nothing should be sent'); }); - it('should not respond with a default response when disableDefaultResponse is true', async function () { - receivingNode.endpoints[1].bind( - 'basic', + it('should read attributes without a default response when disableDefaultResponse is true', async function () { + const { zclNode, frames, settle } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { get modelId() { return 'test'; @@ -253,159 +154,103 @@ describe('Node', function () { })(), ); - const sendingNodeSendFrameSpy = mock.method(sendingNode, 'sendFrame'); - const receivingNodeSendFrameSpy = mock.method(receivingNode, 'sendFrame'); - - await sendingNode.endpoints[1].clusters['basic'].readAttributes(['modelId'], { + const result = await zclNode.endpoints[1].clusters.basic.readAttributes(['modelId'], { disableDefaultResponse: true, }); - - // 1 frame sent by sending node, the read attributes command - assert.equal(sendingNodeSendFrameSpy.mock.callCount(), 1); - // 1 frame sent by receiving node, the read attributes response - assert.equal(receivingNodeSendFrameSpy.mock.callCount(), 1); - - // Parse the received frame - const frame = Endpoint.parseFrame(receivingNodeSendFrameSpy.mock.calls[0].arguments[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 = mock.method(sendingNode, 'sendFrame'); - const receivingNodeSendFrameSpy = mock.method(receivingNode, 'sendFrame'); - - await sendingNode.endpoints[1].clusters['onOff'].toggle(); - // 1 frame sent by sending node, the toggle command - assert.equal(sendingNodeSendFrameSpy.mock.callCount(), 1); - // 1 frame sent by receiving node, the toggle command default response - assert.equal(receivingNodeSendFrameSpy.mock.callCount(), 1); - - // Parse the received frame - const frame = Endpoint.parseFrame(receivingNodeSendFrameSpy.mock.calls[0].arguments[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 = mock.method(sendingNode, 'handleFrame'); - - // Execute toggle and expect UNSUP_COMMAND as the command is not implemented (ZCL R8 2.5.12.2) - await assert.rejects(sendingNode.endpoints[1].clusters['onOff'].toggle(), { - message: 'UNSUP_CLUSTER_COMMAND', - }); - - // 1 frame received by sending node, the default response error - assert.equal(sendingNodeHandleFrameSpy.mock.callCount(), 1); - - // Parse the received frame - const frame = Endpoint.parseFrame(sendingNodeHandleFrameSpy.mock.calls[0].arguments[2]); - assert.equal(frame.cmdId, 0x0b); - assert.equal(frame.data[1], 0x81); // UNSUP_COMMAND + await settle(); + + assert.deepStrictEqual(result, { modelId: 'test' }); + assert.strictEqual(frames[0].frame.frameControl.disableDefaultResponse, true); + // Read attributes, then its response and nothing else + assert.deepStrictEqual(summary(frames), [ + ['zclNode', 0x00], + ['virtualDevice', 0x01], + ]); }); it('should not respond to a group cast frame', async function () { - const toggledSpy = mock.fn(); - receivingNode.endpoints[1].bind( - 'onOff', + const toggled = mock.fn(); + const { virtualDevice, frames, settle } = remoteSendingTo( new (class extends BoundCluster { toggle() { - toggledSpy(); + toggled(); } })(), ); - const receivingNodeSendFrameSpy = mock.method(receivingNode, 'sendFrame'); + await virtualDevice.sendFrame(1, OnOffCluster.ID, TOGGLE, { groupId: 0x01 }); + await settle(); - // Send toggle command to onOff cluster with group id - await receivingNode.handleFrame(1, 6, Buffer.from([0x01, 0x01, 0x02]), { groupId: 0x01 }); - assert.equal(toggledSpy.mock.callCount(), 1); - // 0 frames sent by receiving node, no response to group commands - assert.equal(receivingNodeSendFrameSpy.mock.callCount(), 0); + assert.strictEqual(toggled.mock.callCount(), 1); + assert.deepStrictEqual(summary(frames), [['virtualDevice', 0x02]]); }); it('should not send a default response to a broadcast frame', async function () { - const toggledSpy = mock.fn(); - receivingNode.endpoints[1].bind( - 'onOff', + const toggled = mock.fn(); + const { virtualDevice, frames, settle } = remoteSendingTo( new (class extends BoundCluster { toggle() { - toggledSpy(); + toggled(); } })(), ); - const receivingNodeSendFrameSpy = mock.method(receivingNode, 'sendFrame'); + await virtualDevice.sendFrame(1, OnOffCluster.ID, TOGGLE, { broadcast: true, dstEndpoint: 1 }); + await settle(); - const meta = { broadcast: true, dstEndpoint: 1 }; - await receivingNode.handleFrame(1, 6, Buffer.from([0x01, 0x01, 0x02]), meta); - assert.equal(toggledSpy.mock.callCount(), 1); - assert.equal(receivingNodeSendFrameSpy.mock.callCount(), 0); + assert.strictEqual(toggled.mock.callCount(), 1); + assert.deepStrictEqual(summary(frames), [['virtualDevice', 0x02]]); }); it('should not send a default response error to a broadcast frame', async function () { - receivingNode.endpoints[1].bind('onOff', new (class extends BoundCluster {})()); - - const receivingNodeSendFrameSpy = mock.method(receivingNode, 'sendFrame'); + const { virtualDevice, frames, settle } = remoteSendingTo(new BoundCluster()); // toggle is not implemented, so this would otherwise get a default response error - await receivingNode.handleFrame(1, 6, Buffer.from([0x01, 0x01, 0x02]), { broadcast: true }); - assert.equal(receivingNodeSendFrameSpy.mock.callCount(), 0); + await virtualDevice.sendFrame(1, OnOffCluster.ID, TOGGLE, { broadcast: true }); + await settle(); + + assert.deepStrictEqual(summary(frames), [['virtualDevice', 0x02]]); }); it('should send a default response to a unicast frame for the broadcast endpoint', async function () { - receivingNode.endpoints[1].bind( - 'onOff', + const { virtualDevice, frames, settle } = remoteSendingTo( new (class extends BoundCluster { toggle() {} })(), ); - const receivingNodeSendFrameSpy = mock.method(receivingNode, 'sendFrame'); + await virtualDevice.sendFrame(1, OnOffCluster.ID, TOGGLE, { dstEndpoint: 0xff }); + await settle(); - await receivingNode.handleFrame(1, 6, Buffer.from([0x01, 0x01, 0x02]), { dstEndpoint: 0xff }); - assert.equal(receivingNodeSendFrameSpy.mock.callCount(), 1); + assert.deepStrictEqual(summary(frames), [ + ['virtualDevice', 0x02], + ['zclNode', 0x0b], + ]); }); it('should write attributes', async function () { - mockNode.endpoints[1].bind( - 'basic', + let written; + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { get modelId() { return 'test'; } - get manufacturerName() { - return 'Athom'; - } - - set modelId(val) { - assert.equal(val, 'test1'); + set modelId(value) { + written = value; } })(), ); - await mockNode.endpoints[1].clusters['basic'].writeAttributes({ - modelId: 'test1', - }); + await zclNode.endpoints[1].clusters.basic.writeAttributes({ modelId: 'test1' }); + + assert.strictEqual(written, 'test1'); }); it('should discover attributes', async function () { - mockNode.endpoints[1].bind( - 'basic', + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { get modelId() { return 'test'; @@ -414,45 +259,66 @@ describe('Node', function () { get manufacturerName() { return 'Athom'; } - - set modelId(val) { - assert.equal(val, 'test1'); - } })(), ); - const attrs = await mockNode.endpoints[1].clusters['basic'].discoverAttributes(); + const attrs = await zclNode.endpoints[1].clusters.basic.discoverAttributes(); ['modelId', 'manufacturerName'].forEach((a) => { assert(attrs.includes(a), `${a} is missing`); }); }); it('should discover received commands', async function () { - mockNode.endpoints[1].bind( - 'basic', + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { - async factoryReset() { - debug('factory reset'); - } + async factoryReset() {} })(), ); - const cmds = await mockNode.endpoints[1].clusters['basic'].discoverCommandsReceived(); + + const cmds = await zclNode.endpoints[1].clusters.basic.discoverCommandsReceived(); assert(cmds.includes('factoryReset')); }); - it('should support adding a Cluster with string attribute with 0x0000 ID', async function () { + // A noData (0x00) record must not be decoded with the attribute's own type, which for an + // attribute with id 0x0000 used to be mistaken for the record's type. + it('should decode a noData report for an attribute with id 0x0000', async function (t) { class MyCluster extends Cluster { static get ID() { return 0x1234; } + static get NAME() { + return 'myCluster'; + } + static get ATTRIBUTES() { return { priceOption: { id: 0x0000, type: ZCLDataTypes.string }, }; } } - Cluster.addCluster(MyCluster); + t.after(() => Cluster.removeCluster(MyCluster.ID)); + + const { zclNode, virtualDevice, frames, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [MyCluster.ID] }], + }); + const onPriceOption = mock.fn(); + zclNode.endpoints[1].clusters.myCluster.on('attr.priceOption', onPriceOption); + + // Report, server to client: attribute 0x0000, data type 0x00 (noData), no value + await virtualDevice.sendFrame( + 1, + MyCluster.ID, + Buffer.from([0x18, 0x01, 0x0a, 0x00, 0x00, 0x00]), + ); + await settle(); + + assert.strictEqual(onPriceOption.mock.callCount(), 1); + assert.deepStrictEqual( + frames.map(({ from }) => from), + ['virtualDevice'], + ); }); }); diff --git a/test/testTrxSequenceNumber.js b/test/testTrxSequenceNumber.js index 0fc29ca7..226718ba 100644 --- a/test/testTrxSequenceNumber.js +++ b/test/testTrxSequenceNumber.js @@ -1,67 +1,46 @@ 'use strict'; -const { describe, it, beforeEach, afterEach, mock } = require('node:test'); +const { describe, it } = require('node:test'); const assert = require('node:assert'); -const Node = require('../lib/Node'); const BoundCluster = require('../lib/BoundCluster'); const BasicCluster = require('../lib/clusters/basic'); const OnOffCluster = require('../lib/clusters/onOff'); +const { createVirtualDevice } = require('./util/virtualDevice'); describe('Transaction sequence number', function () { - let receivingNode; - let sendingNode; - let sentTrxSequenceNumbers; - - beforeEach(function () { - sentTrxSequenceNumbers = []; - sendingNode = new Node({ - sendFrame: (endpointId, clusterId, frame) => { - sentTrxSequenceNumbers.push(frame[1]); - receivingNode.handleFrame(endpointId, clusterId, frame); - }, - endpointDescriptors: [ - { endpointId: 1, inputClusters: [OnOffCluster.ID] }, - { endpointId: 2, inputClusters: [BasicCluster.ID] }, - ], - }); - - receivingNode = new Node({ - sendFrame: (...args) => { - sendingNode.handleFrame(...args); - }, - endpointDescriptors: [ - { endpointId: 1, inputClusters: [OnOffCluster.ID] }, + it('should share one counter across endpoints and clusters', async function () { + const { zclNode, frames } = createVirtualDevice({ + endpoints: [ + { + endpointId: 1, + inputClusters: [OnOffCluster.ID], + clusters: { + onOff: new (class extends BoundCluster { + toggle() {} + })(), + }, + }, { endpointId: 2, inputClusters: [BasicCluster.ID] }, ], }); - receivingNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - toggle() {} - })(), - ); - }); - afterEach(function () { - mock.restoreAll(); - }); - - it('should share one counter across endpoints and clusters', async function () { - await sendingNode.endpoints[1].clusters.onOff.toggle(); - const { basic } = sendingNode.endpoints[2].clusters; - await basic.factoryReset(undefined, { waitForResponse: false }); - await sendingNode.endpoints[1].clusters.onOff.toggle(); + await zclNode.endpoints[1].clusters.onOff.toggle(); + await zclNode.endpoints[2].clusters.basic.factoryReset(undefined, { waitForResponse: false }); + await zclNode.endpoints[1].clusters.onOff.toggle(); - const [first] = sentTrxSequenceNumbers; - assert.deepStrictEqual(sentTrxSequenceNumbers, [first, (first + 1) % 256, (first + 2) % 256]); + const sent = frames + .filter(({ from }) => from === 'zclNode') + .map(({ frame }) => frame.trxSequenceNumber); + const [first] = sent; + assert.deepStrictEqual(sent, [first, (first + 1) % 256, (first + 2) % 256]); }); - it('should start at a random value and wrap around', function () { - mock.method(Math, 'random', () => 0.999); - const node = new Node({ sendFrame: () => {}, endpointDescriptors: [] }); + it('should start at a random value and wrap around', function (t) { + t.mock.method(Math, 'random', () => 0.999); + const { zclNode } = createVirtualDevice({ endpoints: [] }); - assert.strictEqual(node.nextSeqNr(), 0); - assert.strictEqual(node.nextSeqNr(), 1); + assert.strictEqual(zclNode.nextSeqNr(), 0); + assert.strictEqual(zclNode.nextSeqNr(), 1); }); }); diff --git a/test/testZCLError.js b/test/testZCLError.js index 108aea28..7a84b757 100644 --- a/test/testZCLError.js +++ b/test/testZCLError.js @@ -3,313 +3,120 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); -const Node = require('../lib/Node'); const OnOffCluster = require('../lib/clusters/onOff'); const LevelControlCluster = require('../lib/clusters/levelControl'); const IasZoneCluster = require('../lib/clusters/iasZone'); -const { ZCLStandardHeader } = require('../lib/zclFrames'); const { ZCLError } = require('../lib/util'); -const { createMockNode } = require('./util'); - -/** - * Creates a connected node pair where frames sent from the device back to the controller - * are captured before being forwarded. - */ -function createSpyNodePair() { - const capturedFrames = []; - let controllerNode; - let deviceNode; - - const mockController = { - sendFrame: (endpointId, clusterId, data) => deviceNode.handleFrame(endpointId, clusterId, data), - endpointDescriptors: [{ endpointId: 1, inputClusters: [OnOffCluster.ID], outputClusters: [] }], - }; - - const mockDevice = { - sendFrame: (endpointId, clusterId, data) => { - capturedFrames.push({ endpointId, clusterId, data }); - return controllerNode.handleFrame(endpointId, clusterId, data); - }, - endpointDescriptors: [{ endpointId: 1, inputClusters: [OnOffCluster.ID], outputClusters: [] }], - }; - - controllerNode = new Node(mockController); - deviceNode = new Node(mockDevice); - - return { controllerNode, deviceNode, capturedFrames }; +const { + createVirtualDevice, + createSingleClusterDevice, + frameToClient, +} = require('./util/virtualDevice'); + +const SET_ON_CMD_ID = OnOffCluster.COMMANDS.setOn.id; +const DEFAULT_RESPONSE_CMD_ID = 0x0b; + +/** A device whose setOn handler throws `error`. */ +function onOffDeviceThrowing(error) { + return createSingleClusterDevice( + OnOffCluster, + new (class extends BoundCluster { + async setOn() { + throw error; + } + })(), + ); } -function makeToClientFrame(cmdId) { - const header = new ZCLStandardHeader(); - header.frameControl.clusterSpecific = true; - header.frameControl.directionToClient = true; - header.trxSequenceNumber = 1; - header.cmdId = cmdId; - header.data = Buffer.alloc(0); - return header.toBuffer(); +/** The single Default Response the virtual device sent, as [command id it answers, status]. */ +function deviceDefaultResponse(frames) { + const responses = frames.filter(({ from }) => from === 'virtualDevice'); + assert.strictEqual(responses.length, 1); + assert.strictEqual(responses[0].frame.cmdId, DEFAULT_RESPONSE_CMD_ID); + return [...responses[0].frame.data]; } -describe('ZCLError handling', function () { - it('should return ZCLError status code in Default Response when BoundCluster throws ZCLError', async function () { - const node = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [OnOffCluster.ID], - }, - ], - }); - - node.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - async setOn() { - throw new ZCLError('NOT_AUTHORIZED'); - } - })(), - ); - - await assert.rejects( - () => node.endpoints[1].clusters.onOff.setOn(), - (err) => { - assert.strictEqual(err.message, 'NOT_AUTHORIZED'); - return true; - }, - ); +/** Has the device send a cluster-specific server-to-client frame; returns the node's answer. */ +async function nodeAnswerTo(clusterId, cmdId, data, inputClusters = [OnOffCluster.ID]) { + const { virtualDevice, frames, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters }], }); + const frame = frameToClient({ cmdId, data, clusterSpecific: true, trxSequenceNumber: 1 }); + await virtualDevice.sendFrame(1, clusterId, frame); + await settle(); + + const answers = frames.filter(({ from }) => from === 'zclNode'); + assert.strictEqual(answers.length, 1); + assert.strictEqual(answers[0].frame.cmdId, DEFAULT_RESPONSE_CMD_ID); + return [...answers[0].frame.data]; +} - it('should return FAILURE status in Default Response when BoundCluster throws a generic Error', async function () { - const node = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [OnOffCluster.ID], - }, - ], +describe('ZCLError handling', function () { + // ZCL status bytes from the spec, not from our own status table + for (const [description, error, status, statusByte] of [ + ['a ZCLError', new ZCLError('NOT_AUTHORIZED'), 'NOT_AUTHORIZED', 0x7e], + ['a ZCLError', new ZCLError('INVALID_VALUE'), 'INVALID_VALUE', 0x87], + ['a ZCLError', new ZCLError('INSUFFICIENT_SPACE'), 'INSUFFICIENT_SPACE', 0x89], + ['a generic Error', new Error('something went wrong'), 'FAILURE', 0x01], + ]) { + it(`should answer ${status} (0x${statusByte.toString(16)}) when BoundCluster throws ${description}`, async function () { + const { zclNode, frames } = onOffDeviceThrowing(error); + + await assert.rejects(zclNode.endpoints[1].clusters.onOff.setOn(), { message: status }); + + assert.deepStrictEqual(deviceDefaultResponse(frames), [SET_ON_CMD_ID, statusByte]); }); - - node.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - async setOn() { - throw new Error('something went wrong'); - } - })(), - ); - - await assert.rejects( - () => node.endpoints[1].clusters.onOff.setOn(), - (err) => { - assert.strictEqual(err.message, 'FAILURE'); - return true; - }, - ); - }); - - it('should encode ZCLError status code in Default Response frame bytes', async function () { - // NOT_AUTHORIZED = 0x7e per ZCL spec - const NOT_AUTHORIZED_STATUS_BYTE = 0x7e; - const SET_ON_CMD_ID = OnOffCluster.COMMANDS.setOn.id; - const DEFAULT_RESPONSE_CMD_ID = 0x0b; - - const { controllerNode, deviceNode, capturedFrames } = createSpyNodePair(); - - deviceNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - async setOn() { - throw new ZCLError('NOT_AUTHORIZED'); - } - })(), - ); - - await assert.rejects(() => controllerNode.endpoints[1].clusters.onOff.setOn()); - - // capturedFrames only contains frames sent from the device back to the controller. - // Find the Default Response frame among the captured device → controller frames. - const defaultResponseFrame = capturedFrames - .map((f) => ZCLStandardHeader.fromBuffer(f.data)) - .find((frame) => frame.cmdId === DEFAULT_RESPONSE_CMD_ID); - - assert.ok(defaultResponseFrame, 'Default Response frame was sent'); - assert.strictEqual( - defaultResponseFrame.data[0], - SET_ON_CMD_ID, - 'Default Response should reference the received setOn command ID', - ); - assert.strictEqual( - defaultResponseFrame.data[1], - NOT_AUTHORIZED_STATUS_BYTE, - 'Default Response should encode NOT_AUTHORIZED (0x7e) as the status byte', - ); - }); - - it('should encode FAILURE (0x01) in Default Response frame bytes when a generic Error is thrown', async function () { - const FAILURE_STATUS_BYTE = 0x01; - const SET_ON_CMD_ID = OnOffCluster.COMMANDS.setOn.id; - const DEFAULT_RESPONSE_CMD_ID = 0x0b; - - const { controllerNode, deviceNode, capturedFrames } = createSpyNodePair(); - - deviceNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - async setOn() { - throw new Error('something went wrong'); - } - })(), - ); - - await assert.rejects(() => controllerNode.endpoints[1].clusters.onOff.setOn()); - - const defaultResponseFrame = capturedFrames - .map((f) => ZCLStandardHeader.fromBuffer(f.data)) - .find((frame) => frame.cmdId === DEFAULT_RESPONSE_CMD_ID); - - assert.ok(defaultResponseFrame, 'Default Response frame was sent'); - assert.strictEqual( - defaultResponseFrame.data[0], - SET_ON_CMD_ID, - 'Default Response should reference the received setOn command ID', - ); - assert.strictEqual( - defaultResponseFrame.data[1], - FAILURE_STATUS_BYTE, - 'Default Response should encode FAILURE (0x01) as the status byte', - ); - }); + } it('should send SUCCESS status byte when ZCLError("SUCCESS") is thrown', async function () { - const SUCCESS_STATUS_BYTE = 0x00; - const SET_ON_CMD_ID = OnOffCluster.COMMANDS.setOn.id; - const DEFAULT_RESPONSE_CMD_ID = 0x0b; - - const { controllerNode, deviceNode, capturedFrames } = createSpyNodePair(); - - deviceNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - async setOn() { - throw new ZCLError('SUCCESS'); - } - })(), - ); - - // The controller should not reject — a SUCCESS default response resolves the command - await controllerNode.endpoints[1].clusters.onOff.setOn(); + const { zclNode, frames } = onOffDeviceThrowing(new ZCLError('SUCCESS')); - const defaultResponseFrame = capturedFrames - .map((f) => ZCLStandardHeader.fromBuffer(f.data)) - .find((frame) => frame.cmdId === DEFAULT_RESPONSE_CMD_ID); + // A SUCCESS default response resolves the command + await zclNode.endpoints[1].clusters.onOff.setOn(); - assert.ok(defaultResponseFrame, 'Default Response frame was sent'); - assert.strictEqual( - defaultResponseFrame.data[0], - SET_ON_CMD_ID, - 'Default Response should reference the received setOn command ID', - ); - assert.strictEqual( - defaultResponseFrame.data[1], - SUCCESS_STATUS_BYTE, - 'Default Response should encode SUCCESS (0x00) as the status byte', - ); + assert.deepStrictEqual(deviceDefaultResponse(frames), [SET_ON_CMD_ID, 0x00]); }); // ZCL R8 2.5.12.2 it('should send UNSUP_COMMAND (0x81) for a command the BoundCluster does not implement', async function () { - const { controllerNode, deviceNode, capturedFrames } = createSpyNodePair(); - deviceNode.endpoints[1].bind('onOff', new BoundCluster()); + const { zclNode, frames } = createSingleClusterDevice(OnOffCluster, new BoundCluster()); - await assert.rejects(() => controllerNode.endpoints[1].clusters.onOff.setOn(), { + await assert.rejects(zclNode.endpoints[1].clusters.onOff.setOn(), { message: 'UNSUP_CLUSTER_COMMAND', }); - const defaultResponseFrame = ZCLStandardHeader.fromBuffer(capturedFrames[0].data); - assert.strictEqual(defaultResponseFrame.cmdId, 0x0b); - assert.strictEqual(defaultResponseFrame.data[1], 0x81); + assert.deepStrictEqual(deviceDefaultResponse(frames), [SET_ON_CMD_ID, 0x81]); }); it('should send UNSUP_COMMAND (0x81) for an unknown command id on a client cluster', async function () { - const { deviceNode, capturedFrames } = createSpyNodePair(); - await deviceNode.handleFrame(1, OnOffCluster.ID, makeToClientFrame(0x7f), {}); - assert.strictEqual(ZCLStandardHeader.fromBuffer(capturedFrames[0].data).data[1], 0x81); + const answer = await nodeAnswerTo(OnOffCluster.ID, 0x7f, Buffer.alloc(0)); + assert.deepStrictEqual(answer, [0x7f, 0x81]); }); // A known request without a handler is still unsupported; only responses without a pending // request are ignored it('should send UNSUP_COMMAND (0x81) for a server-to-client request without a handler', async function () { - const capturedFrames = []; - const node = new Node({ - sendFrame: async (endpointId, clusterId, data) => { - capturedFrames.push(data); - }, - endpointDescriptors: [{ endpointId: 1, inputClusters: [IasZoneCluster.ID] }], - }); - const header = new ZCLStandardHeader(); - header.frameControl.clusterSpecific = true; - header.frameControl.directionToClient = true; - header.trxSequenceNumber = 1; - header.cmdId = 0x00; // zoneStatusChangeNotification - header.data = Buffer.from([0x01, 0x00, 0x00, 0x01, 0x00, 0x00]); - - await node.handleFrame(1, IasZoneCluster.ID, header.toBuffer(), {}); - await new Promise(setImmediate); - - const defaultResponse = ZCLStandardHeader.fromBuffer(capturedFrames[0]); - assert.strictEqual(defaultResponse.cmdId, 0x0b); - assert.strictEqual(defaultResponse.data[1], 0x81); + const answer = await nodeAnswerTo( + IasZoneCluster.ID, + 0x00, // zoneStatusChangeNotification + Buffer.from([0x01, 0x00, 0x00, 0x01, 0x00, 0x00]), + [IasZoneCluster.ID], + ); + assert.deepStrictEqual(answer, [0x00, 0x81]); }); it('should send UNSUPPORTED_CLUSTER (0xc3) for a server-to-client frame on a cluster the endpoint lacks', async function () { - const { deviceNode, capturedFrames } = createSpyNodePair(); - await deviceNode.handleFrame(1, LevelControlCluster.ID, makeToClientFrame(0x00), {}); - assert.strictEqual(ZCLStandardHeader.fromBuffer(capturedFrames[0].data).data[1], 0xc3); + const answer = await nodeAnswerTo(LevelControlCluster.ID, 0x00, Buffer.alloc(0)); + assert.deepStrictEqual(answer, [0x00, 0xc3]); }); it('should send UNSUPPORTED_CLUSTER (0xc3) when the endpoint has no binding for the cluster', async function () { - const { controllerNode, capturedFrames } = createSpyNodePair(); + const { zclNode, frames } = createSingleClusterDevice(OnOffCluster); - await assert.rejects(() => controllerNode.endpoints[1].clusters.onOff.setOn(), { + await assert.rejects(zclNode.endpoints[1].clusters.onOff.setOn(), { message: 'UNSUPPORTED_CLUSTER', }); - const defaultResponseFrame = ZCLStandardHeader.fromBuffer(capturedFrames[0].data); - assert.strictEqual(defaultResponseFrame.cmdId, 0x0b); - assert.strictEqual(defaultResponseFrame.data[1], 0xc3); - }); - - it('should propagate different ZCLError status codes', async function () { - const statuses = ['NOT_AUTHORIZED', 'INVALID_VALUE', 'INSUFFICIENT_SPACE']; - - for (const status of statuses) { - const node = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [OnOffCluster.ID], - }, - ], - }); - - node.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - async setOn() { - throw new ZCLError(status); - } - })(), - ); - - await assert.rejects( - () => node.endpoints[1].clusters.onOff.setOn(), - (err) => { - assert.strictEqual(err.message, status); - return true; - }, - ); - } + assert.deepStrictEqual(deviceDefaultResponse(frames), [SET_ON_CMD_ID, 0xc3]); }); }); diff --git a/test/util/index.js b/test/util/index.js deleted file mode 100644 index dadbea9c..00000000 --- a/test/util/index.js +++ /dev/null @@ -1,21 +0,0 @@ -'use strict'; - -let { debug } = require('../../lib/util'); - -debug = debug.extend('test'); - -const { - createMockNode, - createConnectedNodePair, - createBoundClusterWithAttributes, - MOCK_DEVICES, -} = require('./mockNode'); - -module.exports = { - debug, - // Mock node utilities - createMockNode, - createConnectedNodePair, - createBoundClusterWithAttributes, - MOCK_DEVICES, -}; diff --git a/test/util/mockNode.js b/test/util/mockNode.js deleted file mode 100644 index 59e5f47c..00000000 --- a/test/util/mockNode.js +++ /dev/null @@ -1,324 +0,0 @@ -'use strict'; - -const Node = require('../../lib/Node'); -const BoundCluster = require('../../lib/BoundCluster'); - -// Load clusters and get their IDs -const BasicCluster = require('../../lib/clusters/basic'); -const PowerConfigurationCluster = require('../../lib/clusters/powerConfiguration'); -const IASZoneCluster = require('../../lib/clusters/iasZone'); -const TemperatureMeasurementCluster = require('../../lib/clusters/temperatureMeasurement'); -const RelativeHumidityCluster = require('../../lib/clusters/relativeHumidity'); -const OnOffCluster = require('../../lib/clusters/onOff'); -const MeteringCluster = require('../../lib/clusters/metering'); -const ElectricalMeasurementCluster = require('../../lib/clusters/electricalMeasurement'); - -/** - * Creates a BoundCluster with getter properties for each attribute. - * - * @param {Object} attributes - Attribute name/value pairs - * @returns {BoundCluster} Configured BoundCluster - */ -function createBoundClusterWithAttributes(attributes) { - const ClusterClass = class extends BoundCluster { - constructor() { - super(); - // Store mutable attributes - this._attributes = { ...attributes }; - } - }; - - // Define getters/setters for each attribute - Object.keys(attributes).forEach((attrName) => { - Object.defineProperty(ClusterClass.prototype, attrName, { - get() { - return this._attributes[attrName]; - }, - set(value) { - this._attributes[attrName] = value; - }, - enumerable: true, - configurable: true, - }); - }); - - return new ClusterClass(); -} - -/** - * Creates a mock device Node with configurable cluster attributes. - * - * @example - * const mockMotionSensor = createMockDevice({ - * endpoints: [{ - * endpointId: 1, - * inputClusters: [0x0000, 0x0001, 0x0500], - * clusters: { - * iasZone: { - * zoneType: 0x000D, // Motion sensor - * zoneState: 1, // Enrolled - * zoneStatus: 0, - * }, - * powerConfiguration: { - * batteryPercentageRemaining: 180, // 90% - * }, - * }, - * }], - * }); - * - * @param {Object} config - Device configuration - * @param {Array} config.endpoints - Endpoint configurations - * @param {number} config.endpoints[].endpointId - Endpoint ID - * @param {number[]} config.endpoints[].inputClusters - Input cluster IDs - * @param {number[]} [config.endpoints[].outputClusters] - Output cluster IDs - * @param {Object} [config.endpoints[].clusters] - Cluster attribute values keyed by cluster name - * @param {boolean} [config.loopback] - If true, frames are looped back to the node's handleFrame - * @returns {Node} Configured mock Node - */ -function createMockNode(config) { - const endpointDescriptors = config.endpoints.map((ep) => ({ - endpointId: ep.endpointId, - inputClusters: ep.inputClusters || [], - outputClusters: ep.outputClusters || [], - })); - - // Create node reference first so sendFrame can reference it - let node; - const mockNode = { - sendFrame: config.loopback ? (...args) => node.handleFrame(...args) : () => Promise.resolve(), - endpointDescriptors, - }; - - node = new Node(mockNode); - - // Bind clusters with preset attribute values - config.endpoints.forEach((ep) => { - if (!ep.clusters) return; - - Object.entries(ep.clusters).forEach(([clusterName, attributes]) => { - const boundCluster = createBoundClusterWithAttributes(attributes); - node.endpoints[ep.endpointId].bind(clusterName, boundCluster); - }); - }); - - return node; -} - -/** - * Creates a pair of connected mock nodes that can send frames to each other. - * - * @param {Object} config1 - Configuration for first node - * @param {Object} config2 - Configuration for second node - * @returns {[Node, Node]} Tuple of connected nodes - */ -function createConnectedNodePair(config1, config2) { - const endpointDescriptors1 = config1.endpoints.map((ep) => ({ - endpointId: ep.endpointId, - inputClusters: ep.inputClusters || [], - outputClusters: ep.outputClusters || [], - })); - - const endpointDescriptors2 = config2.endpoints.map((ep) => ({ - endpointId: ep.endpointId, - inputClusters: ep.inputClusters || [], - outputClusters: ep.outputClusters || [], - })); - - let node1; - let node2; - - const mockNode1 = { - sendFrame: (...args) => node2.handleFrame(...args), - endpointDescriptors: endpointDescriptors1, - }; - - const mockNode2 = { - sendFrame: (...args) => node1.handleFrame(...args), - endpointDescriptors: endpointDescriptors2, - }; - - node1 = new Node(mockNode1); - node2 = new Node(mockNode2); - - // Bind clusters for node1 - config1.endpoints.forEach((ep) => { - if (!ep.clusters) return; - Object.entries(ep.clusters).forEach(([clusterName, attributes]) => { - const boundCluster = createBoundClusterWithAttributes(attributes); - node1.endpoints[ep.endpointId].bind(clusterName, boundCluster); - }); - }); - - // Bind clusters for node2 - config2.endpoints.forEach((ep) => { - if (!ep.clusters) return; - Object.entries(ep.clusters).forEach(([clusterName, attributes]) => { - const boundCluster = createBoundClusterWithAttributes(attributes); - node2.endpoints[ep.endpointId].bind(clusterName, boundCluster); - }); - }); - - return [node1, node2]; -} - -/** - * Preset device configurations for common device types. - */ -const MOCK_DEVICES = { - /** - * IAS Zone Motion Sensor - */ - motionSensor: (overrides = {}) => - createMockNode({ - endpoints: [ - { - endpointId: 1, - inputClusters: [BasicCluster.ID, PowerConfigurationCluster.ID, IASZoneCluster.ID], - clusters: { - basic: { - zclVersion: 3, - manufacturerName: 'MockDevice', - modelId: 'MockMotionSensor', - ...overrides.basic, - }, - powerConfiguration: { - batteryPercentageRemaining: 200, // 100% - ...overrides.powerConfiguration, - }, - iasZone: { - zoneState: 1, // Enrolled - zoneType: 0x000d, // Motion sensor - zoneStatus: 0, - iasCIEAddress: '0x0000000000000000', - zoneId: 1, - ...overrides.iasZone, - }, - }, - }, - ], - }), - - /** - * IAS Zone Contact Sensor (door/window) - */ - contactSensor: (overrides = {}) => - createMockNode({ - endpoints: [ - { - endpointId: 1, - inputClusters: [BasicCluster.ID, PowerConfigurationCluster.ID, IASZoneCluster.ID], - clusters: { - basic: { - zclVersion: 3, - manufacturerName: 'MockDevice', - modelId: 'MockContactSensor', - ...overrides.basic, - }, - powerConfiguration: { - batteryPercentageRemaining: 200, - ...overrides.powerConfiguration, - }, - iasZone: { - zoneState: 1, - zoneType: 0x0015, // Contact switch - zoneStatus: 0, - iasCIEAddress: '0x0000000000000000', - zoneId: 1, - ...overrides.iasZone, - }, - }, - }, - ], - }), - - /** - * Temperature + Humidity Sensor - */ - tempHumiditySensor: (overrides = {}) => - createMockNode({ - endpoints: [ - { - endpointId: 1, - inputClusters: [ - BasicCluster.ID, - PowerConfigurationCluster.ID, - TemperatureMeasurementCluster.ID, - RelativeHumidityCluster.ID, - ], - clusters: { - basic: { - zclVersion: 3, - manufacturerName: 'MockDevice', - modelId: 'MockTempHumidity', - ...overrides.basic, - }, - powerConfiguration: { - batteryPercentageRemaining: 200, - ...overrides.powerConfiguration, - }, - temperatureMeasurement: { - measuredValue: 2150, // 21.50°C - minMeasuredValue: -4000, - maxMeasuredValue: 8500, - ...overrides.temperatureMeasurement, - }, - relativeHumidity: { - measuredValue: 6500, // 65.00% - minMeasuredValue: 0, - maxMeasuredValue: 10000, - ...overrides.relativeHumidity, - }, - }, - }, - ], - }), - - /** - * Smart Plug with Power Metering - */ - smartPlug: (overrides = {}) => - createMockNode({ - endpoints: [ - { - endpointId: 1, - inputClusters: [ - BasicCluster.ID, - OnOffCluster.ID, - MeteringCluster.ID, - ElectricalMeasurementCluster.ID, - ], - clusters: { - basic: { - zclVersion: 3, - manufacturerName: 'MockDevice', - modelId: 'MockSmartPlug', - ...overrides.basic, - }, - onOff: { - onOff: false, - ...overrides.onOff, - }, - metering: { - currentSummationDelivered: 12345678, - multiplier: 1, - divisor: 1000, - ...overrides.metering, - }, - electricalMeasurement: { - activePower: 1500, // 150.0W - rmsVoltage: 2300, // 230.0V - rmsCurrent: 652, // 0.652A - ...overrides.electricalMeasurement, - }, - }, - }, - ], - }), -}; - -module.exports = { - createMockNode, - createConnectedNodePair, - createBoundClusterWithAttributes, - MOCK_DEVICES, -}; diff --git a/test/util/virtualDevice.js b/test/util/virtualDevice.js new file mode 100644 index 00000000..a323c53f --- /dev/null +++ b/test/util/virtualDevice.js @@ -0,0 +1,165 @@ +'use strict'; + +// Taken before a test can mock timers: delivery must not stop when a test fakes time. +const { setImmediate } = require('node:timers'); + +const Node = require('../../lib/Node'); +const Endpoint = require('../../lib/Endpoint'); +const BoundCluster = require('../../lib/BoundCluster'); +const { ZCLStandardHeader } = require('../../lib/zclFrames'); +const { MAX_ZCL_FRAME_BYTES } = require('../../lib/constants'); + +// ponytail: counts every frame of a device, raise it when a legit test (e.g. an OTA transfer) +// sends more +const MAX_FRAMES = 1000; + +/** A BoundCluster serving `attributes`: reads return them, writes replace them. */ +function boundClusterWithAttributes(attributes) { + const values = { ...attributes }; + const bound = new BoundCluster(); + // Accessors: writeAttributes treats a field without a setter as read-only. + for (const name of Object.keys(values)) { + Object.defineProperty(bound, name, { + get: () => values[name], + set: (value) => { + values[name] = value; + }, + enumerable: true, + }); + } + return bound; +} + +/** + * Creates the node under test (`zclNode`) and a scripted device (`virtualDevice`), linked in + * memory. docs/testing.md explains the idea. + * + * @param {object} config + * @param {object[]} config.endpoints - The device's endpoints as its descriptors list them + * (`endpointId`, `inputClusters`, `outputClusters`), plus `clusters`: per cluster name, attribute + * values or a `BoundCluster` that serves it. + * @param {number} [config.maxFrameSize] - Longest frame `zclNode` may send. + * @returns {object} `zclNode` and `virtualDevice`; `frames`, every frame sent as `{ from, + * endpointId, clusterId, frame, raw }`; `intercept(fn)`, where `fn(record)` returns `undefined` + * to pass a frame, `null` to drop it or buffers to send instead, and throwing fails the sender's + * `sendFrame`; `settle()`, which waits until no frame is in flight and rejects when a delivery + * failed. + */ +function createVirtualDevice({ endpoints, maxFrameSize = MAX_ZCL_FRAME_BYTES }) { + const frames = []; + const interceptors = []; + const inFlight = new Set(); + const nodes = {}; + let failure; + + const link = (from, to) => ({ + endpointDescriptors: endpoints.map((ep) => ({ + endpointId: ep.endpointId, + inputClusters: ep.inputClusters || [], + outputClusters: ep.outputClusters || [], + })), + async sendFrame(endpointId, clusterId, data, meta) { + if (frames.length >= MAX_FRAMES) { + // The node sending this may swallow the error, so settle() rethrows it. + failure = failure || new Error(`More than ${MAX_FRAMES} frames, are the nodes in a loop?`); + throw failure; + } + if (from === 'zclNode' && data.length > maxFrameSize) { + throw new Error(`ZCL frame too long, got ${data.length} bytes, max: ${maxFrameSize}.`); + } + + let raws = [data]; + for (const interceptor of interceptors) { + raws = raws.flatMap((raw) => { + const result = interceptor({ + from, + endpointId, + clusterId, + frame: Endpoint.parseFrame(raw), + raw, + }); + if (result === undefined) return [raw]; + if (result === null) return []; + return result; + }); + } + if (!raws.every(Buffer.isBuffer)) throw new TypeError('An interceptor returned a non-Buffer'); + + for (const raw of raws) { + frames.push({ from, endpointId, clusterId, frame: Endpoint.parseFrame(raw), raw }); + // Delivered after sendFrame resolves, as on Homey where sendFrame resolves on the APS ACK + // and the response comes later, so a command's response timeout really runs. + const delivery = new Promise((resolve) => setImmediate(resolve)) + .then(() => nodes[to].handleFrame(endpointId, clusterId, raw, meta)) + .catch((err) => { + failure = failure || err; + }) + .finally(() => inFlight.delete(delivery)); + inFlight.add(delivery); + } + }, + }); + + nodes.zclNode = new Node(link('zclNode', 'virtualDevice')); + + // The device sends commands on its output clusters, so those get a Cluster on its side. + const virtualDeviceLink = link('virtualDevice', 'zclNode'); + virtualDeviceLink.endpointDescriptors = virtualDeviceLink.endpointDescriptors.map((ep) => ({ + ...ep, + inputClusters: ep.outputClusters, + })); + nodes.virtualDevice = new Node(virtualDeviceLink); + + endpoints.forEach((ep) => { + Object.entries(ep.clusters || {}).forEach(([clusterName, cluster]) => { + nodes.virtualDevice.endpoints[ep.endpointId].bind( + clusterName, + cluster instanceof BoundCluster ? cluster : boundClusterWithAttributes(cluster), + ); + }); + }); + + return { + zclNode: nodes.zclNode, + virtualDevice: nodes.virtualDevice, + frames, + intercept: (interceptor) => interceptors.push(interceptor), + async settle() { + while (inFlight.size) await Promise.all(inFlight); + if (failure) throw failure; + }, + }; +} + +/** A device with endpoint 1 serving `ClusterClass`, by `bound` when given. */ +function createSingleClusterDevice(ClusterClass, bound) { + return createVirtualDevice({ + endpoints: [ + { + endpointId: 1, + inputClusters: [ClusterClass.ID], + clusters: bound ? { [ClusterClass.NAME]: bound } : {}, + }, + ], + }); +} + +/** A server-to-client frame, as a device sends a report or a notification. */ +function frameToClient({ + cmdId, + data = Buffer.alloc(0), + clusterSpecific = false, + trxSequenceNumber = 0, + disableDefaultResponse = false, +}) { + const header = new ZCLStandardHeader(); + header.frameControl.directionToClient = true; + header.frameControl.clusterSpecific = clusterSpecific; + header.frameControl.disableDefaultResponse = disableDefaultResponse; + header.trxSequenceNumber = trxSequenceNumber; + header.cmdId = cmdId; + header.data = data; + return header.toBuffer(); +} + +module.exports = { createVirtualDevice, createSingleClusterDevice, frameToClient }; diff --git a/test/virtualDevice.js b/test/virtualDevice.js new file mode 100644 index 00000000..b36ba0df --- /dev/null +++ b/test/virtualDevice.js @@ -0,0 +1,192 @@ +'use strict'; + +const { describe, it, mock } = require('node:test'); +const assert = require('node:assert'); + +const BoundCluster = require('../lib/BoundCluster'); +const OnOffCluster = require('../lib/clusters/onOff'); +const MeteringCluster = require('../lib/clusters/metering'); +const { + createVirtualDevice, + createSingleClusterDevice, + frameToClient, +} = require('./util/virtualDevice'); + +const ON_OFF = OnOffCluster.ATTRIBUTES.onOff.id; +const ON_TIME = OnOffCluster.ATTRIBUTES.onTime.id; + +describe('Virtual device', function () { + it('answers a read and records both frames', async function () { + const { zclNode, frames } = createVirtualDevice({ + endpoints: [ + { endpointId: 1, inputClusters: [OnOffCluster.ID], clusters: { onOff: { onOff: true } } }, + ], + }); + + const result = await zclNode.endpoints[1].clusters.onOff.readAttributes(['onOff']); + + assert.deepStrictEqual(result, { onOff: true }); + assert.deepStrictEqual( + frames.map(({ from, frame }) => [from, frame.cmdId]), + [ + ['zclNode', 0x00], + ['virtualDevice', 0x01], + ], + ); + }); + + it('lets attribute values be written', async function () { + const { zclNode, virtualDevice } = createVirtualDevice({ + endpoints: [ + { endpointId: 1, inputClusters: [OnOffCluster.ID], clusters: { onOff: { onTime: 1 } } }, + ], + }); + + await zclNode.endpoints[1].clusters.onOff.writeAttributes({ onTime: 7 }); + + assert.strictEqual(virtualDevice.endpoints[1].bindings.onOff.onTime, 7); + }); + + it('times out when an interceptor drops the response', async function (t) { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { zclNode, intercept, settle } = createVirtualDevice({ + endpoints: [ + { endpointId: 1, inputClusters: [OnOffCluster.ID], clusters: { onOff: { onOff: true } } }, + ], + }); + intercept((frame) => (frame.from === 'virtualDevice' ? null : undefined)); + + const read = zclNode.endpoints[1].clusters.onOff.readAttributes(['onOff']); + await settle(); + t.mock.timers.tick(10_000); + + await assert.rejects(read, /Timeout: Expected Response/); + }); + + it('rejects a frame longer than the Homey stack sends', async function () { + const { zclNode } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [OnOffCluster.ID] }], + maxFrameSize: 2, + }); + + await assert.rejects(zclNode.endpoints[1].clusters.onOff.toggle(), /ZCL frame too long/); + }); + + it('fails settle when the nodes keep answering each other', async function () { + const { zclNode, virtualDevice, settle } = createVirtualDevice({ + endpoints: [ + { endpointId: 1, inputClusters: [OnOffCluster.ID], outputClusters: [OnOffCluster.ID] }, + ], + }); + // Each side answers a toggle with a toggle of its own. Not waiting for responses keeps no + // response timers alive after the test. + const NO_WAIT = { waitForResponse: false }; + const toggleBack = (node) => + new (class extends BoundCluster { + toggle() { + node.endpoints[1].clusters.onOff.toggle(undefined, NO_WAIT).catch(() => {}); + } + })(); + virtualDevice.endpoints[1].bind('onOff', toggleBack(virtualDevice)); + zclNode.endpoints[1].bind('onOff', toggleBack(zclNode)); + + zclNode.endpoints[1].clusters.onOff.toggle(undefined, NO_WAIT).catch(() => {}); + + await assert.rejects(settle(), /are the nodes in a loop/); + }); +}); + +describe('Virtual device: known bugs on master', function () { + it('readAttributes stops when the device answers with unrequested ids', async function () { + // Whatever is asked, the plug answers with onTime only + const { zclNode, intercept } = createSingleClusterDevice( + OnOffCluster, + new (class extends BoundCluster { + async readAttributes() { + const buf = Buffer.alloc(255); + const records = [{ id: ON_TIME, status: 'SUCCESS', value: 5 }]; + const len = OnOffCluster.attributeArrayStatusDataType.toBuffer(buf, records, 0); + return { attributes: buf.subarray(0, len) }; + } + })(), + ); + let reads = 0; + intercept((frame) => { + // Guard: on master the read loops forever. + if (frame.from === 'zclNode' && ++reads > 3) throw new Error('read loop'); + }); + + const result = await zclNode.endpoints[1].clusters.onOff.readAttributes(['onOff']); + + assert.strictEqual(reads, 1); + assert.deepStrictEqual(result, {}); + }); + + it('readAttributes([]) on metering splits the request to fit a frame', async function () { + const { zclNode } = createVirtualDevice({ + endpoints: [ + { + endpointId: 1, + inputClusters: [MeteringCluster.ID], + clusters: { + metering: { currentSummationDelivered: 1234, multiplier: 1, divisor: 1000 }, + }, + }, + ], + }); + + // Every request must also fit the Homey stack, which the virtual link enforces. + const { currentSummationDelivered, multiplier, divisor } = + await zclNode.endpoints[1].clusters.metering.readAttributes([]); + + assert.deepStrictEqual( + { currentSummationDelivered, multiplier, divisor }, + { currentSummationDelivered: 1234, multiplier: 1, divisor: 1000 }, + ); + }); + + it('Endpoint.unbind removes the binding, so the cluster is unsupported', async function () { + const { zclNode, virtualDevice } = createVirtualDevice({ + endpoints: [{ endpointId: 1, outputClusters: [OnOffCluster.ID] }], + }); + zclNode.endpoints[1].bind('onOff', new BoundCluster()); + zclNode.endpoints[1].unbind('onOff'); + + await assert.rejects( + virtualDevice.endpoints[1].clusters.onOff.toggle(), + /^Error: UNSUPPORTED_CLUSTER$/, + ); + }); + + it('a report with an unknown attribute id emits attr., not attr.undefined', async function () { + const { zclNode, virtualDevice, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [OnOffCluster.ID] }], + }); + const onOff = zclNode.endpoints[1].clusters.onOff; + const onUndefined = mock.fn(); + const onUnknown = mock.fn(); + const onOnOff = mock.fn(); + onOff.on('attr.undefined', onUndefined); + onOff.on(`attr.${0x4242}`, onUnknown); + onOff.on('attr.onOff', onOnOff); + + const report = frameToClient({ + cmdId: 0x0a, // reportAttributes + disableDefaultResponse: true, + // 0x4242 uint8 (0x20) 1, then onOff bool (0x10) true + data: Buffer.from([0x42, 0x42, 0x20, 0x01, ON_OFF, 0x00, 0x10, 0x01]), + }); + await virtualDevice.sendFrame(1, OnOffCluster.ID, report); + await settle(); + + assert.deepStrictEqual( + onOnOff.mock.calls.map((call) => call.arguments[0]), + [true], + ); + assert.deepStrictEqual( + onUnknown.mock.calls.map((call) => call.arguments[0]), + [1], + ); + assert.strictEqual(onUndefined.mock.callCount(), 0); + }); +}); diff --git a/test/xiaomiLifeline.js b/test/xiaomiLifeline.js new file mode 100644 index 00000000..906edf70 --- /dev/null +++ b/test/xiaomiLifeline.js @@ -0,0 +1,137 @@ +'use strict'; + +// Xiaomi devices report state in Basic attribute 0xff01: a string (0x42) on the wire holding +// id/type/value records. A cluster that declares its own type for it must get that type used. + +const { describe, it, mock } = require('node:test'); +const assert = require('node:assert'); + +const { ZCLDataTypes, ZCLDataType } = require('../lib/zclTypes'); +const Cluster = require('../lib/Cluster'); +const BasicCluster = require('../lib/clusters/basic'); +const { createVirtualDevice } = require('./util/virtualDevice'); + +const LIFELINE_RECORDS = { + 1: 'batteryVoltage', + 3: 'cpuTemperature', + 4: 'unk_4', + 6: 'txCount', + 100: 'state0', +}; + +// One record: id (uint8), data type (uint8), value of that type. At least 3 bytes, no ZCL id. +const XiaomiLifelineDataRecord = new ZCLDataType( + NaN, + 'XiaomiLifelineDataRecord', + -3, + () => { + throw new Error('not_supported'); + }, + (buf, i = 0, returnLength) => { + const start = i; + const id = ZCLDataTypes.uint8.fromBuffer(buf, i); + const dataTypeId = ZCLDataTypes.uint8.fromBuffer(buf, i + 1); + i += 2; + + const DataType = Object.values(ZCLDataTypes).find((type) => type && type.id === dataTypeId); + if (!DataType) throw new TypeError(`Invalid Type for Attribute: ${id}`); + + const entry = DataType.fromBuffer(buf, i, true); + let value; + if (DataType.length > 0) { + value = entry; + i += DataType.length; + } else { + value = entry.result; + i += entry.length; + } + + const result = { name: LIFELINE_RECORDS[id] || `unknown_attr_${id}`, value }; + return returnLength ? { result, length: i - start } : result; + }, +); + +// The records, wrapped in the length-prefixed string the device puts on the wire. +const XiaomiLifelineDataRecordArray = new ZCLDataType( + NaN, + 'XiaomiLifelineDataRecordArray', + -1, + () => { + throw new Error('not_supported'); + }, + (buf, i, returnLength) => { + const { result: records, length } = ZCLDataTypes.buffer8.fromBuffer(buf, i, true); + const result = Object.fromEntries( + ZCLDataTypes.Array0(XiaomiLifelineDataRecord) + .fromBuffer(records, 0) + .map(({ name, value }) => [name, value]), + ); + return returnLength ? { result, length } : result; + }, +); + +class XiaomiBasicCluster extends BasicCluster { + static get ATTRIBUTES() { + return { + ...super.ATTRIBUTES, + xiaomiLifeline: { id: 0xff01, type: XiaomiLifelineDataRecordArray }, + }; + } +} + +/** A string attribute: data type 0x42, length byte, then the bytes. */ +const string = (bytes) => Buffer.from([0x42, bytes.length, ...bytes]); + +// One lifeline record per line: id, data type, little-endian value +const LIFELINE = Buffer.from([ + ...[0x01, 0x21, 0xb8, 0x0b], // batteryVoltage, uint16 3000 + ...[0x03, 0x28, 0x16], // cpuTemperature, int8 22 + ...[0x04, 0x21, 0xd2, 0x04], // unk_4, uint16 1234 + ...[0x06, 0x24, 0x70, 0x11, 0x01, 0x00, 0x00], // txCount, uint40 70000 + ...[0x08, 0x21, 0x00, 0x01], // no name in the table, uint16 256 + ...[0x64, 0x10, 0x01], // state0, bool true +]); + +// An attribute report shaped like the ones Xiaomi devices send: manufacturer specific with +// Xiaomi's code 0x115f, modelId first, then 0xff01 as a string holding the records. +const XIAOMI_REPORT = Buffer.concat([ + Buffer.from([0x1c, 0x5f, 0x11, 0x01, 0x0a]), // frame control, manufacturer, TSN, report + Buffer.from([0x05, 0x00]), // modelId + string(Buffer.from('lumi.virtual.sensor')), + Buffer.from([0x01, 0xff]), // 0xff01 + string(LIFELINE), +]); + +describe('Xiaomi lifeline attribute', function () { + it('should decode a report with the type the cluster declares, not the one on the wire', async function (t) { + Cluster.addCluster(XiaomiBasicCluster); + t.after(() => Cluster.addCluster(BasicCluster)); + + const { zclNode, virtualDevice, frames, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [BasicCluster.ID] }], + }); + const { basic } = zclNode.endpoints[1].clusters; + const onModelId = mock.fn(); + const onLifeline = mock.fn(); + basic.on('attr.modelId', onModelId); + basic.on('attr.xiaomiLifeline', onLifeline); + + await virtualDevice.sendFrame(1, BasicCluster.ID, XIAOMI_REPORT); + await settle(); + + assert.deepStrictEqual(onModelId.mock.calls[0].arguments[0], 'lumi.virtual.sensor'); + assert.deepStrictEqual(onLifeline.mock.calls[0].arguments[0], { + batteryVoltage: 3000, + cpuTemperature: 22, + unk_4: 1234, + txCount: 70000, + unknown_attr_8: 256, + state0: true, + }); + // Disable Default Response is set, so the node does not answer + assert.deepStrictEqual( + frames.map(({ from }) => from), + ['virtualDevice'], + ); + }); +});