From 5aecc81494420777f35db34bf1e4e9f1edbbb33c Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Wed, 8 Jun 2022 11:01:07 +0200 Subject: [PATCH] feat(test): implement communication between mock controller and mock nodes (#4697) --- .vscode/launch.json | 4 +- package.json | 2 +- packages/cc/package.json | 5 +- packages/cc/src/cc/CRC16CC.ts | 1 + packages/cc/src/cc/MultiChannelCC.ts | 2 + packages/cc/src/cc/MultiCommandCC.ts | 1 + packages/cc/src/cc/SupervisionCC.ts | 1 + packages/cc/src/cc/index.ts | 38 +++ packages/cc/src/lib/CommandClass.ts | 13 +- .../core/src/capabilities/NodeInfo.test.ts | 6 +- packages/core/src/capabilities/NodeInfo.ts | 28 +- packages/serial/src/message/Message.ts | 15 +- packages/testing/src/MockController.ts | 168 +++++++++-- .../testing/src/MockControllerCapabilities.ts | 2 + packages/testing/src/MockNode.ts | 264 +++++++++++++++++- packages/testing/src/MockNodeCapabilities.ts | 27 +- packages/testing/src/MockZWaveFrame.ts | 60 ++++ packages/testing/src/index.ts | 1 + packages/zwave-js/src/Utils.ts | 1 + .../lib/controller/MockControllerBehaviors.ts | 262 ++++++++++++++++- .../src/lib/controller/MockControllerState.ts | 9 + .../src/lib/node/MockNodeBehaviors.ts | 40 +++ .../application/ApplicationCommandRequest.ts | 1 + .../application/ApplicationUpdateRequest.ts | 126 ++++++--- .../BridgeApplicationCommandRequest.ts | 1 + .../serialapi/capability/HardResetRequest.ts | 18 +- .../AssignSUCReturnRouteMessages.ts | 93 +++--- .../network-mgmt/RequestNodeInfoMessages.ts | 76 +++-- .../serialapi/transport/SendDataMessages.ts | 118 ++++---- test/debug.js | 11 +- test/mock.ts | 17 +- test/run.ts | 8 +- 32 files changed, 1178 insertions(+), 241 deletions(-) create mode 100644 packages/testing/src/MockZWaveFrame.ts create mode 100644 packages/zwave-js/src/lib/controller/MockControllerState.ts create mode 100644 packages/zwave-js/src/lib/node/MockNodeBehaviors.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 63863a5a688..86c77c70550 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -39,7 +39,9 @@ "./maintenance/esbuild-register.js", "${workspaceFolder}/test/mock.ts" ], - "env": {}, + "env": { + "NO_CACHE": "true" + }, "console": "integratedTerminal", "skipFiles": ["/**"], "sourceMaps": true, diff --git a/package.json b/package.json index 5a981071232..d7f0cbbebcd 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ }, "scripts": { "foreach": "yarn workspaces foreach -pvi --exclude @zwave-js/repo", - "prebuild": "yarn workspace @zwave-js/maintenance run build", + "prebuild": "yarn workspace @zwave-js/maintenance run build && yarn workspace @zwave-js/cc run prebuild", "build": "yarn prebuild && yarn workspace zwave-js run build", "watch": "yarn prebuild && yarn workspace zwave-js run watch", "clean": "rm -rf packages/*/{build,*.tsbuildinfo}", diff --git a/packages/cc/package.json b/packages/cc/package.json index 4ed14f29ae1..881f2ffe0f9 100644 --- a/packages/cc/package.json +++ b/packages/cc/package.json @@ -49,9 +49,10 @@ }, "scripts": { "b": "yarn ts maintenance/_build.ts", - "build": "yarn b prebuild && tsc -b tsconfig.build.json --verbose", + "prebuild": "yarn b prebuild", + "build": "yarn run prebuild && tsc -b tsconfig.build.json --verbose", "clean": "tsc -b tsconfig.build.json --clean", - "watch": "yarn b prebuild && tsc -b tsconfig.build.json --watch --pretty", + "watch": "yarn run prebuild && tsc -b tsconfig.build.json --watch --pretty", "lint_zwave": "yarn b lint", "ts": "node -r esbuild-register" }, diff --git a/packages/cc/src/cc/CRC16CC.ts b/packages/cc/src/cc/CRC16CC.ts index 9b77af6908b..2b0ed56b023 100644 --- a/packages/cc/src/cc/CRC16CC.ts +++ b/packages/cc/src/cc/CRC16CC.ts @@ -102,6 +102,7 @@ export class CRC16CCCommandEncapsulation extends CRC16CC { data: ccBuffer, fromEncapsulation: true, encapCC: this, + origin: options.origin, }); } else { this.encapsulated = options.encapsulated; diff --git a/packages/cc/src/cc/MultiChannelCC.ts b/packages/cc/src/cc/MultiChannelCC.ts index c437a00a3e3..9ff8aca31da 100644 --- a/packages/cc/src/cc/MultiChannelCC.ts +++ b/packages/cc/src/cc/MultiChannelCC.ts @@ -1137,6 +1137,7 @@ export class MultiChannelCCCommandEncapsulation extends MultiChannelCC { data: this.payload.slice(2), fromEncapsulation: true, encapCC: this, + origin: options.origin, }); } else { this.encapsulated = options.encapsulated; @@ -1315,6 +1316,7 @@ export class MultiChannelCCV1CommandEncapsulation extends MultiChannelCC { data: this.payload.slice(isV2withV1Header ? 2 : 1), fromEncapsulation: true, encapCC: this, + origin: options.origin, }); } else { this.encapsulated = options.encapsulated; diff --git a/packages/cc/src/cc/MultiCommandCC.ts b/packages/cc/src/cc/MultiCommandCC.ts index 68ffadf82e0..04b366df1ce 100644 --- a/packages/cc/src/cc/MultiCommandCC.ts +++ b/packages/cc/src/cc/MultiCommandCC.ts @@ -105,6 +105,7 @@ export class MultiCommandCCCommandEncapsulation extends MultiCommandCC { ), fromEncapsulation: true, encapCC: this, + origin: options.origin, }), ); offset += 1 + cmdLength; diff --git a/packages/cc/src/cc/SupervisionCC.ts b/packages/cc/src/cc/SupervisionCC.ts index f44253b230c..4df4e9bcf5d 100644 --- a/packages/cc/src/cc/SupervisionCC.ts +++ b/packages/cc/src/cc/SupervisionCC.ts @@ -276,6 +276,7 @@ export class SupervisionCCGet extends SupervisionCC { data: this.payload.slice(2), fromEncapsulation: true, encapCC: this, + origin: options.origin, }); } else { this.sessionId = getNextSessionId(); diff --git a/packages/cc/src/cc/index.ts b/packages/cc/src/cc/index.ts index a98c9e2caa7..ecaa0bc6953 100644 --- a/packages/cc/src/cc/index.ts +++ b/packages/cc/src/cc/index.ts @@ -517,3 +517,41 @@ export { WakeUpCCWakeUpNotification, } from "./WakeUpCC"; export { ZWavePlusCC, ZWavePlusCCGet, ZWavePlusCCReport } from "./ZWavePlusCC"; +export { + ZWaveProtocolCC, + ZWaveProtocolCCAcceptLost, + ZWaveProtocolCCAssignIDs, + ZWaveProtocolCCAssignReturnRoute, + ZWaveProtocolCCAssignReturnRoutePriority, + ZWaveProtocolCCAssignSUCReturnRoute, + ZWaveProtocolCCAssignSUCReturnRoutePriority, + ZWaveProtocolCCAutomaticControllerUpdateStart, + ZWaveProtocolCCCommandComplete, + ZWaveProtocolCCExcludeRequest, + ZWaveProtocolCCFindNodesInRange, + ZWaveProtocolCCGetNodesInRange, + ZWaveProtocolCCLost, + ZWaveProtocolCCNewNodeRegistered, + ZWaveProtocolCCNewRangeRegistered, + ZWaveProtocolCCNodeInformationFrame, + ZWaveProtocolCCNodesExist, + ZWaveProtocolCCNodesExistReply, + ZWaveProtocolCCNOPPower, + ZWaveProtocolCCRangeInfo, + ZWaveProtocolCCRequestNodeInformationFrame, + ZWaveProtocolCCReservedIDs, + ZWaveProtocolCCReserveNodeIDs, + ZWaveProtocolCCSetNWIMode, + ZWaveProtocolCCSetSUC, + ZWaveProtocolCCSetSUCAck, + ZWaveProtocolCCSmartStartIncludedNodeInformation, + ZWaveProtocolCCSmartStartInclusionRequest, + ZWaveProtocolCCSmartStartPrime, + ZWaveProtocolCCStaticRouteRequest, + ZWaveProtocolCCSUCNodeID, + ZWaveProtocolCCTransferEnd, + ZWaveProtocolCCTransferNewPrimaryControllerComplete, + ZWaveProtocolCCTransferNodeInformation, + ZWaveProtocolCCTransferPresentation, + ZWaveProtocolCCTransferRangeInformation, +} from "./ZWaveProtocolCC"; diff --git a/packages/cc/src/lib/CommandClass.ts b/packages/cc/src/lib/CommandClass.ts index a7be11a3782..901d6047aad 100644 --- a/packages/cc/src/lib/CommandClass.ts +++ b/packages/cc/src/lib/CommandClass.ts @@ -21,6 +21,7 @@ import { ZWaveErrorCodes, } from "@zwave-js/core"; import type { ZWaveApplicationHost, ZWaveHost } from "@zwave-js/host"; +import { MessageOrigin } from "@zwave-js/serial"; import { buffer2hex, getEnumMemberName, @@ -40,7 +41,10 @@ import { isCommandClassContainer, } from "./ICommandClassContainer"; -export type CommandClassDeserializationOptions = { data: Buffer } & ( +export type CommandClassDeserializationOptions = { + data: Buffer; + origin?: MessageOrigin; +} & ( | { fromEncapsulation?: false; nodeId: number; @@ -67,6 +71,7 @@ interface CommandClassCreationOptions extends CCCommandOptions { ccId?: number; // Used to overwrite the declared CC ID ccCommand?: number; // undefined = NoOp payload?: Buffer; + origin?: undefined; } function gotCCCommandOptions(options: any): options is CCCommandOptions { @@ -144,7 +149,11 @@ export class CommandClass implements ICommandClass { if (this instanceof InvalidCC) return; - if (this.isSinglecast() && this.nodeId !== NODE_ID_BROADCAST) { + if ( + options.origin !== MessageOrigin.Host && + this.isSinglecast() && + this.nodeId !== NODE_ID_BROADCAST + ) { // For singlecast CCs, set the CC version as high as possible this.version = this.host.getSafeCCVersionForNode( this.ccId, diff --git a/packages/core/src/capabilities/NodeInfo.test.ts b/packages/core/src/capabilities/NodeInfo.test.ts index 66881ad553d..8d4b5dd9164 100644 --- a/packages/core/src/capabilities/NodeInfo.test.ts +++ b/packages/core/src/capabilities/NodeInfo.test.ts @@ -37,7 +37,7 @@ describe("lib/node/NodeInfo", () => { describe("parseNodeUpdatePayload()", () => { const payload = Buffer.from([ 5, // NodeID - 2, // CC list length + 5, // remaining length 0x03, // Slave 0x01, // Remote Controller 0x02, // Portable Scene Controller @@ -52,7 +52,7 @@ describe("lib/node/NodeInfo", () => { }); it("should extract the correct BasicDeviceClass", () => { - expect(nup.basic).toBe(3); + expect(nup.basicDeviceClass).toBe(3); }); it("should extract the correct GenericDeviceClass", () => { @@ -73,7 +73,7 @@ describe("lib/node/NodeInfo", () => { it("correctly parses extended CCs", () => { const payload = Buffer.from([ 5, // NodeID - 6, // CC list length + 9, // remaining length 0x03, 0x01, 0x02, // Portable Scene Controller diff --git a/packages/core/src/capabilities/NodeInfo.ts b/packages/core/src/capabilities/NodeInfo.ts index 865c8036421..8ba78cf5bc2 100644 --- a/packages/core/src/capabilities/NodeInfo.ts +++ b/packages/core/src/capabilities/NodeInfo.ts @@ -21,20 +21,34 @@ export function parseApplicationNodeInformation( export interface NodeUpdatePayload extends ApplicationNodeInformation { nodeId: number; - basic: number; + basicDeviceClass: number; } export function parseNodeUpdatePayload(nif: Buffer): NodeUpdatePayload { - const numCCs = nif[1]; - // The application node info starts at 3, and contains 2+N bytes - validatePayload(nif.length >= 3 + 2 + numCCs); + const nodeId = nif[0]; + const remainingLength = nif[1]; + validatePayload(nif.length >= 2 + remainingLength); return { - nodeId: nif[0], - basic: nif[2], - ...parseApplicationNodeInformation(nif.slice(3, 3 + 2 + numCCs)), + nodeId, + basicDeviceClass: nif[2], + ...parseApplicationNodeInformation(nif.slice(3, 2 + remainingLength)), }; } +export function encodeNodeUpdatePayload(nif: NodeUpdatePayload): Buffer { + const ccList = encodeCCList(nif.supportedCCs, []); + return Buffer.concat([ + Buffer.from([ + nif.nodeId, + 3 + ccList.length, + nif.basicDeviceClass, + nif.genericDeviceClass, + nif.specificDeviceClass, + ]), + ccList, + ]); +} + export function isExtendedCCId(ccId: CommandClasses): boolean { return ccId >= 0xf1; } diff --git a/packages/serial/src/message/Message.ts b/packages/serial/src/message/Message.ts index 048a478c69d..e3873fd55bd 100644 --- a/packages/serial/src/message/Message.ts +++ b/packages/serial/src/message/Message.ts @@ -22,8 +22,15 @@ type Constructable = new ( options?: MessageOptions, ) => T; +/** Where a serialized message originates from, to distinguish how certain messages need to be deserialized */ +export enum MessageOrigin { + Controller, + Host, +} + export interface MessageDeserializationOptions { data: Buffer; + origin?: MessageOrigin; } /** @@ -227,9 +234,13 @@ export class Message { } /** Creates an instance of the message that is serialized in the given buffer */ - public static from(host: ZWaveHost, data: Buffer): Message { + public static from( + host: ZWaveHost, + data: Buffer, + origin?: MessageOrigin, + ): Message { const Constructor = Message.getConstructor(data); - const ret = new Constructor(host, { data }); + const ret = new Constructor(host, { data, origin }); return ret; } diff --git a/packages/testing/src/MockController.ts b/packages/testing/src/MockController.ts index bd59cb4375f..0c2dd306904 100644 --- a/packages/testing/src/MockController.ts +++ b/packages/testing/src/MockController.ts @@ -1,5 +1,11 @@ +import type { ICommandClass } from "@zwave-js/core"; import type { ZWaveHost } from "@zwave-js/host"; -import { Message, MessageHeaders, SerialAPIParser } from "@zwave-js/serial"; +import { + Message, + MessageHeaders, + MessageOrigin, + SerialAPIParser, +} from "@zwave-js/serial"; import type { MockPortBinding } from "@zwave-js/serial/mock"; import { TimedExpectation } from "@zwave-js/shared/safe"; import { @@ -7,6 +13,14 @@ import { MockControllerCapabilities, } from "./MockControllerCapabilities"; import type { MockNode } from "./MockNode"; +import { + createMockZWaveAckFrame, + MockZWaveAckFrame, + MockZWaveFrame, + MockZWaveFrameType, + MockZWaveRequestFrame, + MOCK_FRAME_ACK_TIMEOUT, +} from "./MockZWaveFrame"; export interface MockControllerOptions { serial: MockPortBinding; @@ -72,17 +86,35 @@ export class MockController { private readonly serialParser: SerialAPIParser; private expectedHostACK?: TimedExpectation; private expectedHostMessages: TimedExpectation[] = []; - private expectedNodeMessages: Map< + private expectedNodeFrames: Map< number, - TimedExpectation[] + TimedExpectation[] > = new Map(); private behaviors: MockControllerBehavior[] = []; - public readonly nodes = new Map(); + private _nodes = new Map(); + public get nodes(): ReadonlyMap { + return this._nodes; + } + + public addNode(node: MockNode): void { + this._nodes.set(node.id, node); + } + + public removeNode(node: MockNode): void { + this._nodes.delete(node.id); + } + public readonly host: ZWaveHost; public readonly capabilities: MockControllerCapabilities; + /** Can be used by behaviors to store controller related state */ + public readonly state = new Map(); + + /** Controls whether the controller automatically ACKs node frames before handling them */ + public autoAckNodeFrames: boolean = true; + /** Gets called when parsed/chunked data is received from the serial port */ private async serialOnData( data: @@ -116,12 +148,12 @@ export class MockController { try { // Parse the message while remembering potential decoding errors in embedded CCs // This way we can log the invalid CC contents - msg = Message.from(this.host, data); + msg = Message.from(this.host, data, MessageOrigin.Host); // all good, respond with ACK this.sendHeaderToHost(MessageHeaders.ACK); } catch (e: any) { throw new Error( - "Mock controller received an invalid message from the host!", + `Mock controller received an invalid message from the host: ${e.stack}`, ); } @@ -169,7 +201,7 @@ export class MockController { const expectation = new TimedExpectation( timeout, predicate, - "Host did not respond with an ACK within the provided timeout!", + "Host did not send the expected message within the provided timeout!", ); try { this.expectedHostMessages.push(expectation); @@ -185,24 +217,27 @@ export class MockController { * * @param timeout The number of milliseconds to wait. If the timeout elapses, the returned promise will be rejected */ - public async expectNodeMessage( + public async expectNodeFrame( node: MockNode, timeout: number, - predicate: (data: Buffer) => boolean, - ): Promise { - const expectation = new TimedExpectation( + predicate: (msg: MockZWaveFrame) => msg is T, + ): Promise { + const expectation = new TimedExpectation< + MockZWaveFrame, + MockZWaveFrame + >( timeout, predicate, - "Node did not respond with an ACK within the provided timeout!", + `Node ${node.id} did not send the expected frame within the provided timeout!`, ); try { - if (!this.expectedNodeMessages.has(node.id)) { - this.expectedNodeMessages.set(node.id, []); + if (!this.expectedNodeFrames.has(node.id)) { + this.expectedNodeFrames.set(node.id, []); } - this.expectedNodeMessages.get(node.id)!.push(expectation); - return await expectation; + this.expectedNodeFrames.get(node.id)!.push(expectation); + return (await expectation) as T; } finally { - const array = this.expectedNodeMessages.get(node.id); + const array = this.expectedNodeFrames.get(node.id); if (array) { const index = array.indexOf(expectation); if (index !== -1) array.splice(index, 1); @@ -210,6 +245,43 @@ export class MockController { } } + /** + * Waits until the node sends a message matching the given predicate or a timeout has elapsed. + * + * @param timeout The number of milliseconds to wait. If the timeout elapses, the returned promise will be rejected + */ + public async expectNodeCC( + node: MockNode, + timeout: number, + predicate: (cc: ICommandClass) => cc is T, + ): Promise { + const ret = await this.expectNodeFrame( + node, + timeout, + (msg): msg is MockZWaveRequestFrame & { payload: T } => + msg.type === MockZWaveFrameType.Request && + predicate(msg.payload), + ); + return ret.payload; + } + + /** + * Waits until the controller sends an ACK frame or a timeout has elapsed. + * + * @param timeout The number of milliseconds to wait. If the timeout elapses, the returned promise will be rejected + */ + public expectNodeACK( + node: MockNode, + timeout: number, + ): Promise { + return this.expectNodeFrame( + node, + timeout, + (msg): msg is MockZWaveAckFrame => + msg.type === MockZWaveFrameType.ACK, + ); + } + /** Sends a message header (ACK/NAK/CAN) to the host/driver */ private sendHeaderToHost(data: MessageHeaders): void { this.serial.emitData(Buffer.from([data])); @@ -222,33 +294,69 @@ export class MockController { await this.expectHostACK(1000); } - /** Gets called when a complete chunk of data is received from a {@link MockNode} */ - public nodeOnData(node: MockNode, data: Buffer): void { + /** Gets called when a {@link MockZWaveFrame} is received from a {@link MockNode} */ + public async onNodeFrame( + node: MockNode, + frame: MockZWaveFrame, + ): Promise { + // Ack the frame if desired + if ( + this.autoAckNodeFrames && + frame.type === MockZWaveFrameType.Request + ) { + await this.ackNodeRequestFrame(node, frame); + } + // Handle message buffer. Check for pending expectations first. - const handler = this.expectedNodeMessages + const handler = this.expectedNodeFrames .get(node.id) - ?.find((e) => !e.predicate || e.predicate(data)); + ?.find((e) => !e.predicate || e.predicate(frame)); if (handler) { - handler.resolve(data); + handler.resolve(frame); } else { // Then apply generic predefined behavior for (const behavior of this.behaviors) { - if (behavior.onNodeMessage?.(this.host, this, node, data)) + if (await behavior.onNodeFrame?.(this.host, this, node, frame)) return; } } } /** - * Sends a raw buffer to a node - * @param data The data to send. Mock nodes expect a complete message/command. + * Sends an ACK frame to a {@link MockNode} */ - public sendToNode(node: MockNode, data: Buffer): void { - node.controllerOnData(data); + public async ackNodeRequestFrame( + node: MockNode, + frame?: MockZWaveRequestFrame, + ): Promise { + await this.sendToNode( + node, + createMockZWaveAckFrame({ + repeaters: frame?.repeaters, + }), + ); + } + + /** + * Sends a {@link MockZWaveFrame} to a {@link MockNode} + */ + public async sendToNode( + node: MockNode, + frame: MockZWaveFrame, + ): Promise { + let ret: Promise | undefined; + if (frame.type === MockZWaveFrameType.Request && frame.ackRequested) { + ret = this.expectNodeACK(node, MOCK_FRAME_ACK_TIMEOUT); + } + process.nextTick(() => { + void node.onControllerFrame(frame); + }); + if (ret) return await ret; } public defineBehavior(...behaviors: MockControllerBehavior[]): void { - this.behaviors.push(...behaviors); + // New behaviors must override existing ones, so we insert at the front of the array + this.behaviors.unshift(...behaviors); } } @@ -260,10 +368,10 @@ export interface MockControllerBehavior { msg: Message, ) => Promise | boolean; /** Gets called when a message from a node is received. Return `true` to indicate that the message has been handled. */ - onNodeMessage?: ( + onNodeFrame?: ( host: ZWaveHost, controller: MockController, node: MockNode, - data: Buffer, + frame: MockZWaveFrame, ) => Promise | boolean; } diff --git a/packages/testing/src/MockControllerCapabilities.ts b/packages/testing/src/MockControllerCapabilities.ts index dd07a1114ad..ed0fbee3a03 100644 --- a/packages/testing/src/MockControllerCapabilities.ts +++ b/packages/testing/src/MockControllerCapabilities.ts @@ -45,6 +45,8 @@ export function getDefaultMockControllerCapabilities(): MockControllerCapabiliti FunctionType.GetControllerVersion, FunctionType.GetControllerId, FunctionType.GetNodeProtocolInfo, + FunctionType.RequestNodeInfo, + FunctionType.AssignSUCReturnRoute, ], controllerType: ZWaveLibraryTypes["Static Controller"], diff --git a/packages/testing/src/MockNode.ts b/packages/testing/src/MockNode.ts index 0685c8cd905..7f482fc50e7 100644 --- a/packages/testing/src/MockNode.ts +++ b/packages/testing/src/MockNode.ts @@ -1,13 +1,96 @@ +import type { CommandClasses, CommandClassInfo } from "@zwave-js/core"; +import { TimedExpectation } from "@zwave-js/shared"; +import { isDeepStrictEqual } from "util"; import type { MockController } from "./MockController"; import { + getDefaultMockEndpointCapabilities, getDefaultMockNodeCapabilities, + MockEndpointCapabilities, + PartialCCCapabilities, type MockNodeCapabilities, } from "./MockNodeCapabilities"; +import { + createMockZWaveAckFrame, + MockZWaveAckFrame, + MockZWaveFrame, + MockZWaveFrameType, + MockZWaveRequestFrame, + MOCK_FRAME_ACK_TIMEOUT, +} from "./MockZWaveFrame"; + +const defaultCCInfo: CommandClassInfo = { + isSupported: true, + isControlled: false, + secure: false, + version: 1, +}; export interface MockNodeOptions { id: number; controller: MockController; - capabilities?: Partial; + capabilities?: Partial & { + /** The CCs implemented by the root device of this node */ + commandClasses?: PartialCCCapabilities[]; + /** Additional, consecutive endpoints. The first one defined will be available at index 1. */ + endpoints?: (Partial & { + commandClasses?: PartialCCCapabilities[]; + })[]; + }; +} + +export interface MockEndpointOptions { + index: number; + node: MockNode; + capabilities?: Partial & { + /** The CCs implemented by this endpoint */ + commandClasses?: PartialCCCapabilities[]; + }; +} + +export class MockEndpoint { + public constructor(options: MockEndpointOptions) { + this.index = options.index; + this.node = options.node; + + const { commandClasses = [], ...capabilities } = + options.capabilities ?? {}; + this.capabilities = { + ...getDefaultMockEndpointCapabilities(this.node.capabilities), + ...capabilities, + }; + + for (const cc of commandClasses) { + if (typeof cc === "number") { + this.addCC(cc, {}); + } else { + const { ccId, ...ccInfo } = cc; + this.addCC(ccId, ccInfo); + } + } + } + + public readonly index: number; + public readonly node: MockNode; + public readonly capabilities: MockEndpointCapabilities; + + public readonly implementedCCs = new Map< + CommandClasses, + CommandClassInfo + >(); + + /** Adds information about a CC to this mock endpoint */ + public addCC(cc: CommandClasses, info: Partial): void { + const original = this.implementedCCs.get(cc); + const updated = Object.assign({}, original ?? defaultCCInfo, info); + if (!isDeepStrictEqual(original, updated)) { + this.implementedCCs.set(cc, updated); + } + } + + /** Removes information about a CC from this mock node */ + public removeCC(cc: CommandClasses): void { + this.implementedCCs.delete(cc); + } } /** A mock node that can be used to test the driver as if it were speaking to an actual network */ @@ -16,26 +99,189 @@ export class MockNode { this.id = options.id; this.controller = options.controller; + const { + commandClasses = [], + endpoints = [], + ...capabilities + } = options.capabilities ?? {}; this.capabilities = { ...getDefaultMockNodeCapabilities(), - ...options.capabilities, + ...capabilities, }; + + for (const cc of commandClasses) { + if (typeof cc === "number") { + this.addCC(cc, {}); + } else { + const { ccId, ...ccInfo } = cc; + this.addCC(ccId, ccInfo); + } + } + + let index = 0; + for (const endpoint of endpoints) { + index++; + this.endpoints.set( + index, + new MockEndpoint({ + index, + node: this, + capabilities: endpoint, + }), + ); + } } public readonly id: number; public readonly controller: MockController; public readonly capabilities: MockNodeCapabilities; + private behaviors: MockNodeBehavior[] = []; + + public readonly implementedCCs = new Map< + CommandClasses, + CommandClassInfo + >(); + + public readonly endpoints = new Map(); + + /** Can be used by behaviors to store controller related state */ + public readonly state = new Map(); + + /** Controls whether the controller automatically ACKs node frames before handling them */ + public autoAckControllerFrames: boolean = true; + + private expectedControllerFrames: TimedExpectation< + MockZWaveFrame, + MockZWaveFrame + >[] = []; + + /** + * Waits until the controller sends a frame matching the given predicate or a timeout has elapsed. + * + * @param timeout The number of milliseconds to wait. If the timeout elapses, the returned promise will be rejected + */ + public async expectControllerFrame< + T extends MockZWaveFrame = MockZWaveFrame, + >( + timeout: number, + predicate: (msg: MockZWaveFrame) => msg is T, + ): Promise { + const expectation = new TimedExpectation< + MockZWaveFrame, + MockZWaveFrame + >( + timeout, + predicate, + "The controller did not send the expected frame within the provided timeout!", + ); + try { + this.expectedControllerFrames.push(expectation); + return (await expectation) as T; + } finally { + const index = this.expectedControllerFrames.indexOf(expectation); + if (index !== -1) this.expectedControllerFrames.splice(index, 1); + } + } + /** - * Sends a raw buffer to the {@link MockController} - * @param data The data to send. The mock controller expects a complete message/command. + * Waits until the controller sends an ACK frame or a timeout has elapsed. + * + * @param timeout The number of milliseconds to wait. If the timeout elapses, the returned promise will be rejected */ - public sendToController(data: Buffer): void { - this.controller.nodeOnData(this, data); + public expectControllerACK(timeout: number): Promise { + return this.expectControllerFrame( + timeout, + (msg): msg is MockZWaveAckFrame => + msg.type === MockZWaveFrameType.ACK, + ); } - /** Gets called when data is received from the {@link MockController} */ - public controllerOnData(_data: Buffer): void { - // TODO: handle message buffer + /** + * Sends a {@link MockZWaveFrame} to the {@link MockController} + */ + public async sendToController( + frame: MockZWaveFrame, + ): Promise { + let ret: Promise | undefined; + if (frame.type === MockZWaveFrameType.Request && frame.ackRequested) { + ret = this.expectControllerACK(MOCK_FRAME_ACK_TIMEOUT); + } + process.nextTick(() => { + void this.controller.onNodeFrame(this, frame); + }); + if (ret) return await ret; + } + + /** Gets called when a {@link MockZWaveFrame} is received from the {@link MockController} */ + public async onControllerFrame(frame: MockZWaveFrame): Promise { + // Ack the frame if desired + if ( + this.autoAckControllerFrames && + frame.type === MockZWaveFrameType.Request + ) { + await this.ackControllerRequestFrame(frame); + } + + // Handle message buffer. Check for pending expectations first. + const handler = this.expectedControllerFrames.find( + (e) => !e.predicate || e.predicate(frame), + ); + if (handler) { + handler.resolve(frame); + } else { + for (const behavior of this.behaviors) { + if ( + await behavior.onControllerFrame?.( + this.controller, + this, + frame, + ) + ) { + return; + } + } + } } + + /** + * Sends an ACK frame to the {@link MockController} + */ + public async ackControllerRequestFrame( + frame?: MockZWaveRequestFrame, + ): Promise { + await this.sendToController( + createMockZWaveAckFrame({ + repeaters: frame?.repeaters, + }), + ); + } + + /** Adds information about a CC to this mock node */ + public addCC(cc: CommandClasses, info: Partial): void { + const original = this.implementedCCs.get(cc); + const updated = Object.assign({}, original ?? defaultCCInfo, info); + if (!isDeepStrictEqual(original, updated)) { + this.implementedCCs.set(cc, updated); + } + } + + /** Removes information about a CC from this mock node */ + public removeCC(cc: CommandClasses): void { + this.implementedCCs.delete(cc); + } + + public defineBehavior(...behaviors: MockNodeBehavior[]): void { + // New behaviors must override existing ones, so we insert at the front of the array + this.behaviors.unshift(...behaviors); + } +} + +export interface MockNodeBehavior { + /** Gets called when a message from the controller is received. Return `true` to indicate that the message has been handled. */ + onControllerFrame?: ( + controller: MockController, + self: MockNode, + frame: MockZWaveFrame, + ) => Promise | boolean; } diff --git a/packages/testing/src/MockNodeCapabilities.ts b/packages/testing/src/MockNodeCapabilities.ts index 7722d0ebee1..a797f2366f5 100644 --- a/packages/testing/src/MockNodeCapabilities.ts +++ b/packages/testing/src/MockNodeCapabilities.ts @@ -1,4 +1,15 @@ -import { NodeProtocolInfoAndDeviceClass, NodeType } from "@zwave-js/core"; +import { + CommandClasses, + CommandClassInfo, + NodeProtocolInfoAndDeviceClass, + NodeType, +} from "@zwave-js/core"; + +export type PartialCCCapabilities = + | ({ + ccId: CommandClasses; + } & Partial) + | CommandClasses; export interface MockNodeCapabilities extends NodeProtocolInfoAndDeviceClass { firmwareVersion: string; @@ -7,6 +18,11 @@ export interface MockNodeCapabilities extends NodeProtocolInfoAndDeviceClass { productId: number; } +export interface MockEndpointCapabilities { + genericDeviceClass: number; + specificDeviceClass: number; +} + export function getDefaultMockNodeCapabilities(): MockNodeCapabilities { return { firmwareVersion: "1.0", @@ -28,3 +44,12 @@ export function getDefaultMockNodeCapabilities(): MockNodeCapabilities { specificDeviceClass: 0x01, // General Appliance }; } + +export function getDefaultMockEndpointCapabilities( + nodeCaps: MockNodeCapabilities, +): MockEndpointCapabilities { + return { + genericDeviceClass: nodeCaps.genericDeviceClass, + specificDeviceClass: nodeCaps.specificDeviceClass, + }; +} diff --git a/packages/testing/src/MockZWaveFrame.ts b/packages/testing/src/MockZWaveFrame.ts new file mode 100644 index 00000000000..d0b83c89c62 --- /dev/null +++ b/packages/testing/src/MockZWaveFrame.ts @@ -0,0 +1,60 @@ +import type { ICommandClass } from "@zwave-js/core"; + +/** + * Is used to simulate communication between a {@link MockController} and a {@link MockNode}. + * + */ +export type MockZWaveFrame = MockZWaveRequestFrame | MockZWaveAckFrame; + +export interface MockZWaveRequestFrame { + type: MockZWaveFrameType.Request; + /** The repeaters to use to reach the destination */ + repeaters: number[]; + /** Whether an ACK is requested from the destination */ + ackRequested: boolean; + /** The Command Class contained in the frame */ + payload: ICommandClass; +} + +export interface MockZWaveAckFrame { + type: MockZWaveFrameType.ACK; + /** Whether an ACK was received from the destination */ + ack: boolean; + /** The repeaters used to reach the destination */ + repeaters: number[]; + /** If the transmission failed at a repeater, this contains the array index */ + failedHop?: number; +} + +export enum MockZWaveFrameType { + Request, + ACK, +} + +export function createMockZWaveRequestFrame( + payload: ICommandClass, + options: Partial> = {}, +): MockZWaveRequestFrame { + const { repeaters = [], ackRequested = true } = options; + return { + type: MockZWaveFrameType.Request, + repeaters, + ackRequested, + payload, + }; +} + +export function createMockZWaveAckFrame( + options: Partial> = {}, +): MockZWaveAckFrame { + const { repeaters = [], ack = true, failedHop } = options; + return { + type: MockZWaveFrameType.ACK, + repeaters, + ack, + failedHop, + }; +} + +/** How long a Mock Node gets to ack a Z-Wave frame */ +export const MOCK_FRAME_ACK_TIMEOUT = 1000; diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index 3e1b0f728fa..05e97316594 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -1,3 +1,4 @@ export * from "./MockController"; export * from "./MockNode"; +export * from "./MockZWaveFrame"; export * from "./SpyTransport"; diff --git a/packages/zwave-js/src/Utils.ts b/packages/zwave-js/src/Utils.ts index 875e376e665..e221e36af39 100644 --- a/packages/zwave-js/src/Utils.ts +++ b/packages/zwave-js/src/Utils.ts @@ -31,3 +31,4 @@ export { formatRouteHealthCheckSummary, healthCheckRatingToWord, } from "./lib/node/HealthCheck"; +export { createDefaultBehaviors as createDefaultMockNodeBehaviors } from "./lib/node/MockNodeBehaviors"; diff --git a/packages/zwave-js/src/lib/controller/MockControllerBehaviors.ts b/packages/zwave-js/src/lib/controller/MockControllerBehaviors.ts index 52e60fe3cb8..fb4b86dcbc7 100644 --- a/packages/zwave-js/src/lib/controller/MockControllerBehaviors.ts +++ b/packages/zwave-js/src/lib/controller/MockControllerBehaviors.ts @@ -1,5 +1,25 @@ -import { NodeType } from "@zwave-js/core"; -import type { MockControllerBehavior } from "@zwave-js/testing"; +import { WakeUpTime } from "@zwave-js/cc"; +import { + ZWaveProtocolCCAssignSUCReturnRoute, + ZWaveProtocolCCNodeInformationFrame, + ZWaveProtocolCCRequestNodeInformationFrame, +} from "@zwave-js/cc/ZWaveProtocolCC"; +import { + NodeType, + TransmitOptions, + TransmitStatus, + ZWaveDataRate, +} from "@zwave-js/core"; +import { + createMockZWaveRequestFrame, + MockControllerBehavior, + MOCK_FRAME_ACK_TIMEOUT, +} from "@zwave-js/testing"; +import { + ApplicationUpdateRequest, + ApplicationUpdateRequestNodeInfoReceived, + ApplicationUpdateRequestNodeInfoRequestFailed, +} from "../serialapi/application/ApplicationUpdateRequest"; import { SerialAPIStartedRequest, SerialAPIWakeUpReason, @@ -25,6 +45,11 @@ import { GetControllerIdResponse, } from "../serialapi/memory/GetControllerIdMessages"; import { SoftResetRequest } from "../serialapi/misc/SoftResetRequest"; +import { + AssignSUCReturnRouteRequest, + AssignSUCReturnRouteRequestTransmitReport, + AssignSUCReturnRouteResponse, +} from "../serialapi/network-mgmt/AssignSUCReturnRouteMessages"; import { GetNodeProtocolInfoRequest, GetNodeProtocolInfoResponse, @@ -33,6 +58,19 @@ import { GetSUCNodeIdRequest, GetSUCNodeIdResponse, } from "../serialapi/network-mgmt/GetSUCNodeIdMessages"; +import { + RequestNodeInfoRequest, + RequestNodeInfoResponse, +} from "../serialapi/network-mgmt/RequestNodeInfoMessages"; +import { + SendDataRequest, + SendDataRequestTransmitReport, + SendDataResponse, +} from "../serialapi/transport/SendDataMessages"; +import { + MockControllerCommunicationState, + MockControllerStateKeys, +} from "./MockControllerState"; import { determineNIF } from "./NodeInformationFrame"; const respondToGetControllerId: MockControllerBehavior = { @@ -178,6 +216,223 @@ const respondToGetNodeProtocolInfo: MockControllerBehavior = { }, }; +const handleSendData: MockControllerBehavior = { + async onHostMessage(host, controller, msg) { + if (msg instanceof SendDataRequest) { + // Check if this command is legal right now + const state = controller.state.get( + MockControllerStateKeys.CommunicationState, + ) as MockControllerCommunicationState | undefined; + if ( + state != undefined && + state !== MockControllerCommunicationState.Idle + ) { + throw new Error("Received SendDataRequest while not idle"); + } + + // Put the controller into sending state + controller.state.set( + MockControllerStateKeys.CommunicationState, + MockControllerCommunicationState.Sending, + ); + + // Send the data to the node + const frame = createMockZWaveRequestFrame(msg.command, { + ackRequested: !!(msg.transmitOptions & TransmitOptions.ACK), + }); + const node = controller.nodes.get(msg.getNodeId()!)!; + const ackPromise = controller.sendToNode(node, frame); + + // Notify the host that the message was sent + const res = new SendDataResponse(host, { + wasSent: true, + }); + await controller.sendToHost(res.serialize()); + // Put the controller into waiting state + controller.state.set( + MockControllerStateKeys.CommunicationState, + MockControllerCommunicationState.WaitingForNode, + ); + + // Wait for the ACK and notify the host + let ack = false; + try { + const ackResult = await ackPromise; + ack = !!ackResult?.ack; + } catch { + // No response + } + controller.state.set( + MockControllerStateKeys.CommunicationState, + MockControllerCommunicationState.Idle, + ); + + const cb = new SendDataRequestTransmitReport(host, { + callbackId: msg.callbackId, + transmitStatus: ack ? TransmitStatus.OK : TransmitStatus.NoAck, + }); + + await controller.sendToHost(cb.serialize()); + } + return false; + }, +}; + +const handleRequestNodeInfo: MockControllerBehavior = { + async onHostMessage(host, controller, msg) { + if (msg instanceof RequestNodeInfoRequest) { + // Check if this command is legal right now + const state = controller.state.get( + MockControllerStateKeys.CommunicationState, + ) as MockControllerCommunicationState | undefined; + if ( + state != undefined && + state !== MockControllerCommunicationState.Idle + ) { + throw new Error( + "Received RequestNodeInfoRequest while not idle", + ); + } + + // Put the controller into sending state + controller.state.set( + MockControllerStateKeys.CommunicationState, + MockControllerCommunicationState.Sending, + ); + + // Send the data to the node + const node = controller.nodes.get(msg.getNodeId()!)!; + const command = new ZWaveProtocolCCRequestNodeInformationFrame( + host, + { nodeId: node.id }, + ); + const frame = createMockZWaveRequestFrame(command, { + ackRequested: false, + }); + void controller.sendToNode(node, frame); + const nodeInfoPromise = controller.expectNodeCC( + node, + MOCK_FRAME_ACK_TIMEOUT, + (cc): cc is ZWaveProtocolCCNodeInformationFrame => + cc instanceof ZWaveProtocolCCNodeInformationFrame, + ); + + // Notify the host that the message was sent + const res = new RequestNodeInfoResponse(host, { + wasSent: true, + }); + await controller.sendToHost(res.serialize()); + + // Put the controller into waiting state + controller.state.set( + MockControllerStateKeys.CommunicationState, + MockControllerCommunicationState.WaitingForNode, + ); + + // Wait for node information and notify the host + let cb: ApplicationUpdateRequest; + try { + const nodeInfo = await nodeInfoPromise; + cb = new ApplicationUpdateRequestNodeInfoReceived(host, { + nodeInformation: { + ...nodeInfo, + nodeId: nodeInfo.nodeId as number, + }, + }); + } catch (e) { + cb = new ApplicationUpdateRequestNodeInfoRequestFailed(host); + } + controller.state.set( + MockControllerStateKeys.CommunicationState, + MockControllerCommunicationState.Idle, + ); + + await controller.sendToHost(cb.serialize()); + } + return false; + }, +}; + +const handleAssignSUCReturnRoute: MockControllerBehavior = { + async onHostMessage(host, controller, msg) { + if (msg instanceof AssignSUCReturnRouteRequest) { + // Check if this command is legal right now + const state = controller.state.get( + MockControllerStateKeys.CommunicationState, + ) as MockControllerCommunicationState | undefined; + if ( + state != undefined && + state !== MockControllerCommunicationState.Idle + ) { + throw new Error( + "Received AssignSUCReturnRouteRequest while not idle", + ); + } + + // Put the controller into sending state + controller.state.set( + MockControllerStateKeys.CommunicationState, + MockControllerCommunicationState.Sending, + ); + + const expectCallback = msg.callbackId !== 0; + + // Send the command to the node + const node = controller.nodes.get(msg.getNodeId()!)!; + const command = new ZWaveProtocolCCAssignSUCReturnRoute(host, { + nodeId: host.ownNodeId, + repeaters: [], // don't care + routeIndex: 0, // don't care + destinationSpeed: ZWaveDataRate["100k"], + destinationWakeUp: WakeUpTime.None, + }); + const frame = createMockZWaveRequestFrame(command, { + ackRequested: expectCallback, + }); + const ackPromise = controller.sendToNode(node, frame); + + // Notify the host that the message was sent + const res = new AssignSUCReturnRouteResponse(host, { + wasExecuted: true, + }); + await controller.sendToHost(res.serialize()); + + let ack = false; + if (expectCallback) { + // Put the controller into waiting state + controller.state.set( + MockControllerStateKeys.CommunicationState, + MockControllerCommunicationState.WaitingForNode, + ); + + // Wait for the ACK and notify the host + try { + const ackResult = await ackPromise; + ack = !!ackResult?.ack; + } catch { + // No response + } + } + controller.state.set( + MockControllerStateKeys.CommunicationState, + MockControllerCommunicationState.Idle, + ); + + if (expectCallback) { + const cb = new AssignSUCReturnRouteRequestTransmitReport(host, { + callbackId: msg.callbackId, + transmitStatus: ack + ? TransmitStatus.OK + : TransmitStatus.NoAck, + }); + + await controller.sendToHost(cb.serialize()); + } + } + return false; + }, +}; + /** Predefined default behaviors that are required for interacting with the driver correctly */ export function createDefaultBehaviors(): MockControllerBehavior[] { return [ @@ -189,5 +444,8 @@ export function createDefaultBehaviors(): MockControllerBehavior[] { respondToGetSerialApiInitData, respondToSoftReset, respondToGetNodeProtocolInfo, + handleSendData, + handleRequestNodeInfo, + handleAssignSUCReturnRoute, ]; } diff --git a/packages/zwave-js/src/lib/controller/MockControllerState.ts b/packages/zwave-js/src/lib/controller/MockControllerState.ts new file mode 100644 index 00000000000..b36f56bec01 --- /dev/null +++ b/packages/zwave-js/src/lib/controller/MockControllerState.ts @@ -0,0 +1,9 @@ +export enum MockControllerStateKeys { + CommunicationState = "communicationState", +} + +export enum MockControllerCommunicationState { + Idle, + Sending, + WaitingForNode, +} diff --git a/packages/zwave-js/src/lib/node/MockNodeBehaviors.ts b/packages/zwave-js/src/lib/node/MockNodeBehaviors.ts new file mode 100644 index 00000000000..d64044533d0 --- /dev/null +++ b/packages/zwave-js/src/lib/node/MockNodeBehaviors.ts @@ -0,0 +1,40 @@ +import { + ZWaveProtocolCCNodeInformationFrame, + ZWaveProtocolCCRequestNodeInformationFrame, +} from "@zwave-js/cc/ZWaveProtocolCC"; +import { + createMockZWaveRequestFrame, + MockNodeBehavior, + MockZWaveFrameType, +} from "@zwave-js/testing"; + +const respondToRequestNodeInfo: MockNodeBehavior = { + async onControllerFrame(controller, self, frame) { + if ( + frame.type === MockZWaveFrameType.Request && + frame.payload instanceof ZWaveProtocolCCRequestNodeInformationFrame + ) { + const cc = new ZWaveProtocolCCNodeInformationFrame( + controller.host, + { + nodeId: self.id, + ...self.capabilities, + supportedCCs: [...self.implementedCCs] + .filter(([, info]) => info.isSupported) + .map(([ccId]) => ccId), + }, + ); + await self.sendToController( + createMockZWaveRequestFrame(cc, { + ackRequested: false, + }), + ); + } + return false; + }, +}; + +/** Predefined default behaviors that are required for interacting with the Mock Controller correctly */ +export function createDefaultBehaviors(): MockNodeBehavior[] { + return [respondToRequestNodeInfo]; +} diff --git a/packages/zwave-js/src/lib/serialapi/application/ApplicationCommandRequest.ts b/packages/zwave-js/src/lib/serialapi/application/ApplicationCommandRequest.ts index d2e83dd2a2d..7c34322858a 100644 --- a/packages/zwave-js/src/lib/serialapi/application/ApplicationCommandRequest.ts +++ b/packages/zwave-js/src/lib/serialapi/application/ApplicationCommandRequest.ts @@ -87,6 +87,7 @@ export class ApplicationCommandRequest this.command = CommandClass.from(this.host, { data: this.payload.slice(3, 3 + commandLength), nodeId, + origin: options.origin, }) as SinglecastCC; } else { // TODO: This logic is unsound diff --git a/packages/zwave-js/src/lib/serialapi/application/ApplicationUpdateRequest.ts b/packages/zwave-js/src/lib/serialapi/application/ApplicationUpdateRequest.ts index 61622ccea59..9b926176110 100644 --- a/packages/zwave-js/src/lib/serialapi/application/ApplicationUpdateRequest.ts +++ b/packages/zwave-js/src/lib/serialapi/application/ApplicationUpdateRequest.ts @@ -1,5 +1,6 @@ import { CommandClasses, + encodeNodeUpdatePayload, getCCName, MessageOrCCLogEntry, MessageRecord, @@ -8,15 +9,18 @@ import { parseNodeUpdatePayload, } from "@zwave-js/core"; import type { ZWaveHost } from "@zwave-js/host"; -import type { SuccessIndicator } from "@zwave-js/serial"; import { FunctionType, + gotDeserializationOptions, Message, + MessageBaseOptions, MessageDeserializationOptions, + MessageOptions, MessageType, messageTypes, + SuccessIndicator, } from "@zwave-js/serial"; -import { buffer2hex, getEnumMemberName, JSONObject } from "@zwave-js/shared"; +import { buffer2hex, getEnumMemberName } from "@zwave-js/shared"; export enum ApplicationUpdateTypes { SmartStart_NodeInfo_Received = 0x86, // An included smart start node has been powered up @@ -30,66 +34,101 @@ export enum ApplicationUpdateTypes { SUC_IdChanged = 0x10, } +interface ApplicationUpdateRequestOptions extends MessageBaseOptions { + updateType: ApplicationUpdateTypes; +} + @messageTypes(MessageType.Request, FunctionType.ApplicationUpdateRequest) // this is only received, not sent! export class ApplicationUpdateRequest extends Message { public constructor( host: ZWaveHost, - options: MessageDeserializationOptions, + options: + | ApplicationUpdateRequestOptions + | MessageDeserializationOptions, ) { super(host, options); - this.updateType = this.payload[0]; - this.payload = this.payload.slice(1); - - let CommandConstructor: typeof ApplicationUpdateRequest | undefined; - switch (this.updateType) { - case ApplicationUpdateTypes.NodeInfo_Received: - CommandConstructor = ApplicationUpdateRequestNodeInfoReceived; - break; - case ApplicationUpdateTypes.NodeInfo_RequestFailed: - CommandConstructor = - ApplicationUpdateRequestNodeInfoRequestFailed; - break; - case ApplicationUpdateTypes.SmartStart_HomeId_Received: - CommandConstructor = - ApplicationUpdateRequestSmartStartHomeIDReceived; - break; - } - if (CommandConstructor && (new.target as any) !== CommandConstructor) { - return new CommandConstructor(host, options); + if (gotDeserializationOptions(options)) { + this.updateType = this.payload[0]; + this.payload = this.payload.slice(1); + + let CommandConstructor: + | (new ( + host: ZWaveHost, + options: MessageDeserializationOptions, + ) => ApplicationUpdateRequest) + | undefined; + + switch (this.updateType) { + case ApplicationUpdateTypes.NodeInfo_Received: + CommandConstructor = + ApplicationUpdateRequestNodeInfoReceived; + break; + case ApplicationUpdateTypes.NodeInfo_RequestFailed: + CommandConstructor = + ApplicationUpdateRequestNodeInfoRequestFailed; + break; + case ApplicationUpdateTypes.SmartStart_HomeId_Received: + CommandConstructor = + ApplicationUpdateRequestSmartStartHomeIDReceived; + break; + } + + if ( + CommandConstructor && + (new.target as any) !== CommandConstructor + ) { + return new CommandConstructor(host, options); + } + } else { + this.updateType = options.updateType; } } public readonly updateType: ApplicationUpdateTypes; + + public serialize(): Buffer { + this.payload = Buffer.concat([ + Buffer.from([this.updateType]), + this.payload, + ]); + return super.serialize(); + } +} + +interface ApplicationUpdateRequestNodeInfoReceivedOptions + extends MessageBaseOptions { + nodeInformation: NodeUpdatePayload; } export class ApplicationUpdateRequestNodeInfoReceived extends ApplicationUpdateRequest { public constructor( host: ZWaveHost, - options: MessageDeserializationOptions, + options: + | MessageDeserializationOptions + | ApplicationUpdateRequestNodeInfoReceivedOptions, ) { - super(host, options); - this._nodeInformation = parseNodeUpdatePayload(this.payload); - this._nodeId = this._nodeInformation.nodeId; - } + super(host, { + ...options, + updateType: ApplicationUpdateTypes.NodeInfo_Received, + }); - private _nodeId: number; - public get nodeId(): number { - return this._nodeId; + if (gotDeserializationOptions(options)) { + this.nodeInformation = parseNodeUpdatePayload(this.payload); + this.nodeId = this.nodeInformation.nodeId; + } else { + this.nodeId = options.nodeInformation.nodeId; + this.nodeInformation = options.nodeInformation; + } } - private _nodeInformation: NodeUpdatePayload; - public get nodeInformation(): NodeUpdatePayload { - return this._nodeInformation; - } + public nodeId: number; + public nodeInformation: NodeUpdatePayload; - public toJSON(): JSONObject { - return super.toJSONInherited({ - updateType: ApplicationUpdateTypes[this.updateType], - nodeId: this.nodeId, - nodeInformation: this.nodeInformation, - }); + public serialize(): Buffer { + this.payload = encodeNodeUpdatePayload(this.nodeInformation); + return super.serialize(); } } @@ -97,6 +136,13 @@ export class ApplicationUpdateRequestNodeInfoRequestFailed extends ApplicationUpdateRequest implements SuccessIndicator { + public constructor(host: ZWaveHost, options?: MessageOptions) { + super(host, { + ...options, + updateType: ApplicationUpdateTypes.NodeInfo_RequestFailed, + }); + } + isOK(): boolean { return false; } diff --git a/packages/zwave-js/src/lib/serialapi/application/BridgeApplicationCommandRequest.ts b/packages/zwave-js/src/lib/serialapi/application/BridgeApplicationCommandRequest.ts index eb6f9905658..f7247452730 100644 --- a/packages/zwave-js/src/lib/serialapi/application/BridgeApplicationCommandRequest.ts +++ b/packages/zwave-js/src/lib/serialapi/application/BridgeApplicationCommandRequest.ts @@ -64,6 +64,7 @@ export class BridgeApplicationCommandRequest this.command = CommandClass.from(this.host, { data: this.payload.slice(offset, offset + commandLength), nodeId: sourceNodeId, + origin: options.origin, }) as SinglecastCC; offset += commandLength; diff --git a/packages/zwave-js/src/lib/serialapi/capability/HardResetRequest.ts b/packages/zwave-js/src/lib/serialapi/capability/HardResetRequest.ts index 58f9d7f5558..c911616e130 100644 --- a/packages/zwave-js/src/lib/serialapi/capability/HardResetRequest.ts +++ b/packages/zwave-js/src/lib/serialapi/capability/HardResetRequest.ts @@ -8,6 +8,7 @@ import { Message, MessageDeserializationOptions, MessageOptions, + MessageOrigin, MessageType, messageTypes, priority, @@ -17,11 +18,18 @@ import { @priority(MessagePriority.Controller) export class HardResetRequestBase extends Message { public constructor(host: ZWaveHost, options?: MessageOptions) { - if ( - gotDeserializationOptions(options) && - (new.target as any) !== HardResetCallback - ) { - return new HardResetCallback(host, options); + if (gotDeserializationOptions(options)) { + if ( + options.origin === MessageOrigin.Host && + (new.target as any) !== HardResetRequest + ) { + return new HardResetRequest(host, options); + } else if ( + options.origin !== MessageOrigin.Host && + (new.target as any) !== HardResetCallback + ) { + return new HardResetCallback(host, options); + } } super(host, options); } diff --git a/packages/zwave-js/src/lib/serialapi/network-mgmt/AssignSUCReturnRouteMessages.ts b/packages/zwave-js/src/lib/serialapi/network-mgmt/AssignSUCReturnRouteMessages.ts index d9e80222f19..3b269aa5c4b 100644 --- a/packages/zwave-js/src/lib/serialapi/network-mgmt/AssignSUCReturnRouteMessages.ts +++ b/packages/zwave-js/src/lib/serialapi/network-mgmt/AssignSUCReturnRouteMessages.ts @@ -2,36 +2,48 @@ import { MessageOrCCLogEntry, MessagePriority, TransmitStatus, - ZWaveError, - ZWaveErrorCodes, } from "@zwave-js/core"; import type { ZWaveHost } from "@zwave-js/host"; -import type { INodeQuery, SuccessIndicator } from "@zwave-js/serial"; import { expectedCallback, expectedResponse, FunctionType, gotDeserializationOptions, + INodeQuery, Message, MessageBaseOptions, MessageDeserializationOptions, MessageOptions, + MessageOrigin, MessageType, messageTypes, priority, + SuccessIndicator, } from "@zwave-js/serial"; -import { getEnumMemberName, JSONObject } from "@zwave-js/shared"; +import { getEnumMemberName } from "@zwave-js/shared"; @messageTypes(MessageType.Request, FunctionType.AssignSUCReturnRoute) @priority(MessagePriority.Normal) export class AssignSUCReturnRouteRequestBase extends Message { public constructor(host: ZWaveHost, options: MessageOptions) { - if ( - gotDeserializationOptions(options) && - (new.target as any) !== AssignSUCReturnRouteRequestTransmitReport - ) { - return new AssignSUCReturnRouteRequestTransmitReport(host, options); + if (gotDeserializationOptions(options)) { + if ( + options.origin === MessageOrigin.Host && + (new.target as any) !== AssignSUCReturnRouteRequest + ) { + return new AssignSUCReturnRouteRequest(host, options); + } else if ( + options.origin !== MessageOrigin.Host && + (new.target as any) !== + AssignSUCReturnRouteRequestTransmitReport + ) { + return new AssignSUCReturnRouteRequestTransmitReport( + host, + options, + ); + } } + super(host, options); } } @@ -54,10 +66,8 @@ export class AssignSUCReturnRouteRequest ) { super(host, options); if (gotDeserializationOptions(options)) { - throw new ZWaveError( - `${this.constructor.name}: deserialization not implemented`, - ZWaveErrorCodes.Deserialization_NotImplemented, - ); + this.nodeId = this.payload[0]; + this.callbackId = this.payload[1]; } else { this.nodeId = options.nodeId; } @@ -72,6 +82,10 @@ export class AssignSUCReturnRouteRequest } } +interface AssignSUCReturnRouteResponseOptions extends MessageBaseOptions { + wasExecuted: boolean; +} + @messageTypes(MessageType.Response, FunctionType.AssignSUCReturnRoute) export class AssignSUCReturnRouteResponse extends Message @@ -79,22 +93,27 @@ export class AssignSUCReturnRouteResponse { public constructor( host: ZWaveHost, - options: MessageDeserializationOptions, + options: + | MessageDeserializationOptions + | AssignSUCReturnRouteResponseOptions, ) { super(host, options); - this.wasExecuted = this.payload[0] !== 0; + if (gotDeserializationOptions(options)) { + this.wasExecuted = this.payload[0] !== 0; + } else { + this.wasExecuted = options.wasExecuted; + } } public isOK(): boolean { return this.wasExecuted; } - public readonly wasExecuted: boolean; + public wasExecuted: boolean; - public toJSON(): JSONObject { - return super.toJSONInherited({ - wasExecuted: this.wasExecuted, - }); + public serialize(): Buffer { + this.payload = Buffer.from([this.wasExecuted ? 0x01 : 0]); + return super.serialize(); } public toLogEntry(): MessageOrCCLogEntry { @@ -105,34 +124,42 @@ export class AssignSUCReturnRouteResponse } } +interface AssignSUCReturnRouteRequestTransmitReportOptions + extends MessageBaseOptions { + transmitStatus: TransmitStatus; + callbackId: number; +} + export class AssignSUCReturnRouteRequestTransmitReport extends AssignSUCReturnRouteRequestBase implements SuccessIndicator { public constructor( host: ZWaveHost, - options: MessageDeserializationOptions, + options: + | MessageDeserializationOptions + | AssignSUCReturnRouteRequestTransmitReportOptions, ) { super(host, options); - this.callbackId = this.payload[0]; - this._transmitStatus = this.payload[1]; + if (gotDeserializationOptions(options)) { + this.callbackId = this.payload[0]; + this.transmitStatus = this.payload[1]; + } else { + this.callbackId = options.callbackId; + this.transmitStatus = options.transmitStatus; + } } public isOK(): boolean { - return this._transmitStatus === TransmitStatus.OK; + return this.transmitStatus === TransmitStatus.OK; } - private _transmitStatus: TransmitStatus; - public get transmitStatus(): TransmitStatus { - return this._transmitStatus; - } + public transmitStatus: TransmitStatus; - public toJSON(): JSONObject { - return super.toJSONInherited({ - callbackId: this.callbackId, - transmitStatus: this.transmitStatus, - }); + public serialize(): Buffer { + this.payload = Buffer.from([this.callbackId, this.transmitStatus]); + return super.serialize(); } public toLogEntry(): MessageOrCCLogEntry { diff --git a/packages/zwave-js/src/lib/serialapi/network-mgmt/RequestNodeInfoMessages.ts b/packages/zwave-js/src/lib/serialapi/network-mgmt/RequestNodeInfoMessages.ts index c26d9b1330a..c94db0d71c8 100644 --- a/packages/zwave-js/src/lib/serialapi/network-mgmt/RequestNodeInfoMessages.ts +++ b/packages/zwave-js/src/lib/serialapi/network-mgmt/RequestNodeInfoMessages.ts @@ -1,36 +1,26 @@ import { MessagePriority } from "@zwave-js/core"; import type { ZWaveHost } from "@zwave-js/host"; -import type { INodeQuery, SuccessIndicator } from "@zwave-js/serial"; import { expectedCallback, expectedResponse, FunctionType, + gotDeserializationOptions, + INodeQuery, Message, MessageBaseOptions, MessageDeserializationOptions, MessageType, messageTypes, priority, + SuccessIndicator, } from "@zwave-js/serial"; -import type { JSONObject } from "@zwave-js/shared"; import { ApplicationUpdateRequestNodeInfoReceived, ApplicationUpdateRequestNodeInfoRequestFailed, } from "../application/ApplicationUpdateRequest"; -function testCallbackForRequestNodeInfoRequest( - sent: RequestNodeInfoRequest, - received: Message, -) { - return ( - (received instanceof ApplicationUpdateRequestNodeInfoReceived && - received.nodeId === sent.nodeId) || - received instanceof ApplicationUpdateRequestNodeInfoRequestFailed - ); -} - -interface RequestNodeInfoRequestOptions extends MessageBaseOptions { - nodeId: number; +interface RequestNodeInfoResponseOptions extends MessageBaseOptions { + wasSent: boolean; } @messageTypes(MessageType.Response, FunctionType.RequestNodeInfo) @@ -40,33 +30,41 @@ export class RequestNodeInfoResponse { public constructor( host: ZWaveHost, - options: MessageDeserializationOptions, + options: MessageDeserializationOptions | RequestNodeInfoResponseOptions, ) { super(host, options); - this._wasSent = this.payload[0] !== 0; - if (!this._wasSent) this._errorCode = this.payload[0]; + if (gotDeserializationOptions(options)) { + this.wasSent = this.payload[0] !== 0; + } else { + this.wasSent = options.wasSent; + } } + public wasSent: boolean; + public isOK(): boolean { - return this._wasSent; + return this.wasSent; } - private _wasSent: boolean; - public get wasSent(): boolean { - return this._wasSent; + public serialize(): Buffer { + this.payload = Buffer.from([this.wasSent ? 0x01 : 0]); + return super.serialize(); } +} - private _errorCode: number | undefined; - public get errorCode(): number | undefined { - return this._errorCode; - } +interface RequestNodeInfoRequestOptions extends MessageBaseOptions { + nodeId: number; +} - public toJSON(): JSONObject { - return super.toJSONInherited({ - wasSent: this.wasSent, - errorCode: this.errorCode, - }); - } +function testCallbackForRequestNodeInfoRequest( + sent: RequestNodeInfoRequest, + received: Message, +) { + return ( + (received instanceof ApplicationUpdateRequestNodeInfoReceived && + received.nodeId === sent.nodeId) || + received instanceof ApplicationUpdateRequestNodeInfoRequestFailed + ); } @messageTypes(MessageType.Request, FunctionType.RequestNodeInfo) @@ -76,10 +74,14 @@ export class RequestNodeInfoResponse export class RequestNodeInfoRequest extends Message implements INodeQuery { public constructor( host: ZWaveHost, - options: RequestNodeInfoRequestOptions, + options: RequestNodeInfoRequestOptions | MessageDeserializationOptions, ) { super(host, options); - this.nodeId = options.nodeId; + if (gotDeserializationOptions(options)) { + this.nodeId = this.payload[0]; + } else { + this.nodeId = options.nodeId; + } } public nodeId: number; @@ -93,10 +95,4 @@ export class RequestNodeInfoRequest extends Message implements INodeQuery { this.payload = Buffer.from([this.nodeId]); return super.serialize(); } - - public toJSON(): JSONObject { - return super.toJSONInherited({ - nodeId: this.nodeId, - }); - } } diff --git a/packages/zwave-js/src/lib/serialapi/transport/SendDataMessages.ts b/packages/zwave-js/src/lib/serialapi/transport/SendDataMessages.ts index fa2f702f3fe..8243a977874 100644 --- a/packages/zwave-js/src/lib/serialapi/transport/SendDataMessages.ts +++ b/packages/zwave-js/src/lib/serialapi/transport/SendDataMessages.ts @@ -1,4 +1,4 @@ -import type { CommandClass, ICommandClassContainer } from "@zwave-js/cc"; +import { CommandClass, ICommandClassContainer } from "@zwave-js/cc"; import { MAX_NODES, MessageOrCCLogEntry, @@ -12,7 +12,6 @@ import { ZWaveErrorCodes, } from "@zwave-js/core"; import type { ZWaveHost } from "@zwave-js/host"; -import type { SuccessIndicator } from "@zwave-js/serial"; import { expectedCallback, expectedResponse, @@ -22,9 +21,11 @@ import { MessageBaseOptions, MessageDeserializationOptions, MessageOptions, + MessageOrigin, MessageType, messageTypes, priority, + SuccessIndicator, } from "@zwave-js/serial"; import { getEnumMemberName, JSONObject, num2hex } from "@zwave-js/shared"; import { clamp } from "alcalzone-shared/math"; @@ -38,11 +39,18 @@ export const MAX_SEND_ATTEMPTS = 5; @priority(MessagePriority.Normal) export class SendDataRequestBase extends Message { public constructor(host: ZWaveHost, options: MessageOptions) { - if ( - gotDeserializationOptions(options) && - (new.target as any) !== SendDataRequestTransmitReport - ) { - return new SendDataRequestTransmitReport(host, options); + if (gotDeserializationOptions(options)) { + if ( + options.origin === MessageOrigin.Host && + (new.target as any) !== SendDataRequest + ) { + return new SendDataRequest(host, options); + } else if ( + options.origin !== MessageOrigin.Host && + (new.target as any) !== SendDataRequestTransmitReport + ) { + return new SendDataRequestTransmitReport(host, options); + } } super(host, options); } @@ -63,22 +71,35 @@ export class SendDataRequest { public constructor( host: ZWaveHost, - options: SendDataRequestOptions, + options: MessageDeserializationOptions | SendDataRequestOptions, ) { super(host, options); - if (!options.command.isSinglecast()) { - throw new ZWaveError( - `SendDataRequest can only be used for singlecast and broadcast CCs`, - ZWaveErrorCodes.Argument_Invalid, - ); - } - - this.command = options.command; - this.transmitOptions = - options.transmitOptions ?? TransmitOptions.DEFAULT; - if (options.maxSendAttempts != undefined) { - this.maxSendAttempts = options.maxSendAttempts; + if (gotDeserializationOptions(options)) { + const nodeId = this.payload[0]; + const serializedCCLength = this.payload[1]; + const ccBuffer = this.payload.slice(2, 2 + serializedCCLength); + this.command = CommandClass.from(host, { + nodeId, + data: ccBuffer, + origin: options.origin, + }) as SinglecastCC; + this.transmitOptions = this.payload[2 + serializedCCLength]; + this.callbackId = this.payload[3 + serializedCCLength]; + } else { + if (!options.command.isSinglecast()) { + throw new ZWaveError( + `SendDataRequest can only be used for singlecast and broadcast CCs`, + ZWaveErrorCodes.Argument_Invalid, + ); + } + + this.command = options.command; + this.transmitOptions = + options.transmitOptions ?? TransmitOptions.DEFAULT; + if (options.maxSendAttempts != undefined) { + this.maxSendAttempts = options.maxSendAttempts; + } } } @@ -153,6 +174,7 @@ export class SendDataRequest interface SendDataRequestTransmitReportOptions extends MessageBaseOptions { transmitStatus: TransmitStatus; callbackId: number; + txReport?: TXReport; } export class SendDataRequestTransmitReport @@ -180,18 +202,21 @@ export class SendDataRequestTransmitReport } } - public readonly transmitStatus: TransmitStatus; - public readonly txReport: TXReport | undefined; + public transmitStatus: TransmitStatus; + public txReport: TXReport | undefined; - public isOK(): boolean { - return this.transmitStatus === TransmitStatus.OK; + public serialize(): Buffer { + this.payload = Buffer.from([ + this.callbackId, + this.transmitStatus, + // TODO: Serialize TXReport + ]); + + return super.serialize(); } - public toJSON(): JSONObject { - return super.toJSONInherited({ - callbackId: this.callbackId, - transmitStatus: this.transmitStatus, - }); + public isOK(): boolean { + return this.transmitStatus === TransmitStatus.OK; } public toLogEntry(): MessageOrCCLogEntry { @@ -212,36 +237,33 @@ export class SendDataRequestTransmitReport } } +export interface SendDataResponseOptions extends MessageBaseOptions { + wasSent: boolean; +} + @messageTypes(MessageType.Response, FunctionType.SendData) export class SendDataResponse extends Message implements SuccessIndicator { public constructor( host: ZWaveHost, - options: MessageDeserializationOptions, + options: MessageDeserializationOptions | SendDataResponseOptions, ) { super(host, options); - this._wasSent = this.payload[0] !== 0; - // if (!this._wasSent) this._errorCode = this.payload[0]; + if (gotDeserializationOptions(options)) { + this.wasSent = this.payload[0] !== 0; + } else { + this.wasSent = options.wasSent; + } } - isOK(): boolean { - return this._wasSent; - } + public wasSent: boolean; - private _wasSent: boolean; - public get wasSent(): boolean { - return this._wasSent; + public serialize(): Buffer { + this.payload = Buffer.from([this.wasSent ? 1 : 0]); + return super.serialize(); } - // private _errorCode: number; - // public get errorCode(): number { - // return this._errorCode; - // } - - public toJSON(): JSONObject { - return super.toJSONInherited({ - wasSent: this.wasSent, - // errorCode: this.errorCode, - }); + isOK(): boolean { + return this.wasSent; } public toLogEntry(): MessageOrCCLogEntry { diff --git a/test/debug.js b/test/debug.js index acdd158dc1c..71a35bedf24 100644 --- a/test/debug.js +++ b/test/debug.js @@ -2,11 +2,9 @@ require("reflect-metadata"); require("zwave-js"); -const { Message } = require("../packages/zwave-js/build/lib/message/Message"); +const { Message } = require("@zwave-js/serial"); const { generateAuthKey, generateEncryptionKey } = require("@zwave-js/core"); -const { - isCommandClassContainer, -} = require("../packages/zwave-js/build/lib/commandclass/ICommandClassContainer"); +const { isCommandClassContainer } = require("@zwave-js/cc"); const { ConfigManager } = require("@zwave-js/config"); (async () => { @@ -21,10 +19,7 @@ const { ConfigManager } = require("@zwave-js/config"); await configManager.loadIndicators(); // The data to decode - const data = Buffer.from( - "011b00a800011f129f03a70015820de70626870e785dafb9090300d543", - "hex", - ); + const data = Buffer.from("010e00498414080421015e98845aeff3", "hex"); // The nonce needed to decode it const nonce = Buffer.from("478d7aa05d83f3ea", "hex"); // The network key needed to decode it diff --git a/test/mock.ts b/test/mock.ts index 63d1da49083..6e68df5d176 100644 --- a/test/mock.ts +++ b/test/mock.ts @@ -1,10 +1,12 @@ /* eslint-disable @typescript-eslint/require-await */ +import { CommandClasses } from "@zwave-js/core"; import { MockController, MockNode } from "@zwave-js/testing"; import path from "path"; import "reflect-metadata"; import { createAndStartDriverWithMockPort, createDefaultMockControllerBehaviors, + createDefaultMockNodeBehaviors, } from "zwave-js"; process.on("unhandledRejection", (_r) => { @@ -13,7 +15,8 @@ process.on("unhandledRejection", (_r) => { void (async () => { const { driver, continueStartup, mockPort } = - await createAndStartDriverWithMockPort("/tty/FAKE", { + await createAndStartDriverWithMockPort({ + portAddress: "/tty/FAKE", logConfig: { // logToFile: true, enabled: true, @@ -58,13 +61,21 @@ void (async () => { id: 2, controller, capabilities: { - isListening: false, + isListening: true, + commandClasses: [ + CommandClasses.Basic, + CommandClasses["Binary Switch"], + ], + endpoints: [{ commandClasses: [CommandClasses.Basic] }], }, }); - controller.nodes.set(2, node2); + controller.addNode(node2); + + // node2.autoAckControllerFrames = false; // Apply default behaviors that are required for interacting with the driver correctly controller.defineBehavior(...createDefaultMockControllerBehaviors()); + node2.defineBehavior(...createDefaultMockNodeBehaviors()); continueStartup(); })(); diff --git a/test/run.ts b/test/run.ts index 8016590600e..39abb6d90c4 100644 --- a/test/run.ts +++ b/test/run.ts @@ -10,10 +10,10 @@ process.on("unhandledRejection", (_r) => { const port = os.platform() === "win32" ? "COM5" : "/dev/ttyUSB0"; const driver = new Driver(port, { - logConfig: { - logToFile: true, - forceConsole: true, - }, + // logConfig: { + // logToFile: true, + // forceConsole: true, + // }, securityKeys: { S0_Legacy: Buffer.from("0102030405060708090a0b0c0d0e0f10", "hex"), S2_Unauthenticated: Buffer.from(