From 2823a1c718e59ff32505ca1d081c40349c646c0c Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 5 May 2026 13:09:56 +0000 Subject: [PATCH] Add native Z-Wave JS integration --- test/zwave_js/test.zwave_js.discovery.node.ts | 18 + test/zwave_js/test.zwave_js.mapper.node.ts | 44 +++ ts/index.ts | 2 + ts/integrations/generated/index.ts | 7 +- .../zwave_js/.generated-by-smarthome-exchange | 1 - ts/integrations/zwave_js/index.ts | 4 + .../zwave_js/zwave_js.classes.client.ts | 188 ++++++++++ .../zwave_js/zwave_js.classes.configflow.ts | 40 +++ .../zwave_js/zwave_js.classes.integration.ts | 151 ++++++-- .../zwave_js/zwave_js.discovery.ts | 125 +++++++ ts/integrations/zwave_js/zwave_js.mapper.ts | 321 ++++++++++++++++++ ts/integrations/zwave_js/zwave_js.types.ts | 138 +++++++- 12 files changed, 1003 insertions(+), 36 deletions(-) create mode 100644 test/zwave_js/test.zwave_js.discovery.node.ts create mode 100644 test/zwave_js/test.zwave_js.mapper.node.ts delete mode 100644 ts/integrations/zwave_js/.generated-by-smarthome-exchange create mode 100644 ts/integrations/zwave_js/zwave_js.classes.client.ts create mode 100644 ts/integrations/zwave_js/zwave_js.classes.configflow.ts create mode 100644 ts/integrations/zwave_js/zwave_js.discovery.ts create mode 100644 ts/integrations/zwave_js/zwave_js.mapper.ts diff --git a/test/zwave_js/test.zwave_js.discovery.node.ts b/test/zwave_js/test.zwave_js.discovery.node.ts new file mode 100644 index 0000000..0fb85c7 --- /dev/null +++ b/test/zwave_js/test.zwave_js.discovery.node.ts @@ -0,0 +1,18 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createZwaveJsDiscoveryDescriptor } from '../../ts/integrations/zwave_js/index.js'; + +tap.test('matches Z-Wave JS Server mDNS records', async () => { + const descriptor = createZwaveJsDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + type: '_zwave-js-server._tcp.local.', + name: 'Z-Wave JS._zwave-js-server._tcp.local.', + host: 'zwave.local', + port: 3000, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('zwave.local'); + expect(result.candidate?.metadata?.url).toEqual('ws://zwave.local:3000'); +}); + +export default tap.start(); diff --git a/test/zwave_js/test.zwave_js.mapper.node.ts b/test/zwave_js/test.zwave_js.mapper.node.ts new file mode 100644 index 0000000..d35899d --- /dev/null +++ b/test/zwave_js/test.zwave_js.mapper.node.ts @@ -0,0 +1,44 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { ZwaveJsMapper } from '../../ts/integrations/zwave_js/index.js'; + +const snapshot = ZwaveJsMapper.toSnapshot({ + version: { serverVersion: '1.39.0', driverVersion: '15.0.0', homeId: 123456 }, + controller: { homeId: 123456, ownNodeId: 1 }, + nodes: [{ + nodeId: 2, + name: 'Living Room Switch', + manufacturer: 'Zooz', + productLabel: 'ZEN71', + status: 'alive', + ready: true, + values: { + '37-0-currentValue': { + commandClass: 37, + commandClassName: 'Binary Switch', + endpoint: 0, + property: 'currentValue', + metadata: { label: 'Switch', type: 'boolean', readable: true, writeable: true }, + value: true, + }, + '50-0-value': { + commandClass: 50, + commandClassName: 'Meter', + endpoint: 0, + property: 'value', + propertyKey: 'Electric_kWh_Consumed', + metadata: { label: 'Electric consumption', type: 'number', readable: true, writeable: false, unit: 'kWh' }, + value: 12.4, + }, + }, + }], +}); + +tap.test('maps Z-Wave JS nodes and values to devices and entities', async () => { + const devices = ZwaveJsMapper.toDevices(snapshot); + const entities = ZwaveJsMapper.toEntities(snapshot); + expect(devices.some((deviceArg) => deviceArg.id === 'zwave_js.node.2')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'switch' && entityArg.state === 'on')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'sensor' && entityArg.state === 12.4)).toBeTrue(); +}); + +export default tap.start(); diff --git a/ts/index.ts b/ts/index.ts index 981d510..086f18a 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -9,6 +9,7 @@ import { RokuIntegration } from './integrations/roku/index.js'; import { ShellyIntegration } from './integrations/shelly/index.js'; import { SonosIntegration } from './integrations/sonos/index.js'; import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js'; +import { ZwaveJsIntegration } from './integrations/zwave_js/index.js'; import { generatedHomeAssistantPortIntegrations } from './integrations/generated/index.js'; import { IntegrationRegistry } from './core/index.js'; @@ -20,6 +21,7 @@ export const integrations = [ new ShellyIntegration(), new SonosIntegration(), new WolfSmartsetIntegration(), + new ZwaveJsIntegration(), ]; export const createDefaultIntegrationRegistry = (): IntegrationRegistry => { diff --git a/ts/integrations/generated/index.ts b/ts/integrations/generated/index.ts index 80ab053..c9f81e6 100644 --- a/ts/integrations/generated/index.ts +++ b/ts/integrations/generated/index.ts @@ -1451,7 +1451,6 @@ import { HomeAssistantZodiacIntegration } from '../zodiac/index.js'; import { HomeAssistantZondergasIntegration } from '../zondergas/index.js'; import { HomeAssistantZoneIntegration } from '../zone/index.js'; import { HomeAssistantZoneminderIntegration } from '../zoneminder/index.js'; -import { HomeAssistantZwaveJsIntegration } from '../zwave_js/index.js'; import { HomeAssistantZwaveMeIntegration } from '../zwave_me/index.js'; export const generatedHomeAssistantPortIntegrations: BaseIntegration[] = []; @@ -2905,15 +2904,15 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZodiacIntegration() generatedHomeAssistantPortIntegrations.push(new HomeAssistantZondergasIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveJsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); -export const generatedHomeAssistantPortCount = 1452; +export const generatedHomeAssistantPortCount = 1451; export const handwrittenHomeAssistantPortDomains = [ "cast", "hue", "mqtt", "roku", "shelly", - "sonos" + "sonos", + "zwave_js" ]; diff --git a/ts/integrations/zwave_js/.generated-by-smarthome-exchange b/ts/integrations/zwave_js/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/zwave_js/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/zwave_js/index.ts b/ts/integrations/zwave_js/index.ts index 4b60de0..6b4ef0c 100644 --- a/ts/integrations/zwave_js/index.ts +++ b/ts/integrations/zwave_js/index.ts @@ -1,2 +1,6 @@ export * from './zwave_js.classes.integration.js'; +export * from './zwave_js.classes.client.js'; +export * from './zwave_js.classes.configflow.js'; +export * from './zwave_js.discovery.js'; +export * from './zwave_js.mapper.js'; export * from './zwave_js.types.js'; diff --git a/ts/integrations/zwave_js/zwave_js.classes.client.ts b/ts/integrations/zwave_js/zwave_js.classes.client.ts new file mode 100644 index 0000000..929f19c --- /dev/null +++ b/ts/integrations/zwave_js/zwave_js.classes.client.ts @@ -0,0 +1,188 @@ +import * as plugins from '../../plugins.js'; +import type { IZwaveJsConfig, IZwaveJsServerCommand, IZwaveJsServerEvent, IZwaveJsSnapshot, IZwaveJsState, IZwaveJsVersion } from './zwave_js.types.js'; +import { ZwaveJsMapper } from './zwave_js.mapper.js'; + +type TEventHandler = (eventArg: IZwaveJsServerEvent) => void; + +interface IPendingRequest { + resolve(valueArg: unknown): void; + reject(errorArg: Error): void; + timer: ReturnType; +} + +export class ZwaveJsClient { + private socket?: any; + private started = false; + private version?: IZwaveJsVersion; + private state?: IZwaveJsState; + private readonly events: IZwaveJsServerEvent[] = []; + private readonly pendingRequests = new Map(); + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IZwaveJsConfig) { + this.version = config.version; + this.state = config.state || { controller: config.controller, nodes: config.nodes || [] }; + } + + public async getSnapshot(): Promise { + if (this.config.url && !this.started) { + await this.start(); + } + return { + ...ZwaveJsMapper.toSnapshot({ ...this.config, version: this.version, state: this.state }, Boolean(this.socket && this.socket.readyState === 1), this.events), + url: this.config.url, + }; + } + + public async start(): Promise { + if (this.started || !this.config.url) { + this.started = true; + return; + } + const WebSocketCtor = (globalThis as typeof globalThis & { WebSocket?: new (urlArg: string) => any }).WebSocket; + if (!WebSocketCtor) { + throw new Error('Global WebSocket is not available in this runtime.'); + } + const socket = new WebSocketCtor(this.config.url); + this.socket = socket; + socket.addEventListener('message', (eventArg: { data: unknown }) => this.handleMessage(eventArg.data)); + socket.addEventListener('close', () => this.rejectAll(new Error('Z-Wave JS WebSocket closed.'))); + await new Promise((resolve, reject) => { + socket.addEventListener('open', () => resolve(), { once: true }); + socket.addEventListener('error', () => reject(new Error(`Unable to connect to Z-Wave JS Server at ${this.config.url}.`)), { once: true }); + }); + await this.waitForVersion(); + await this.sendCommand({ command: 'initialize', schemaVersion: this.config.schemaVersion ?? this.version?.maxSchemaVersion ?? 0, additionalUserAgentComponents: { 'smarthome.exchange': '0.1.0' } }).catch(async () => { + await this.sendCommand({ command: 'set_api_schema', schemaVersion: this.config.schemaVersion ?? this.version?.maxSchemaVersion ?? 0 }); + }); + const result = await this.sendCommand({ command: 'start_listening' }); + if (this.isRecord(result) && this.isRecord(result.state)) { + this.state = result.state as IZwaveJsState; + } + this.started = true; + } + + public async sendCommand(commandArg: IZwaveJsServerCommand): Promise { + await this.ensureStarted(); + if (!this.socket || this.socket.readyState !== 1) { + throw new Error('Z-Wave JS WebSocket is not connected.'); + } + const messageId = `shx-${plugins.crypto.randomBytes(6).toString('hex')}`; + const payload = { ...commandArg, messageId }; + const promise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(messageId); + reject(new Error(`Z-Wave JS command ${commandArg.command} timed out.`)); + }, 10000); + this.pendingRequests.set(messageId, { resolve, reject, timer }); + }); + this.socket.send(JSON.stringify(payload)); + return promise; + } + + public onEvent(handlerArg: TEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async destroy(): Promise { + this.rejectAll(new Error('Z-Wave JS client destroyed.')); + if (this.socket?.readyState === 1) { + this.socket.close(); + } + this.socket = undefined; + this.started = false; + this.eventHandlers.clear(); + } + + private async ensureStarted(): Promise { + if (!this.started) { + await this.start(); + } + } + + private async waitForVersion(): Promise { + if (this.version) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + private handleMessage(dataArg: unknown): void { + const text = typeof dataArg === 'string' ? dataArg : dataArg instanceof Buffer ? dataArg.toString('utf8') : String(dataArg); + const message = JSON.parse(text) as Record; + if (message.type === 'version') { + this.version = message as IZwaveJsVersion; + return; + } + if (message.type === 'result') { + this.handleResult(message); + return; + } + if (message.type === 'event' && this.isRecord(message.event)) { + const event = message.event as IZwaveJsServerEvent; + this.events.push(event); + this.applyEvent(event); + for (const handler of this.eventHandlers) { + handler(event); + } + } + } + + private handleResult(messageArg: Record): void { + const messageId = typeof messageArg.messageId === 'string' ? messageArg.messageId : undefined; + if (!messageId) { + return; + } + const pending = this.pendingRequests.get(messageId); + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pendingRequests.delete(messageId); + if (messageArg.success === false) { + pending.reject(new Error(`Z-Wave JS command failed: ${JSON.stringify(messageArg.error || messageArg)}`)); + return; + } + pending.resolve(messageArg.result); + } + + private applyEvent(eventArg: IZwaveJsServerEvent): void { + if (eventArg.source !== 'node' || typeof eventArg.nodeId !== 'number') { + return; + } + const nodes = this.state?.nodes || []; + const node = nodes.find((nodeArg) => nodeArg.nodeId === eventArg.nodeId); + if (!node) { + return; + } + if (eventArg.event === 'value updated' && eventArg.valueId) { + const value = ZwaveJsMapper.nodeValues(node).find((valueArg) => this.valueIdMatches(valueArg, eventArg.valueId!)); + if (value) { + value.value = eventArg.newValue ?? eventArg.value; + } + } + if (eventArg.event === 'node status' && typeof eventArg.status === 'string') { + node.status = eventArg.status; + } + } + + private valueIdMatches(valueArg: ReturnType[number], valueIdArg: NonNullable): boolean { + return (valueArg.commandClass || 0) === valueIdArg.commandClass + && (valueArg.endpoint || 0) === (valueIdArg.endpoint || 0) + && String(valueArg.property ?? valueArg.propertyName ?? '') === String(valueIdArg.property) + && String(valueArg.propertyKey ?? '') === String(valueIdArg.propertyKey ?? ''); + } + + private rejectAll(errorArg: Error): void { + for (const [messageId, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(errorArg); + this.pendingRequests.delete(messageId); + } + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/zwave_js/zwave_js.classes.configflow.ts b/ts/integrations/zwave_js/zwave_js.classes.configflow.ts new file mode 100644 index 0000000..20d4f0f --- /dev/null +++ b/ts/integrations/zwave_js/zwave_js.classes.configflow.ts @@ -0,0 +1,40 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IZwaveJsConfig } from './zwave_js.types.js'; + +export class ZwaveJsConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Z-Wave JS', + description: 'Configure the local Z-Wave JS Server WebSocket endpoint.', + fields: [ + { name: 'url', label: 'Server URL', type: 'text', required: true }, + { name: 'schemaVersion', label: 'API schema version', type: 'number' }, + ], + submit: async (valuesArg) => ({ + kind: 'done', + title: 'Z-Wave JS configured', + config: { + url: this.stringValue(valuesArg.url) || this.urlFromCandidate(candidateArg), + schemaVersion: this.numberValue(valuesArg.schemaVersion), + }, + }), + }; + } + + private urlFromCandidate(candidateArg: IDiscoveryCandidate): string { + if (typeof candidateArg.metadata?.url === 'string') { + return candidateArg.metadata.url; + } + return candidateArg.host ? `ws://${candidateArg.host}:${candidateArg.port || 3000}` : 'ws://localhost:3000'; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } +} diff --git a/ts/integrations/zwave_js/zwave_js.classes.integration.ts b/ts/integrations/zwave_js/zwave_js.classes.integration.ts index 2094d5b..e76804b 100644 --- a/ts/integrations/zwave_js/zwave_js.classes.integration.ts +++ b/ts/integrations/zwave_js/zwave_js.classes.integration.ts @@ -1,33 +1,126 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { ZwaveJsClient } from './zwave_js.classes.client.js'; +import { ZwaveJsConfigFlow } from './zwave_js.classes.configflow.js'; +import { createZwaveJsDiscoveryDescriptor } from './zwave_js.discovery.js'; +import { ZwaveJsMapper } from './zwave_js.mapper.js'; +import type { IZwaveJsConfig, IZwaveJsServerCommand } from './zwave_js.types.js'; -export class HomeAssistantZwaveJsIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "zwave_js", - displayName: "Z-Wave", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/zwave_js", - "upstreamDomain": "zwave_js", - "integrationType": "hub", - "iotClass": "local_push", - "requirements": [ - "zwave-js-server-python==0.70.0" - ], - "dependencies": [ - "http", - "repairs", - "usb", - "websocket_api" - ], - "afterDependencies": [ - "hassio" - ], - "codeowners": [ - "@home-assistant/z-wave" - ] -}, +export class ZwaveJsIntegration extends BaseIntegration { + public readonly domain = 'zwave_js'; + public readonly displayName = 'Z-Wave'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createZwaveJsDiscoveryDescriptor(); + public readonly configFlow = new ZwaveJsConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/zwave_js', + upstreamDomain: 'zwave_js', + integrationType: 'hub', + iotClass: 'local_push', + qualityScale: 'unknown', + }; + + public async setup(configArg: IZwaveJsConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new ZwaveJsRuntime(new ZwaveJsClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantZwaveJsIntegration extends ZwaveJsIntegration {} + +class ZwaveJsRuntime implements IIntegrationRuntime { + public domain = 'zwave_js'; + + constructor(private readonly client: ZwaveJsClient) {} + + public async devices(): Promise { + return ZwaveJsMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return ZwaveJsMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => { + handlerArg({ + type: eventArg.event === 'value updated' ? 'state_changed' : 'device_added', + integrationDomain: 'zwave_js', + deviceId: typeof eventArg.nodeId === 'number' ? `zwave_js.node.${eventArg.nodeId}` : undefined, + data: eventArg, + timestamp: Date.now(), + }); }); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + if (requestArg.domain === 'zwave_js') { + const command = this.commandFromService(requestArg); + if (!command) { + return { success: false, error: `Unsupported Z-Wave JS service: ${requestArg.service}` }; + } + const data = await this.client.sendCommand(command); + return { success: true, data }; + } + + const command = ZwaveJsMapper.commandForService(await this.client.getSnapshot(), requestArg); + if (!command) { + return { success: false, error: `Z-Wave JS entity service ${requestArg.domain}.${requestArg.service} has no writable value mapping.` }; + } + const data = await this.client.sendCommand(command); + return { success: true, data }; + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private commandFromService(requestArg: IServiceCallRequest): IZwaveJsServerCommand | undefined { + if (requestArg.service === 'ping') { + const nodeId = this.numberValue(requestArg.data?.node_id ?? requestArg.data?.nodeId); + return nodeId ? { command: 'node.ping', nodeId } : undefined; + } + if (requestArg.service === 'refresh_value') { + const nodeId = this.numberValue(requestArg.data?.node_id ?? requestArg.data?.nodeId); + const valueId = requestArg.data?.value_id ?? requestArg.data?.valueId; + return nodeId && this.isRecord(valueId) ? { command: 'node.poll_value', nodeId, valueId } : undefined; + } + if (requestArg.service === 'set_value') { + const nodeId = this.numberValue(requestArg.data?.node_id ?? requestArg.data?.nodeId); + const valueId = requestArg.data?.value_id ?? requestArg.data?.valueId; + return nodeId && this.isRecord(valueId) ? { command: 'node.set_value', nodeId, valueId, value: requestArg.data?.value } : undefined; + } + if (requestArg.service === 'set_config_parameter') { + const nodeId = this.numberValue(requestArg.data?.node_id ?? requestArg.data?.nodeId); + const parameter = this.numberValue(requestArg.data?.parameter); + return nodeId && parameter ? { command: 'node.set_raw_config_parameter_value', nodeId, parameter, bitMask: requestArg.data?.bitmask, value: requestArg.data?.value, valueSize: requestArg.data?.value_size, valueFormat: requestArg.data?.value_format } : undefined; + } + if (requestArg.service === 'begin_inclusion') { + return { command: 'controller.begin_inclusion', strategy: requestArg.data?.strategy || 0 }; + } + if (requestArg.service === 'stop_inclusion') { + return { command: 'controller.stop_inclusion' }; + } + if (requestArg.service === 'invoke_cc_api') { + const nodeId = this.numberValue(requestArg.data?.node_id ?? requestArg.data?.nodeId); + const commandClass = this.numberValue(requestArg.data?.command_class ?? requestArg.data?.commandClass); + const methodName = requestArg.data?.method_name ?? requestArg.data?.methodName; + return nodeId && commandClass && typeof methodName === 'string' ? { command: 'endpoint.invoke_cc_api', nodeId, endpoint: requestArg.data?.endpoint, commandClass, methodName, args: Array.isArray(requestArg.data?.parameters) ? requestArg.data.parameters : [] } : undefined; + } + return undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); } } diff --git a/ts/integrations/zwave_js/zwave_js.discovery.ts b/ts/integrations/zwave_js/zwave_js.discovery.ts new file mode 100644 index 0000000..6e9ad55 --- /dev/null +++ b/ts/integrations/zwave_js/zwave_js.discovery.ts @@ -0,0 +1,125 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IZwaveJsManualEntry, IZwaveJsMdnsRecord, IZwaveJsUsbRecord } from './zwave_js.types.js'; + +const knownUsbIds = new Set(['0658:0200', '10c4:8a2a', '303a:4001', '10c4:ea60']); + +export class ZwaveJsMdnsMatcher implements IDiscoveryMatcher { + public id = 'zwave-js-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Z-Wave JS Server mDNS advertisements.'; + + public async matches(recordArg: IZwaveJsMdnsRecord): Promise { + const type = (recordArg.type || '').toLowerCase().replace(/\.$/, ''); + const matched = type === '_zwave-js-server._tcp.local' || Boolean(recordArg.name?.toLowerCase().includes('zwave-js')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a Z-Wave JS Server advertisement.' }; + } + return { + matched: true, + confidence: recordArg.host ? 'certain' : 'high', + reason: 'mDNS record matches Z-Wave JS Server metadata.', + normalizedDeviceId: recordArg.name, + candidate: { + source: 'mdns', + integrationDomain: 'zwave_js', + id: recordArg.name, + host: recordArg.host, + port: recordArg.port || 3000, + name: 'Z-Wave JS Server', + manufacturer: 'Z-Wave JS', + model: 'WebSocket Server', + metadata: { mdnsName: recordArg.name, mdnsType: recordArg.type, txt: recordArg.txt, url: recordArg.host ? `ws://${recordArg.host}:${recordArg.port || 3000}` : undefined }, + }, + }; + } +} + +export class ZwaveJsUsbMatcher implements IDiscoveryMatcher { + public id = 'zwave-js-usb-match'; + public source = 'usb' as const; + public description = 'Recognize known Z-Wave USB adapters used by Z-Wave JS.'; + + public async matches(recordArg: IZwaveJsUsbRecord): Promise { + const usbId = `${(recordArg.vid || '').toLowerCase()}:${(recordArg.pid || '').toLowerCase()}`; + const description = `${recordArg.manufacturer || ''} ${recordArg.description || ''}`.toLowerCase(); + const matched = knownUsbIds.has(usbId) || description.includes('z-wave') || description.includes('zwave') || description.includes('zwa-2'); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'USB device is not a known Z-Wave adapter.' }; + } + return { + matched: true, + confidence: knownUsbIds.has(usbId) ? 'certain' : 'high', + reason: 'USB record matches known Z-Wave adapter metadata.', + normalizedDeviceId: recordArg.serialNumber || recordArg.path || usbId, + candidate: { + source: 'usb', + integrationDomain: 'zwave_js', + id: recordArg.serialNumber || recordArg.path || usbId, + name: recordArg.description || 'Z-Wave adapter', + manufacturer: recordArg.manufacturer || 'Z-Wave', + model: recordArg.description, + serialNumber: recordArg.serialNumber, + metadata: { vid: recordArg.vid, pid: recordArg.pid, path: recordArg.path }, + }, + }; + } +} + +export class ZwaveJsManualMatcher implements IDiscoveryMatcher { + public id = 'zwave-js-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Z-Wave JS setup entries.'; + + public async matches(inputArg: IZwaveJsManualEntry): Promise { + const model = inputArg.model?.toLowerCase() || ''; + const url = inputArg.url || (inputArg.host ? `ws://${inputArg.host}:${inputArg.port || 3000}` : undefined); + const matched = Boolean(url || inputArg.usbPath || inputArg.metadata?.zwaveJs || model.includes('z-wave') || model.includes('zwave')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Z-Wave JS setup hints.' }; + } + return { + matched: true, + confidence: url ? 'high' : 'medium', + reason: 'Manual entry can start Z-Wave JS setup.', + normalizedDeviceId: inputArg.id || url || inputArg.usbPath, + candidate: { + source: 'manual', + integrationDomain: 'zwave_js', + id: inputArg.id || url || inputArg.usbPath, + host: inputArg.host, + port: inputArg.port || 3000, + name: inputArg.name || 'Z-Wave JS', + manufacturer: 'Z-Wave JS', + model: inputArg.model || 'WebSocket Server', + metadata: { ...inputArg.metadata, url, usbPath: inputArg.usbPath }, + }, + }; + } +} + +export class ZwaveJsCandidateValidator implements IDiscoveryValidator { + public id = 'zwave-js-candidate-validator'; + public description = 'Validate Z-Wave JS candidate metadata.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const model = candidateArg.model?.toLowerCase() || ''; + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const matched = candidateArg.integrationDomain === 'zwave_js' || model.includes('z-wave') || model.includes('zwave') || manufacturer.includes('z-wave') || Boolean(candidateArg.metadata?.zwaveJs); + return { + matched, + confidence: matched && (candidateArg.host || candidateArg.metadata?.url || candidateArg.source === 'usb') ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Z-Wave JS metadata.' : 'Candidate is not Z-Wave JS.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: candidateArg.id, + }; + } +} + +export const createZwaveJsDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'zwave_js', displayName: 'Z-Wave' }) + .addMatcher(new ZwaveJsMdnsMatcher()) + .addMatcher(new ZwaveJsUsbMatcher()) + .addMatcher(new ZwaveJsManualMatcher()) + .addValidator(new ZwaveJsCandidateValidator()); +}; diff --git a/ts/integrations/zwave_js/zwave_js.mapper.ts b/ts/integrations/zwave_js/zwave_js.mapper.ts new file mode 100644 index 0000000..d156194 --- /dev/null +++ b/ts/integrations/zwave_js/zwave_js.mapper.ts @@ -0,0 +1,321 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { IZwaveJsConfig, IZwaveJsNode, IZwaveJsServerCommand, IZwaveJsSnapshot, IZwaveJsValue, IZwaveJsValueId } from './zwave_js.types.js'; + +export class ZwaveJsMapper { + public static toSnapshot(configArg: IZwaveJsConfig, connectedArg = false, eventsArg: IZwaveJsSnapshot['events'] = []): IZwaveJsSnapshot { + return { + version: configArg.version, + controller: configArg.controller || configArg.state?.controller, + nodes: configArg.nodes || configArg.state?.nodes || [], + events: eventsArg, + connected: connectedArg, + url: configArg.url, + }; + } + + public static toDevices(snapshotArg: IZwaveJsSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = []; + devices.push({ + id: this.controllerDeviceId(snapshotArg), + integrationDomain: 'zwave_js', + name: 'Z-Wave controller', + protocol: 'zwave', + manufacturer: 'Z-Wave JS', + model: 'Controller', + online: snapshotArg.connected || Boolean(snapshotArg.nodes.length), + features: [ + { id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false }, + { id: 'node_count', capability: 'sensor', name: 'Node count', readable: true, writable: false }, + ], + state: [ + { featureId: 'connection', value: snapshotArg.connected ? 'connected' : 'configured', updatedAt }, + { featureId: 'node_count', value: snapshotArg.nodes.length, updatedAt }, + ], + metadata: { + homeId: snapshotArg.controller?.homeId ?? snapshotArg.version?.homeId, + serverVersion: snapshotArg.version?.serverVersion, + driverVersion: snapshotArg.version?.driverVersion, + url: snapshotArg.url, + }, + }); + + for (const node of snapshotArg.nodes) { + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'status', capability: 'sensor', name: 'Node status', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'status', value: node.status || (node.ready ? 'ready' : 'unknown'), updatedAt }, + ]; + for (const value of this.nodeValues(node)) { + const feature = this.featureForValue(value); + features.push(feature); + state.push({ featureId: feature.id, value: this.stateValue(value.value), updatedAt }); + } + devices.push({ + id: this.nodeDeviceId(node), + integrationDomain: 'zwave_js', + name: this.nodeName(node), + room: node.location, + protocol: 'zwave', + manufacturer: node.manufacturer, + model: node.productLabel || node.productDescription, + online: node.status ? !['dead', 'asleep'].includes(node.status.toLowerCase()) : node.ready !== false, + features, + state, + metadata: { + nodeId: node.nodeId, + manufacturerId: node.manufacturerId, + productId: node.productId, + productType: node.productType, + firmwareVersion: node.firmwareVersion, + isControllerNode: node.isControllerNode, + }, + }); + } + return devices; + } + + public static toEntities(snapshotArg: IZwaveJsSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + for (const node of snapshotArg.nodes) { + const deviceId = this.nodeDeviceId(node); + for (const value of this.nodeValues(node)) { + const platform = this.platformForValue(value); + entities.push({ + id: `${platform}.${this.slug(`${this.nodeName(node)} ${this.valueLabel(value)}`)}`, + uniqueId: `zwave_js_${this.slug(`${node.nodeId}_${this.valueIdKey(value)}`)}`, + integrationDomain: 'zwave_js', + deviceId, + platform, + name: this.valueLabel(value), + state: this.entityState(value, platform), + attributes: { + nodeId: node.nodeId, + valueId: this.valueId(value), + commandClass: value.commandClass, + commandClassName: value.commandClassName, + endpoint: value.endpoint, + property: value.property, + propertyKey: value.propertyKey, + unit: value.metadata?.unit, + zwaveType: value.metadata?.type, + states: value.metadata?.states, + }, + available: node.ready !== false && node.status !== 'dead', + }); + } + } + return entities; + } + + public static commandForService(snapshotArg: IZwaveJsSnapshot, requestArg: IServiceCallRequest): IZwaveJsServerCommand | undefined { + const target = this.findTargetValue(snapshotArg, requestArg); + if (!target) { + return undefined; + } + let value: unknown; + if (requestArg.service === 'turn_on') { + value = this.onValue(target.value); + } else if (requestArg.service === 'turn_off') { + value = this.offValue(target.value); + } else if (requestArg.service === 'set_value') { + value = requestArg.data?.value; + } else if (requestArg.service === 'set_position') { + value = requestArg.data?.position; + } else if (requestArg.service === 'select_option') { + value = requestArg.data?.option; + } else if (requestArg.service === 'set_percentage') { + value = requestArg.data?.percentage; + } else if (requestArg.service === 'open_cover') { + value = 99; + } else if (requestArg.service === 'close_cover') { + value = 0; + } else if (requestArg.service === 'press') { + value = true; + } + if (value === undefined) { + return undefined; + } + return { + command: 'node.set_value', + nodeId: target.node.nodeId, + valueId: this.valueId(target.value), + value, + }; + } + + public static valueId(valueArg: IZwaveJsValue): IZwaveJsValueId { + return { + commandClass: valueArg.commandClass || 0, + endpoint: valueArg.endpoint, + property: valueArg.property ?? valueArg.propertyName ?? 'value', + propertyKey: valueArg.propertyKey, + }; + } + + public static nodeValues(nodeArg: IZwaveJsNode): IZwaveJsValue[] { + if (Array.isArray(nodeArg.values)) { + return nodeArg.values.map((valueArg) => ({ ...valueArg, nodeId: nodeArg.nodeId })); + } + if (nodeArg.values && typeof nodeArg.values === 'object') { + return Object.entries(nodeArg.values).map(([id, value]) => ({ ...value, id, nodeId: nodeArg.nodeId })); + } + return []; + } + + private static findTargetValue(snapshotArg: IZwaveJsSnapshot, requestArg: IServiceCallRequest): { node: IZwaveJsNode; value: IZwaveJsValue } | undefined { + const entities = this.toEntities(snapshotArg); + if (requestArg.target.entityId) { + const entity = entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId); + if (!entity) { + return undefined; + } + return this.nodeValueByUniqueId(snapshotArg, entity.uniqueId); + } + if (requestArg.target.deviceId) { + const node = snapshotArg.nodes.find((nodeArg) => this.nodeDeviceId(nodeArg) === requestArg.target.deviceId); + const value = node ? this.nodeValues(node).find((valueArg) => this.valueWritable(valueArg)) : undefined; + return node && value ? { node, value } : undefined; + } + for (const node of snapshotArg.nodes) { + const value = this.nodeValues(node).find((valueArg) => this.valueWritable(valueArg)); + if (value) { + return { node, value }; + } + } + return undefined; + } + + private static nodeValueByUniqueId(snapshotArg: IZwaveJsSnapshot, uniqueIdArg: string): { node: IZwaveJsNode; value: IZwaveJsValue } | undefined { + for (const node of snapshotArg.nodes) { + for (const value of this.nodeValues(node)) { + if (`zwave_js_${this.slug(`${node.nodeId}_${this.valueIdKey(value)}`)}` === uniqueIdArg) { + return { node, value }; + } + } + } + return undefined; + } + + private static featureForValue(valueArg: IZwaveJsValue): plugins.shxInterfaces.data.IDeviceFeature { + const platform = this.platformForValue(valueArg); + return { + id: this.slug(this.valueIdKey(valueArg)), + capability: this.capabilityForPlatform(platform), + name: this.valueLabel(valueArg), + readable: valueArg.metadata?.readable !== false, + writable: this.valueWritable(valueArg), + unit: valueArg.metadata?.unit, + }; + } + + private static platformForValue(valueArg: IZwaveJsValue): TEntityPlatform { + const label = this.valueLabel(valueArg).toLowerCase(); + const cc = (valueArg.commandClassName || '').toLowerCase(); + const type = valueArg.metadata?.type; + const writable = this.valueWritable(valueArg); + if (cc.includes('switch binary') || (type === 'boolean' && writable)) { + return 'switch'; + } + if (cc.includes('switch multilevel') && writable) { + return 'light'; + } + if (cc.includes('window covering')) { + return 'cover'; + } + if (cc.includes('thermostat')) { + return 'climate'; + } + if (valueArg.metadata?.states && writable) { + return 'select'; + } + if (type === 'number' && writable) { + return 'number'; + } + if (type === 'boolean') { + return 'binary_sensor'; + } + if (label.includes('firmware')) { + return 'update'; + } + return 'sensor'; + } + + private static capabilityForPlatform(platformArg: TEntityPlatform): plugins.shxInterfaces.data.TDeviceCapability { + if (platformArg === 'light') { + return 'light'; + } + if (platformArg === 'switch' || platformArg === 'button' || platformArg === 'number' || platformArg === 'select' || platformArg === 'text') { + return 'switch'; + } + if (platformArg === 'cover') { + return 'cover'; + } + if (platformArg === 'climate') { + return 'climate'; + } + if (platformArg === 'fan') { + return 'fan'; + } + return 'sensor'; + } + + private static entityState(valueArg: IZwaveJsValue, platformArg: TEntityPlatform): unknown { + if (platformArg === 'switch' || platformArg === 'light') { + return valueArg.value ? 'on' : 'off'; + } + return valueArg.value ?? 'unknown'; + } + + private static valueWritable(valueArg: IZwaveJsValue): boolean { + return valueArg.metadata?.writeable === true || valueArg.metadata?.writeable === undefined && valueArg.commandClassName?.toLowerCase().includes('switch') === true; + } + + private static valueLabel(valueArg: IZwaveJsValue): string { + return valueArg.metadata?.label || valueArg.propertyName || String(valueArg.property ?? valueArg.id ?? 'Z-Wave value'); + } + + private static valueIdKey(valueArg: IZwaveJsValue): string { + return [valueArg.commandClass, valueArg.endpoint || 0, valueArg.property ?? valueArg.propertyName ?? 'value', valueArg.propertyKey ?? ''].join('_'); + } + + private static onValue(currentArg: unknown): unknown { + return typeof currentArg === 'number' ? 99 : true; + } + + private static offValue(currentArg: unknown): unknown { + return typeof currentArg === 'number' ? 0 : false; + } + + private static stateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (Array.isArray(valueArg)) { + return JSON.stringify(valueArg); + } + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) { + return valueArg; + } + return valueArg === undefined ? null : String(valueArg); + } + + private static nodeDeviceId(nodeArg: IZwaveJsNode): string { + return `zwave_js.node.${nodeArg.nodeId}`; + } + + private static controllerDeviceId(snapshotArg: IZwaveJsSnapshot): string { + return `zwave_js.controller.${snapshotArg.controller?.homeId ?? snapshotArg.version?.homeId ?? 'network'}`; + } + + private static nodeName(nodeArg: IZwaveJsNode): string { + return nodeArg.name || nodeArg.productLabel || nodeArg.productDescription || `Z-Wave node ${nodeArg.nodeId}`; + } + + private static isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'zwave_js'; + } +} diff --git a/ts/integrations/zwave_js/zwave_js.types.ts b/ts/integrations/zwave_js/zwave_js.types.ts index fadc2e8..552cb8c 100644 --- a/ts/integrations/zwave_js/zwave_js.types.ts +++ b/ts/integrations/zwave_js/zwave_js.types.ts @@ -1,4 +1,138 @@ -export interface IHomeAssistantZwaveJsConfig { - // TODO: replace with the TypeScript-native config for zwave_js. +export interface IZwaveJsConfig { + url?: string; + schemaVersion?: number; + version?: IZwaveJsVersion; + state?: IZwaveJsState; + nodes?: IZwaveJsNode[]; + controller?: IZwaveJsController; +} + +export interface IZwaveJsVersion { + type?: 'version'; + driverVersion?: string; + serverVersion?: string; + homeId?: number; + minSchemaVersion?: number; + maxSchemaVersion?: number; +} + +export interface IZwaveJsState { + driver?: Record; + controller?: IZwaveJsController; + nodes?: IZwaveJsNode[]; +} + +export interface IZwaveJsController { + homeId?: number; + ownNodeId?: number; + status?: string; + isUsingHomeIdFromOtherNetwork?: boolean; + statistics?: Record; [key: string]: unknown; } + +export interface IZwaveJsNode { + nodeId: number; + id?: number; + name?: string; + location?: string; + status?: string; + ready?: boolean; + isControllerNode?: boolean; + manufacturer?: string; + manufacturerId?: number; + productLabel?: string; + productDescription?: string; + productId?: number; + productType?: number; + firmwareVersion?: string; + deviceConfig?: Record; + values?: Record | IZwaveJsValue[]; + [key: string]: unknown; +} + +export interface IZwaveJsValueId { + commandClass: number; + endpoint?: number; + property: string | number; + propertyKey?: string | number; +} + +export interface IZwaveJsValueMetadata { + label?: string; + type?: string; + readable?: boolean; + writeable?: boolean; + unit?: string; + states?: Record; + min?: number; + max?: number; + [key: string]: unknown; +} + +export interface IZwaveJsValue { + id?: string; + nodeId?: number; + commandClass?: number; + commandClassName?: string; + endpoint?: number; + property?: string | number; + propertyName?: string; + propertyKey?: string | number; + propertyKeyName?: string; + metadata?: IZwaveJsValueMetadata; + value?: unknown; + [key: string]: unknown; +} + +export interface IZwaveJsSnapshot { + version?: IZwaveJsVersion; + controller?: IZwaveJsController; + nodes: IZwaveJsNode[]; + events: IZwaveJsServerEvent[]; + connected: boolean; + url?: string; +} + +export interface IZwaveJsServerCommand { + command: string; + [key: string]: unknown; +} + +export interface IZwaveJsServerEvent { + source?: string; + event?: string; + nodeId?: number; + valueId?: IZwaveJsValueId; + value?: unknown; + newValue?: unknown; + [key: string]: unknown; +} + +export interface IZwaveJsMdnsRecord { + type?: string; + name?: string; + host?: string; + port?: number; + txt?: Record; +} + +export interface IZwaveJsUsbRecord { + vid?: string; + pid?: string; + manufacturer?: string; + description?: string; + path?: string; + serialNumber?: string; +} + +export interface IZwaveJsManualEntry { + url?: string; + host?: string; + port?: number; + usbPath?: string; + id?: string; + name?: string; + model?: string; + metadata?: Record; +}