UNPKG

zwave-js

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

375 lines 15.7 kB
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