MIgrate adapter to Typescript

This commit is contained in:
GermanBluefox
2026-03-31 19:44:44 +02:00
parent 92140fe771
commit ebca1ad36c
39 changed files with 1176 additions and 1325 deletions
+248
View File
@@ -0,0 +1,248 @@
import { EventEmitter } from 'node:events';
import WebSocket from 'ws';
interface HassOptions {
host: string;
port: number;
password?: string;
secure?: boolean;
}
interface HassRequest {
type: string;
ts: number;
cb?: (err: boolean | string | null, result?: any) => void;
}
/*
const ERRORS: Record<number, string> = {
1: 'ERR_CANNOT_CONNECT',
2: 'ERR_INVALID_AUTH',
3: 'ERR_CONNECTION_LOST',
};
*/
export default class HASS extends EventEmitter {
private socket: WebSocket | null = null;
private readonly options: HassOptions;
private readonly log: ioBroker.Logger;
private currentId: number = 1;
private readonly requests: Record<number, HassRequest> = {};
private _connected: boolean = false;
private connectTimeout: ReturnType<typeof setTimeout> | null = null;
private closed: boolean = false;
constructor(options: HassOptions, log: ioBroker.Logger) {
super();
this.options = {
host: options.host || '127.0.0.1',
port: parseInt(String(options.port), 10) || 8123,
password: options.password,
secure: options.secure,
};
this.log = log;
}
private subscribeEvents(socket: WebSocket, callback?: (err: boolean | string | null) => void): void {
if (socket && typeof socket.send === 'function') {
const id = this.currentId++;
this.requests[id] = { type: 'subscribe_events', ts: Date.now(), cb: callback };
socket.send(
JSON.stringify({
id,
type: 'subscribe_events',
}),
);
} else {
callback?.('not connected');
}
}
private sendCommand(
socket: WebSocket | null,
type: string,
callback?: (err: boolean | string | null, result?: any) => void,
extra?: Record<string, any>,
): void {
if (socket && typeof socket.send === 'function') {
const id = this.currentId++;
this.requests[id] = { type, cb: callback, ts: Date.now() };
socket.send(
JSON.stringify({
id,
type,
...extra,
}),
);
} else {
callback?.('not connected');
}
}
private sendAuth(socket: WebSocket, pass: string): void {
if (socket && typeof socket.send === 'function') {
socket.send(
JSON.stringify({
type: 'auth',
access_token: pass,
}),
);
}
}
private initSocket(socket: WebSocket): void {
socket.on('message', (msg: WebSocket.Data): void => {
const msgStr = (msg as Buffer).toString();
this.log.silly(msgStr);
const response = JSON.parse(msgStr);
if (response.type === 'event') {
if (response.event?.data && response.event.event_type === 'system_log_event') {
if (response.event.data.level === 'WARNING') {
this.log.warn(`EVENT: ${response.event.data.message}`);
} else if (response.event.data.level === 'ERROR') {
this.log.error(`EVENT: ${response.event.data.message}`);
} else {
this.log.debug(`EVENT: ${response.event.data.message}`);
}
} else if (response.event?.event_type === 'state_changed') {
this.emit('state_changed', response.event.data.new_state);
}
} else if (response.type === 'auth_required') {
if (!this.options.password) {
this.emit('error', 'Password required. Connection closed');
socket.terminate();
} else {
setTimeout(() => this.sendAuth(socket, this.options.password!), 50);
}
} else if (response.type === 'auth_ok') {
setImmediate(() =>
this.subscribeEvents(socket, err => {
if (!err) {
this._connected = true;
this.emit('connected');
}
}),
);
} else if (response.id === undefined) {
this.log.error(`Invalid answer: ${msgStr}`);
} else {
if (response.type === 'result' && this.requests[response.id]) {
this.log.debug(
`got answer for ${this.requests[response.id].type} success = ${response.success}, result = ${JSON.stringify(response.result)}`,
);
if (typeof this.requests[response.id].cb === 'function') {
this.requests[response.id].cb!(!response.success, response.result);
delete this.requests[response.id];
}
}
}
});
socket.on('error', (err: Error) => {
this.socket = null;
if (err?.message?.indexOf('RSV2 and RSV3 must be clear') !== -1) {
// ignore deflate error
} else {
this.log.error(err.toString());
}
});
socket.on('open', () => {
// connection opened
});
socket.on('close', () => {
this.socket = null;
if (this._connected) {
this._connected = false;
this.emit('disconnected');
}
if (!this.connectTimeout && !this.closed) {
this.connectTimeout = setTimeout(() => {
this.connectTimeout = null;
this.connect();
}, 3000);
}
});
}
isConnected(): boolean {
return this._connected;
}
getConfig(callback: (err: boolean | string | null, result?: any) => void): void {
if (!this._connected) {
callback('not connected');
} else {
this.sendCommand(this.socket, 'get_config', callback);
}
}
getStates(callback: (err: boolean | string | null, result?: any) => void): void {
if (!this._connected) {
callback('not connected');
} else {
this.sendCommand(this.socket, 'get_states', callback);
}
}
getServices(callback: (err: boolean | string | null, result?: any) => void): void {
if (!this._connected) {
callback('not connected');
} else {
this.sendCommand(this.socket, 'get_services', callback);
}
}
getPanels(callback: (err: boolean | string | null, result?: any) => void): void {
if (!this._connected) {
callback('not connected');
} else {
this.sendCommand(this.socket, 'get_panels', callback);
}
}
callService(
service: string,
domain: string,
serviceData: Record<string, any>,
target: Record<string, any>,
callback: (err: boolean | string | null, result?: any) => void,
): void {
if (!this._connected) {
callback('not connected');
} else {
this.sendCommand(this.socket, 'call_service', callback, {
domain: domain || '',
service,
service_data: serviceData,
target,
});
}
}
connect(): void {
if (this.connectTimeout) {
clearTimeout(this.connectTimeout);
this.connectTimeout = null;
}
this.socket = new WebSocket(
`ws${this.options.secure ? 's' : ''}://${this.options.host}:${this.options.port}/api/websocket`,
{ perMessageDeflate: false },
);
this.initSocket(this.socket);
}
close(): void {
if (this.connectTimeout) {
clearTimeout(this.connectTimeout);
this.connectTimeout = null;
}
this.closed = true;
if (this.socket) {
this.socket.close();
}
}
}
+480
View File
@@ -0,0 +1,480 @@
import { Adapter, type AdapterOptions } from '@iobroker/adapter-core';
import HASS from './lib/hass';
interface HassAdapterConfig {
host: string;
port: number;
password: string;
secure: boolean;
}
const knownAttributes: Record<string, { write: boolean; read: boolean; unit: string }> = {
azimuth: { write: false, read: true, unit: '°' },
elevation: { write: false, read: true, unit: '°' },
};
const mapTypes: Record<string, ioBroker.CommonType> = {
string: 'string',
number: 'number',
object: 'mixed',
boolean: 'boolean',
};
const skipServices: string[] = ['persistent_notification'];
class HassAdapter extends Adapter {
declare config: HassAdapterConfig;
private hassConnected: boolean = false;
private hass: HASS | null = null;
private readonly hassObjects: Record<string, ioBroker.ChannelObject | ioBroker.StateObject> = {};
private delayTimeout: ReturnType<typeof setTimeout> | null = null;
private stopped: boolean = false;
public constructor(options: Partial<AdapterOptions> = {}) {
super({
...options,
name: 'hass',
ready: () => this.main(),
unload: callback => this.onUnload(callback),
stateChange: (id, state) => this.onStateChange(id, state),
});
}
private onStateChange(id: string, state: ioBroker.State | null | undefined): void {
if (!state || state.ack) {
return;
}
if (!this.hassConnected) {
this.log.warn(`Cannot send command to "${id}", because not connected`);
return;
}
if (!this.hassObjects[id]) {
return;
}
if (!(this.hassObjects[id] as ioBroker.StateObject).common.write) {
this.log.warn(`Object ${id} is not writable!`);
return;
}
const serviceData: Record<string, any> = {};
const fields: { entity_id: string } = this.hassObjects[id].native.fields;
const target: Record<string, any> = {};
let requestFields: Record<string, any> = {};
if (typeof state.val === 'string') {
state.val = state.val.trim();
if (state.val.startsWith('{') && state.val.endsWith('}')) {
try {
requestFields = JSON.parse(state.val) || {};
} catch (err) {
this.log.info(`Ignore data for service call ${id} is no valid JSON: ${err.message}`);
requestFields = {};
}
}
}
// If a non-JSON value was set, and we only have one relevant field, use this field as value
if (fields && !Object.keys(requestFields).length) {
const fieldList = Object.keys(fields);
if (fieldList.length === 1 && fieldList[0] !== 'entity_id') {
requestFields[fieldList[0]] = state.val;
} else if (fieldList.length === 2 && fields.entity_id) {
requestFields[fieldList[1 - fieldList.indexOf('entity_id')]] = state.val;
}
}
this.log.debug(
`Prepare service call for ${id} with (mapped) request parameters ${JSON.stringify(requestFields)} from value: ${JSON.stringify(state.val)}`,
);
if (fields) {
for (const field in fields) {
if (!Object.prototype.hasOwnProperty.call(fields, field)) {
continue;
}
if (field === 'entity_id') {
target.entity_id = this.hassObjects[id].native.entity_id;
} else if (requestFields[field] !== undefined) {
serviceData[field] = requestFields[field];
}
}
}
const noFields = Object.keys(serviceData).length === 0;
serviceData.entity_id = this.hassObjects[id].native.entity_id;
this.log.debug(
`Send to HASS for service ${this.hassObjects[id].native.attr} with ${this.hassObjects[id].native.domain || this.hassObjects[id].native.type} and data ${JSON.stringify(serviceData)}`,
);
this.hass!.callService(
this.hassObjects[id].native.attr,
this.hassObjects[id].native.domain || this.hassObjects[id].native.type,
serviceData,
target,
err => {
if (err) {
this.log.error(`Cannot control ${id}: ${err}`);
}
if (err && fields && noFields) {
this.log.warn(
`Please make sure to provide a stringified JSON as value to set relevant fields! Please refer to the Readme for details!`,
);
this.log.warn(`Allowed field keys are: ${Object.keys(fields).join(', ')}`);
}
},
);
}
private onUnload(callback?: () => void): void {
this.stopped = true;
if (this.delayTimeout) {
clearTimeout(this.delayTimeout);
this.delayTimeout = null;
}
this.hass?.close();
callback?.();
}
private async syncStates(
states: { id?: string; lc?: number; ts?: number; val: ioBroker.StateValue; ack: boolean }[],
): Promise<void> {
if (states?.length) {
for (const state of states) {
const id = state.id!;
delete state.id;
try {
await this.setForeignStateAsync(id, state);
} catch (err) {
this.log.error(err.toString());
}
}
}
}
private async syncObjects(objects: (ioBroker.ChannelObject | ioBroker.StateObject)[]): Promise<void> {
if (objects?.length) {
for (const obj of objects) {
this.hassObjects[obj._id] = obj;
try {
const oldObj = await this.getForeignObjectAsync(obj._id);
if (!oldObj) {
this.log.debug(`Create "${obj._id}": ${JSON.stringify(obj.common)}`);
this.hassObjects[obj._id] = obj;
await this.setForeignObjectAsync(obj._id, obj);
} else {
this.hassObjects[obj._id] = oldObj as ioBroker.StateObject | ioBroker.ChannelObject;
if (JSON.stringify(obj.native) !== JSON.stringify(oldObj.native)) {
oldObj.native = obj.native;
this.log.debug(`Update "${obj._id}": ${JSON.stringify(obj.common)}`);
await this.setForeignObjectAsync(obj._id, oldObj);
}
}
} catch (err) {
this.log.error(err.toString());
}
}
}
}
private async parseStates(
entities: {
name: string;
attributes: { friendly_name?: string; attribution?: string; unit_of_measurement?: string };
entity_id: string;
object_id: string;
last_changed?: string;
last_updated?: string;
state: ioBroker.StateValue;
domain: string;
}[],
services: Record<string, { [serviceType: string]: { description: string; fields: { entity_id: string } } }>,
): Promise<void> {
const objs: (ioBroker.ChannelObject | ioBroker.StateObject)[] = [];
const states: { id: string; lc?: number; ts?: number; val: ioBroker.StateValue; ack: boolean }[] = [];
for (let e = 0; e < entities.length; e++) {
const entity = entities[e];
if (!entity) {
continue;
}
const name = entity.name || entity.attributes?.friendly_name || entity.entity_id;
const desc = entity.attributes?.attribution || undefined;
const channel: ioBroker.ChannelObject = {
_id: `${this.namespace}.entities.${entity.entity_id}`,
common: {
name,
},
type: 'channel',
native: {
object_id: entity.object_id,
entity_id: entity.entity_id,
},
};
if (desc) {
channel.common.desc = desc;
}
objs.push(channel);
const lc = entity.last_changed ? new Date(entity.last_changed).getTime() : undefined;
const ts = entity.last_updated ? new Date(entity.last_updated).getTime() : undefined;
if (entity.state !== undefined) {
const obj: ioBroker.StateObject = {
_id: `${this.namespace}.entities.${entity.entity_id}.state`,
type: 'state',
common: {
name: `${name} STATE`,
type: typeof entity.state as ioBroker.CommonType,
role: 'state',
read: true,
write: false,
},
native: {
object_id: entity.object_id,
domain: entity.domain,
entity_id: entity.entity_id,
},
};
if (entity.attributes?.unit_of_measurement) {
obj.common.unit = entity.attributes.unit_of_measurement;
}
this.log.debug(
`Found Entity state ${obj._id}: ${JSON.stringify(obj.common)} / ${JSON.stringify(obj.native)}`,
);
objs.push(obj);
let val = entity.state;
if ((typeof val === 'object' && val !== null) || Array.isArray(val)) {
val = JSON.stringify(val);
}
states.push({ id: obj._id, lc, ts, val, ack: true });
}
if (entity.attributes) {
for (const attr in entity.attributes) {
if (Object.prototype.hasOwnProperty.call(entity.attributes, attr)) {
if (attr === 'friendly_name' || attr === 'unit_of_measurement' || attr === 'icon') {
continue;
}
let common: ioBroker.StateCommon;
if (knownAttributes[attr]) {
common = { ...knownAttributes[attr] } as ioBroker.StateCommon;
} else {
common = {} as ioBroker.StateCommon;
}
const attrId = attr.replace(this.FORBIDDEN_CHARS, '_').replace(/\.+$/, '_');
const obj: ioBroker.StateObject = {
_id: `${this.namespace}.entities.${entity.entity_id}.${attrId}`,
type: 'state',
common,
native: {
object_id: entity.object_id,
domain: entity.domain,
entity_id: entity.entity_id,
attr,
},
};
common.name ||= `${name} ${attr.replace(/_/g, ' ')}`;
common.read ??= true;
common.write ??= false;
common.type ??=
mapTypes[
typeof entity.attributes[
attr as 'friendly_name' | 'attribution' | 'unit_of_measurement'
]
];
this.log.debug(
`Found Entity attribute ${obj._id}: ${JSON.stringify(obj.common)} / ${JSON.stringify(obj.native)}`,
);
objs.push(obj);
let val: string | null =
entity.attributes[attr as 'friendly_name' | 'attribution' | 'unit_of_measurement'] ?? null;
if ((typeof val === 'object' && val !== null) || Array.isArray(val)) {
val = JSON.stringify(val);
}
states.push({ id: obj._id, lc, ts, val, ack: true });
}
}
}
const serviceType = entity.entity_id.split('.')[0];
if (services[serviceType] && !skipServices.includes(serviceType)) {
const service = services[serviceType];
for (const s in service) {
if (Object.prototype.hasOwnProperty.call(service, s)) {
const obj: ioBroker.StateObject = {
_id: `${this.namespace}.entities.${entity.entity_id}.${s}`,
type: 'state',
common: {
name: entity.entity_id,
desc: service[s].description,
read: false,
write: true,
type: 'mixed' as ioBroker.CommonType,
role: 'state',
},
native: {
object_id: entity.object_id,
domain: entity.domain,
fields: service[s].fields,
entity_id: entity.entity_id,
attr: s,
type: serviceType,
},
};
this.log.debug(
`Found Entity service ${obj._id}: ${JSON.stringify(obj.common)} / ${JSON.stringify(obj.native)}`,
);
objs.push(obj);
}
}
}
}
await this.syncObjects(objs);
await this.syncStates(states);
}
private async main(): Promise<void> {
this.config.host ||= '127.0.0.1';
this.config.port = parseInt(String(this.config.port), 10) || 8123;
await this.setStateAsync('info.connection', false, true);
this.hass = new HASS(this.config, this.log);
this.hass.on('error', err => this.log.error(err));
this.hass.on('state_changed', entity => {
this.log.debug(`HASS-Message: State Changed: ${JSON.stringify(entity)}`);
if (!entity || typeof entity.entity_id !== 'string') {
return;
}
const id = `entities.${entity.entity_id}.`;
const lc = entity.last_changed ? new Date(entity.last_changed).getTime() : undefined;
const ts = entity.last_updated ? new Date(entity.last_updated).getTime() : undefined;
if (entity.state !== undefined) {
if (this.hassObjects[`${this.namespace}.${id}state`]) {
this.setState(`${id}state`, { val: entity.state, ack: true, lc, ts });
} else {
this.log.info(
`State changed for unknown object ${id}state. Please restart the adapter to resync the objects.`,
);
}
}
if (entity.attributes) {
for (const attr in entity.attributes) {
if (
!Object.prototype.hasOwnProperty.call(entity.attributes, attr) ||
attr === 'friendly_name' ||
attr === 'unit_of_measurement' ||
attr === 'icon' ||
!attr.length
) {
continue;
}
let val = entity.attributes[attr];
if ((typeof val === 'object' && val !== null) || Array.isArray(val)) {
val = JSON.stringify(val);
}
const attrId = attr.replace(this.FORBIDDEN_CHARS, '_').replace(/\.+$/, '_');
if (this.hassObjects[`${this.namespace}.${id}state`]) {
this.setState(id + attrId, { val, ack: true, lc, ts });
} else {
this.log.info(
`State changed for unknown object ${id + attrId}. Please restart the adapter to resync the objects.`,
);
}
}
}
});
this.hass.on('connected', () => {
if (!this.hassConnected) {
this.log.debug('Connected');
this.hassConnected = true;
this.setState('info.connection', true, true);
this.hass!.getConfig(err => {
if (err) {
this.log.error(`Cannot read config: ${err}`);
return;
}
this.delayTimeout = setTimeout(() => {
this.delayTimeout = null;
if (!this.stopped) {
this.hass!.getStates((err, states) => {
if (this.stopped) {
return;
}
if (err) {
this.log.error(`Cannot read states: ${err}`);
return;
}
this.delayTimeout = setTimeout(() => {
this.delayTimeout = null;
if (!this.stopped) {
this.hass!.getServices(async (err, services): Promise<void> => {
if (this.stopped) {
return;
}
if (err) {
this.log.error(`Cannot read states: ${err}`);
} else {
await this.parseStates(states, services);
this.log.debug(
'Initial parsing of states done, subscribe to ioBroker states',
);
await this.subscribeStatesAsync('*');
}
});
}
}, 100);
});
}
}, 100);
});
}
});
this.hass.on('disconnected', () => {
if (this.hassConnected) {
this.log.debug('Disconnected');
this.hassConnected = false;
this.setState('info.connection', false, true);
}
});
this.hass.connect();
}
}
export default HassAdapter;
if (require.main !== module) {
// Export the constructor in compact mode
module.exports = (options: Partial<AdapterOptions> | undefined) => new HassAdapter(options);
} else {
// otherwise start the instance directly
(() => new HassAdapter())();
}