inventoresed
Version:
Z-Wave driver written entirely in JavaScript/TypeScript
415 lines (375 loc) • 11.6 kB
text/typescript
import {
CommandClasses,
CommandClassInfo,
Maybe,
SecurityClass,
securityClassOrder,
unknownBoolean,
} from "@zwave-js/core";
import type { ZWaveHost } from "@zwave-js/host";
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<MockNodeCapabilities> & {
/** 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<MockEndpointCapabilities> & {
commandClasses?: PartialCCCapabilities[];
})[];
};
}
export interface MockEndpointOptions {
index: number;
node: MockNode;
capabilities?: Partial<MockEndpointCapabilities> & {
/** 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<CommandClassInfo>): 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 */
export class MockNode {
public constructor(options: MockNodeOptions) {
this.id = options.id;
this.controller = options.controller;
// A node's host is a bit more specialized than the controller's host.
const securityClasses = new Map<number, Map<SecurityClass, boolean>>();
this.host = {
...this.controller.host,
ownNodeId: this.id,
__internalIsMockNode: true,
// Mimic the behavior of ZWaveNode, but for arbitrary node IDs
hasSecurityClass(
nodeId: number,
securityClass: SecurityClass,
): Maybe<boolean> {
return (
securityClasses.get(nodeId)?.get(securityClass) ??
unknownBoolean
);
},
setSecurityClass(
nodeId: number,
securityClass: SecurityClass,
granted: boolean,
): void {
if (!securityClasses.has(nodeId)) {
securityClasses.set(nodeId, new Map());
}
securityClasses.get(nodeId)!.set(securityClass, granted);
},
getHighestSecurityClass(nodeId: number): SecurityClass | undefined {
const map = securityClasses.get(nodeId);
if (!map?.size) return undefined;
let missingSome = false;
for (const secClass of securityClassOrder) {
if (map.get(secClass) === true) return secClass;
if (!map.has(secClass)) {
missingSome = true;
}
}
// If we don't have the info for every security class, we don't know the highest one yet
return missingSome ? undefined : SecurityClass.None;
},
};
const {
commandClasses = [],
endpoints = [],
...capabilities
} = options.capabilities ?? {};
this.capabilities = {
...getDefaultMockNodeCapabilities(),
...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 host: ZWaveHost;
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<number, MockEndpoint>();
/** Can be used by behaviors to store controller related state */
public readonly state = new Map<string, unknown>();
/** Controls whether the controller automatically ACKs node frames before handling them */
public autoAckControllerFrames: boolean = true;
private expectedControllerFrames: TimedExpectation<
MockZWaveFrame,
MockZWaveFrame
>[] = [];
/** Records the frames received from the controller to perform assertions on them */
private receivedControllerFrames: MockZWaveFrame[] = [];
/** Records the frames sent to the controller to perform assertions on them */
private sentControllerFrames: 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<T> {
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);
}
}
/**
* 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 expectControllerACK(timeout: number): Promise<MockZWaveAckFrame> {
return this.expectControllerFrame(
timeout,
(msg): msg is MockZWaveAckFrame =>
msg.type === MockZWaveFrameType.ACK,
);
}
/**
* Sends a {@link MockZWaveFrame} to the {@link MockController}
*/
public async sendToController(
frame: MockZWaveFrame,
): Promise<MockZWaveAckFrame | undefined> {
let ret: Promise<MockZWaveAckFrame> | undefined;
if (frame.type === MockZWaveFrameType.Request && frame.ackRequested) {
ret = this.expectControllerACK(MOCK_FRAME_ACK_TIMEOUT);
}
this.sentControllerFrames.push(frame);
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<void> {
this.receivedControllerFrames.push(frame);
// 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<void> {
await this.sendToController(
createMockZWaveAckFrame({
repeaters: frame?.repeaters,
}),
);
}
/** Adds information about a CC to this mock node */
public addCC(cc: CommandClasses, info: Partial<CommandClassInfo>): 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);
}
/** Asserts that a frame matching the given predicate was received from the controller */
public assertReceivedControllerFrame(
predicate: (frame: MockZWaveFrame) => boolean,
options?: {
noMatch?: boolean;
errorMessage?: string;
},
): void {
const { errorMessage, noMatch } = options ?? {};
const index = this.receivedControllerFrames.findIndex(predicate);
if (index === -1 && !noMatch) {
throw new Error(
`Node ${
this.id
} did not receive a Z-Wave frame matching the predicate!${
errorMessage ? ` ${errorMessage}` : ""
}`,
);
} else if (index > -1 && noMatch) {
throw new Error(
`Node ${
this.id
} received a Z-Wave frame matching the predicate, but this was not expected!${
errorMessage ? ` ${errorMessage}` : ""
}`,
);
}
}
/** Forgets all recorded frames received from the controller */
public clearReceivedControllerFrames(): void {
this.receivedControllerFrames = [];
}
/** Asserts that a frame matching the given predicate was sent to the controller */
public assertSentControllerFrame(
predicate: (frame: MockZWaveFrame) => boolean,
options?: {
noMatch?: boolean;
errorMessage?: string;
},
): void {
const { errorMessage, noMatch } = options ?? {};
const index = this.sentControllerFrames.findIndex(predicate);
if (index === -1 && !noMatch) {
throw new Error(
`Node ${
this.id
} did not send a Z-Wave frame matching the predicate!${
errorMessage ? ` ${errorMessage}` : ""
}`,
);
} else if (index > -1 && noMatch) {
throw new Error(
`Node ${
this.id
} sent a Z-Wave frame matching the predicate, but this was not expected!${
errorMessage ? ` ${errorMessage}` : ""
}`,
);
}
}
/** Forgets all recorded frames sent to the controller */
public clearSentControllerFrames(): void {
this.sentControllerFrames = [];
}
}
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 | undefined> | boolean | undefined;
}