Add native Z-Wave JS integration
This commit is contained in:
@@ -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();
|
||||||
@@ -9,6 +9,7 @@ import { RokuIntegration } from './integrations/roku/index.js';
|
|||||||
import { ShellyIntegration } from './integrations/shelly/index.js';
|
import { ShellyIntegration } from './integrations/shelly/index.js';
|
||||||
import { SonosIntegration } from './integrations/sonos/index.js';
|
import { SonosIntegration } from './integrations/sonos/index.js';
|
||||||
import { WolfSmartsetIntegration } from './integrations/wolf_smartset/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 { generatedHomeAssistantPortIntegrations } from './integrations/generated/index.js';
|
||||||
import { IntegrationRegistry } from './core/index.js';
|
import { IntegrationRegistry } from './core/index.js';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export const integrations = [
|
|||||||
new ShellyIntegration(),
|
new ShellyIntegration(),
|
||||||
new SonosIntegration(),
|
new SonosIntegration(),
|
||||||
new WolfSmartsetIntegration(),
|
new WolfSmartsetIntegration(),
|
||||||
|
new ZwaveJsIntegration(),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const createDefaultIntegrationRegistry = (): IntegrationRegistry => {
|
export const createDefaultIntegrationRegistry = (): IntegrationRegistry => {
|
||||||
|
|||||||
@@ -1451,7 +1451,6 @@ import { HomeAssistantZodiacIntegration } from '../zodiac/index.js';
|
|||||||
import { HomeAssistantZondergasIntegration } from '../zondergas/index.js';
|
import { HomeAssistantZondergasIntegration } from '../zondergas/index.js';
|
||||||
import { HomeAssistantZoneIntegration } from '../zone/index.js';
|
import { HomeAssistantZoneIntegration } from '../zone/index.js';
|
||||||
import { HomeAssistantZoneminderIntegration } from '../zoneminder/index.js';
|
import { HomeAssistantZoneminderIntegration } from '../zoneminder/index.js';
|
||||||
import { HomeAssistantZwaveJsIntegration } from '../zwave_js/index.js';
|
|
||||||
import { HomeAssistantZwaveMeIntegration } from '../zwave_me/index.js';
|
import { HomeAssistantZwaveMeIntegration } from '../zwave_me/index.js';
|
||||||
|
|
||||||
export const generatedHomeAssistantPortIntegrations: BaseIntegration[] = [];
|
export const generatedHomeAssistantPortIntegrations: BaseIntegration[] = [];
|
||||||
@@ -2905,15 +2904,15 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZodiacIntegration()
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZondergasIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZondergasIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveJsIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
||||||
|
|
||||||
export const generatedHomeAssistantPortCount = 1452;
|
export const generatedHomeAssistantPortCount = 1451;
|
||||||
export const handwrittenHomeAssistantPortDomains = [
|
export const handwrittenHomeAssistantPortDomains = [
|
||||||
"cast",
|
"cast",
|
||||||
"hue",
|
"hue",
|
||||||
"mqtt",
|
"mqtt",
|
||||||
"roku",
|
"roku",
|
||||||
"shelly",
|
"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.
|
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
export * from './zwave_js.classes.integration.js';
|
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';
|
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 {
|
export class ZwaveJsIntegration extends BaseIntegration<IZwaveJsConfig> {
|
||||||
constructor() {
|
public readonly domain = 'zwave_js';
|
||||||
super({
|
public readonly displayName = 'Z-Wave';
|
||||||
domain: "zwave_js",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "Z-Wave",
|
public readonly discoveryDescriptor = createZwaveJsDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new ZwaveJsConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/zwave_js",
|
upstreamPath: 'homeassistant/components/zwave_js',
|
||||||
"upstreamDomain": "zwave_js",
|
upstreamDomain: 'zwave_js',
|
||||||
"integrationType": "hub",
|
integrationType: 'hub',
|
||||||
"iotClass": "local_push",
|
iotClass: 'local_push',
|
||||||
"requirements": [
|
qualityScale: 'unknown',
|
||||||
"zwave-js-server-python==0.70.0"
|
};
|
||||||
],
|
|
||||||
"dependencies": [
|
public async setup(configArg: IZwaveJsConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
"http",
|
void contextArg;
|
||||||
"repairs",
|
return new ZwaveJsRuntime(new ZwaveJsClient(configArg));
|
||||||
"usb",
|
}
|
||||||
"websocket_api"
|
|
||||||
],
|
public async destroy(): Promise<void> {}
|
||||||
"afterDependencies": [
|
}
|
||||||
"hassio"
|
|
||||||
],
|
export class HomeAssistantZwaveJsIntegration extends ZwaveJsIntegration {}
|
||||||
"codeowners": [
|
|
||||||
"@home-assistant/z-wave"
|
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());
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,138 @@
|
|||||||
export interface IHomeAssistantZwaveJsConfig {
|
export interface IZwaveJsConfig {
|
||||||
// TODO: replace with the TypeScript-native config for zwave_js.
|
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;
|
[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>;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user