Add native Z-Wave JS integration

This commit is contained in:
2026-05-05 13:09:56 +00:00
parent 97cba0a9a1
commit 2823a1c718
12 changed files with 1003 additions and 36 deletions
@@ -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();
@@ -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();
+2
View File
@@ -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 => {
+3 -4
View File
@@ -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"
];
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -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';
@@ -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<typeof setTimeout>;
}
export class ZwaveJsClient {
private socket?: any;
private started = false;
private version?: IZwaveJsVersion;
private state?: IZwaveJsState;
private readonly events: IZwaveJsServerEvent[] = [];
private readonly pendingRequests = new Map<string, IPendingRequest>();
private readonly eventHandlers = new Set<TEventHandler>();
constructor(private readonly config: IZwaveJsConfig) {
this.version = config.version;
this.state = config.state || { controller: config.controller, nodes: config.nodes || [] };
}
public async getSnapshot(): Promise<IZwaveJsSnapshot> {
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<void> {
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<void>((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<unknown> {
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<unknown>((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<void> {
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<void> {
if (!this.started) {
await this.start();
}
}
private async waitForVersion(): Promise<void> {
if (this.version) {
return;
}
await new Promise<void>((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<string, unknown>;
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<string, unknown>): 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<typeof ZwaveJsMapper.nodeValues>[number], valueIdArg: NonNullable<IZwaveJsServerEvent['valueId']>): 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<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
}
@@ -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<IZwaveJsConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IZwaveJsConfig>> {
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;
}
}
@@ -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<IZwaveJsConfig> {
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<IIntegrationRuntime> {
void contextArg;
return new ZwaveJsRuntime(new ZwaveJsClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantZwaveJsIntegration extends ZwaveJsIntegration {}
class ZwaveJsRuntime implements IIntegrationRuntime {
public domain = 'zwave_js';
constructor(private readonly client: ZwaveJsClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return ZwaveJsMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return ZwaveJsMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
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<IServiceCallResult> {
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<void> {
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<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
}
@@ -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<IZwaveJsMdnsRecord> {
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<IDiscoveryMatch> {
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<IZwaveJsUsbRecord> {
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<IDiscoveryMatch> {
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<IZwaveJsManualEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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());
};
+321
View File
@@ -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<string, unknown> {
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';
}
}
+136 -2
View File
@@ -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<string, unknown>;
controller?: IZwaveJsController;
nodes?: IZwaveJsNode[];
}
export interface IZwaveJsController {
homeId?: number;
ownNodeId?: number;
status?: string;
isUsingHomeIdFromOtherNetwork?: boolean;
statistics?: Record<string, unknown>;
[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<string, unknown>;
values?: Record<string, IZwaveJsValue> | 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<string, string>;
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<string, string | undefined>;
}
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<string, unknown>;
}