zwave-js
Version:
Z-Wave driver written entirely in JavaScript/TypeScript
375 lines • 15.7 kB
JavaScript
import { getResponder as getMdnsResponder, } from "@homebridge/ciao";
import { BinarySwitchCCValues, MultilevelSwitchCCValues, NodeNamingAndLocationCCValues, NotificationCCValues, SoundSwitchCCValues, SwitchType, } from "@zwave-js/cc";
import { CommandClasses, } from "@zwave-js/core";
import { Faucet, ZWaveSerialFrameType, } from "@zwave-js/serial";
import { createAndOpenMockedZWaveSerialPort, } from "@zwave-js/serial/mock";
import { getErrorMessage } from "@zwave-js/shared";
import { MockController, MockNode, getDefaultMockEndpointCapabilities, getDefaultMockNodeCapabilities, } from "@zwave-js/testing";
import { createDeferredPromise } from "alcalzone-shared/deferred-promise";
import { createServer } from "node:net";
import { createDefaultMockControllerBehaviors, createDefaultMockNodeBehaviors, } from "./Testing.js";
import { ProtocolVersion } from "./Utils.js";
export class MockServer {
options;
constructor(options = {}) {
this.options = options;
}
serialport;
binding;
server;
responder;
service;
mockController;
mockNodes;
async start() {
const { serial, port: mockPort } = await createAndOpenMockedZWaveSerialPort();
this.serialport = serial;
this.binding = mockPort;
console.log("Mock serial port opened");
// Hook up a fake controller and nodes
({ mockController: this.mockController, mockNodes: this.mockNodes } =
await prepareMocks(mockPort, serial, this.options.config?.controller, this.options.config?.nodes));
// Call the init hook if it is defined
if (typeof this.options.config?.onInit === "function") {
this.options.config.onInit(this.mockController, this.mockNodes);
}
// Forward data from the serialport to the socket while one is connected
const faucet = new Faucet(serial.readable);
// Start a TCP server, listen for connections, and forward them to the serial port
this.server = createServer((socket) => {
if (!this.serialport) {
console.error("Serial port not initialized");
socket.destroy();
return;
}
console.log("Client connected");
// Wrap the socket in a writable stream
const writable = new WritableStream({
write: (chunk) => {
if (chunk.type !== ZWaveSerialFrameType.SerialAPI)
return;
if (typeof chunk.data === "number") {
socket.write(Uint8Array.from([chunk.data]));
}
else {
socket.write(chunk.data);
}
},
});
// And forward data from the serial port
faucet.connect(writable);
socket.on("close", () => {
faucet.disconnect();
void writable.close();
console.log("Client disconnected");
});
// Forward data from the socket to the serial port
socket.on("data", async (chunk) => {
await this.serialport?.writeAsync(chunk).catch((e) => {
console.error(`Error writing to serialport`, e);
});
});
});
const port = this.options.port ?? 5555;
this.responder = getMdnsResponder();
this.service = this.responder.createService({
name: "zwave-mock-server",
type: "zwave",
protocol: "tcp" /* Protocol.TCP */,
port,
txt: {
manufacturer: "Z-Wave JS",
model: "Mock Server",
},
});
// Do not allow more than one client to connect
this.server.maxConnections = 1;
const promise = createDeferredPromise();
this.server.on("error", (err) => {
if (err.code === "EADDRINUSE") {
promise.reject(err);
}
});
this.server.listen({
host: this.options.interface,
port,
}, async () => {
const address = this.server.address();
console.log(`Server listening on tcp://${address.address}:${address.port}`);
promise.resolve();
// Advertise the service via mDNS
try {
await this.service.advertise();
console.log(`Enabled mDNS service discovery.`);
}
catch (e) {
console.error(`Failed to enable mDNS service discovery: ${getErrorMessage(e)}`);
}
});
}
async stop() {
console.log("Shutting down mock server...");
await this.service?.end();
await this.service?.destroy();
await this.responder?.shutdown();
this.mockController?.destroy();
this.server?.close();
await this.serialport?.close();
this.binding?.destroy();
console.log("Mock server shut down");
}
}
async function prepareMocks(mockPort, serial, controller = {}, nodes = []) {
const mockController = await MockController.create({
homeId: 0x7e570001,
ownNodeId: 1,
...controller,
mockPort,
serial,
});
// Apply default behaviors that are required for interacting with the driver correctly
mockController.defineBehavior(...createDefaultMockControllerBehaviors());
// Apply custom behaviors
if (controller.behaviors) {
mockController.defineBehavior(...controller.behaviors);
}
const mockNodes = [];
for (const node of nodes) {
const mockNode = await MockNode.create({
...node,
controller: mockController,
});
mockController.addNode(mockNode);
mockNodes.push(mockNode);
// Apply default behaviors that are required for interacting with the driver correctly
mockNode.defineBehavior(...createDefaultMockNodeBehaviors());
// Apply custom behaviors
if (node.behaviors) {
mockNode.defineBehavior(...node.behaviors);
}
}
return {
mockController,
mockNodes,
};
}
export function createMockNodeOptionsFromDump(dump) {
const ret = {
id: dump.id,
};
ret.capabilities = getDefaultMockNodeCapabilities();
if (typeof dump.isListening === "boolean") {
ret.capabilities.isListening = dump.isListening;
}
if (dump.isFrequentListening !== "unknown") {
ret.capabilities.isFrequentListening = dump.isFrequentListening;
}
if (typeof dump.isRouting === "boolean") {
ret.capabilities.isRouting = dump.isRouting;
}
if (typeof dump.supportsBeaming === "boolean") {
ret.capabilities.supportsBeaming = dump.supportsBeaming;
}
if (typeof dump.supportsSecurity === "boolean") {
ret.capabilities.supportsSecurity = dump.supportsSecurity;
}
if (typeof dump.supportedDataRates === "boolean") {
ret.capabilities.supportedDataRates = dump.supportedDataRates;
}
if (ProtocolVersion[dump.protocol] !== undefined) {
ret.capabilities.protocolVersion =
ProtocolVersion[dump.protocol];
}
if (dump.deviceClass !== "unknown") {
ret.capabilities.basicDeviceClass = dump.deviceClass.basic.key;
ret.capabilities.genericDeviceClass = dump.deviceClass.generic.key;
ret.capabilities.specificDeviceClass = dump.deviceClass.specific.key;
}
ret.capabilities.firmwareVersion = dump.fingerprint.firmwareVersion;
ret.capabilities.manufacturerId = parseInt(dump.fingerprint.manufacturerId, 16);
ret.capabilities.productType = parseInt(dump.fingerprint.productType, 16);
ret.capabilities.productId = parseInt(dump.fingerprint.productId, 16);
for (const [ccName, ccDump] of Object.entries(dump.commandClasses)) {
const ccId = CommandClasses[ccName];
if (ccId == undefined)
continue;
// FIXME: Security encapsulation is not supported yet in mocks
if (ccId === CommandClasses.Security
|| ccId === CommandClasses["Security 2"]) {
continue;
}
// FIXME: Supervision encapsulation is not supported yet in mocks
if (ccId === CommandClasses.Supervision) {
continue;
}
// FIXME: Transport Service encapsulation is not supported yet in mocks
if (ccId === CommandClasses["Transport Service"]) {
continue;
}
ret.capabilities.commandClasses ??= [];
ret.capabilities.commandClasses.push(createCCCapabilitiesFromDump(ccId, ccDump));
}
if (dump.endpoints) {
// oxlint-disable-next-line no-unused-vars
for (const [indexStr, endpointDump] of Object.entries(dump.endpoints)) {
// FIXME: The mocks expect endpoints to be consecutive
// const index = parseInt(indexStr);
const epCaps = getDefaultMockEndpointCapabilities(
// @ts-expect-error We are initializing the device classes above
ret.capabilities);
let epCCs;
if (endpointDump.deviceClass !== "unknown") {
epCaps.genericDeviceClass =
endpointDump.deviceClass.generic.key;
epCaps.specificDeviceClass =
endpointDump.deviceClass.specific.key;
}
for (const [ccName, ccDump] of Object.entries(endpointDump.commandClasses)) {
const ccId = CommandClasses[ccName];
if (ccId == undefined)
continue;
// FIXME: Security encapsulation is not supported yet in mocks
if (ccId === CommandClasses.Security
|| ccId === CommandClasses["Security 2"]) {
continue;
}
epCCs ??= [];
epCCs.push(createCCCapabilitiesFromDump(ccId, ccDump));
}
ret.capabilities.endpoints ??= [];
ret.capabilities.endpoints.push({
...epCaps,
commandClasses: epCCs,
});
}
}
return ret;
}
function createCCCapabilitiesFromDump(ccId, dump) {
const ret = {
ccId,
isSupported: dump.isSupported,
isControlled: dump.isControlled,
secure: dump.secure,
version: dump.version,
};
// Parse CC specific info from values
if (ccId === CommandClasses.Configuration) {
Object.assign(ret, createConfigurationCCCapabilitiesFromDump(dump));
}
else if (ccId === CommandClasses.Notification) {
Object.assign(ret, createNotificationCCCapabilitiesFromDump(dump));
}
else if (ccId === CommandClasses["Binary Switch"]) {
Object.assign(ret, createBinarySwitchCCCapabilitiesFromDump(dump));
}
else if (ccId === CommandClasses["Multilevel Switch"]) {
Object.assign(ret, createMultilevelSwitchCCCapabilitiesFromDump(dump));
}
else if (ccId === CommandClasses["Sound Switch"]) {
Object.assign(ret, createSoundSwitchCCCapabilitiesFromDump(dump));
}
else if (ccId === CommandClasses["Node Naming and Location"]) {
Object.assign(ret, createNodeNamingAndLocationCCCapabilitiesFromDump(dump));
}
return ret;
}
function createConfigurationCCCapabilitiesFromDump(dump) {
const ret = {
bulkSupport: false,
parameters: [],
};
for (const val of dump.values) {
if (typeof val.property !== "number")
continue;
// Mocks don't support partial parameters
if (val.propertyKey != undefined)
continue;
// Metadata contains the param information
if (!val.metadata)
continue;
const meta = val.metadata;
ret.parameters.push({
"#": val.property,
valueSize: meta.valueSize ?? 1,
name: meta.label,
info: meta.description,
format: meta.format,
minValue: meta.min,
maxValue: meta.max,
defaultValue: meta.default,
readonly: !meta.writeable,
});
}
return ret;
}
function createNotificationCCCapabilitiesFromDump(dump) {
const supportsV1Alarm = findDumpedValue(dump, CommandClasses.Notification, NotificationCCValues.supportsV1Alarm.id, false);
const ret = {
supportsV1Alarm,
notificationTypesAndEvents: {},
};
const supportedNotificationTypes = findDumpedValue(dump, CommandClasses.Notification, NotificationCCValues.supportedNotificationTypes.id, []);
for (const type of supportedNotificationTypes) {
const supportedEvents = findDumpedValue(dump, CommandClasses.Notification, NotificationCCValues.supportedNotificationEvents(type).id, []);
ret.notificationTypesAndEvents[type] = supportedEvents;
}
return ret;
}
function createBinarySwitchCCCapabilitiesFromDump(dump) {
const defaultValue = findDumpedValue(dump, CommandClasses["Binary Switch"], BinarySwitchCCValues.currentValue.id, undefined);
return {
defaultValue,
};
}
function createMultilevelSwitchCCCapabilitiesFromDump(dump) {
const defaultValue = findDumpedValue(dump, CommandClasses["Multilevel Switch"], MultilevelSwitchCCValues.currentValue.id, undefined);
const switchType = findDumpedValue(dump, CommandClasses["Multilevel Switch"], MultilevelSwitchCCValues.switchType.id, SwitchType["Down/Up"]);
return {
defaultValue,
primarySwitchType: switchType,
};
}
function createSoundSwitchCCCapabilitiesFromDump(dump) {
const defaultToneId = findDumpedValue(dump, CommandClasses["Sound Switch"], SoundSwitchCCValues.defaultToneId.id, 1);
const defaultVolume = findDumpedValue(dump, CommandClasses["Sound Switch"], SoundSwitchCCValues.defaultVolume.id, 50);
const ret = {
defaultToneId,
defaultVolume,
tones: [],
};
const tonesMetadata = findDumpedMetadata(dump, CommandClasses["Sound Switch"], SoundSwitchCCValues.toneId.id);
if (tonesMetadata?.states) {
for (const [toneIdStr, nameAndDuration] of Object.entries(tonesMetadata.states)) {
const toneId = parseInt(toneIdStr);
if (Number.isNaN(toneId) || toneId < 1 || toneId > 0xfe)
continue;
const durationIndex = nameAndDuration.lastIndexOf("(");
if (durationIndex === -1)
continue;
const name = nameAndDuration.slice(0, durationIndex).trim();
const duration = parseInt(nameAndDuration.slice(durationIndex + 1, -1), 10);
if (Number.isNaN(duration))
continue;
ret.tones.push({ name, duration });
}
}
return ret;
}
function createNodeNamingAndLocationCCCapabilitiesFromDump(dump) {
const name = findDumpedValue(dump, CommandClasses["Node Naming and Location"], NodeNamingAndLocationCCValues.name.id, undefined);
const location = findDumpedValue(dump, CommandClasses["Node Naming and Location"], NodeNamingAndLocationCCValues.location.id, undefined);
return {
name,
location,
};
}
function findDumpedValue(dump, commandClass, valueId, defaultValue) {
return (dump.values.find((id) => id.property === valueId.property
&& id.propertyKey === valueId.propertyKey)?.value) ?? defaultValue;
}
function findDumpedMetadata(dump, commandClass, valueId) {
return dump.values.find((id) => id.property === valueId.property
&& id.propertyKey === valueId.propertyKey)?.metadata;
}
//# sourceMappingURL=mockServer.js.map