From bc74169c7eb5679725ee0f9cc04e4f6bb6e46a91 Mon Sep 17 00:00:00 2001 From: GermanBluefox Date: Tue, 31 Mar 2026 22:11:09 +0200 Subject: [PATCH] Merge https://github.com/ioBroker/ioBroker.hass/pull/129 --- README.md | 3 +- src/main.ts | 455 ++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 372 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index b6d0dd4..a0ea4eb 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,9 @@ Please check it https://www.smarthomejetzt.de/mit-iobroker-auf-eine-home-assista --> ## Changelog -### 2.0.1 (2026-03-31) +### **WORK IN PROGRESS** * (@GermanBluefox) Adapter was updated and migrated to TypeScript +* (@Titanium177) Added roles for states and added debouncing for reading states from hass ### 1.4.0 (2023-01-03) * (Apollon77) Added more guidance logging when setting services incorrectly diff --git a/src/main.ts b/src/main.ts index 8e21a47..80949f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,26 @@ interface HassAdapterConfig { secure: boolean; } +interface HassEntity { + name: string; + attributes: Record; + entity_id: string; + object_id: string; + last_changed?: string; + last_updated?: string; + state: ioBroker.StateValue; + domain: string; +} + +interface HassServices { + [domain: string]: { + [serviceName: string]: { + description: string; + fields: Record; + }; + }; +} + const knownAttributes: Record = { azimuth: { write: false, read: true, unit: '°' }, elevation: { write: false, read: true, unit: '°' }, @@ -22,6 +42,143 @@ const mapTypes: Record = { const skipServices: string[] = ['persistent_notification']; +function getRoleForState(entity: HassEntity): string { + const domain = entity.domain || entity.entity_id.split('.')[0]; + const state = entity.state; + + switch (domain) { + case 'light': + return 'switch'; + case 'switch': + return 'switch'; + case 'binary_sensor': + return 'sensor.binary'; + case 'sensor': + if (typeof state === 'number' || !isNaN(parseFloat(String(state)))) { + if (entity.attributes?.unit_of_measurement) { + const unit = entity.attributes.unit_of_measurement; + if (unit === '°C' || unit === '°F' || unit === 'K') { + return 'value.temperature'; + } + if (unit === '%') { + return 'value.humidity'; + } + if (unit === 'hPa' || unit === 'mbar') { + return 'value.pressure'; + } + if (unit === 'W' || unit === 'kW') { + return 'value.power'; + } + if (unit === 'V') { + return 'value.voltage'; + } + if (unit === 'A') { + return 'value.current'; + } + if (unit.indexOf('m/s') !== -1 || unit.indexOf('km/h') !== -1) { + return 'value.speed'; + } + } + return 'value'; + } + return 'text'; + case 'climate': + return 'thermostat'; + case 'cover': + return 'blind'; + case 'lock': + return 'state'; + case 'input_boolean': + return 'switch'; + case 'input_number': + return 'level'; + case 'input_text': + return 'text'; + case 'input_select': + return 'text'; + case 'media_player': + return 'media.state'; + case 'device_tracker': + return 'state'; + case 'scene': + return 'button'; + case 'script': + return 'button'; + case 'automation': + return 'switch'; + case 'vacuum': + return 'state'; + case 'weather': + return 'weather'; + default: + if (state === 'on' || state === 'off') { + return 'switch'; + } + if (typeof state === 'number' || !isNaN(parseFloat(String(state)))) { + return 'value'; + } + if (typeof state === 'boolean') { + return 'indicator'; + } + return 'state'; + } +} + +function getRoleForAttribute(attr: string, value: ioBroker.StateValue, type: ioBroker.CommonType): string { + const attrLower = attr.toLowerCase(); + if (attrLower.includes('temperature')) { + return 'value.temperature'; + } + if (attrLower.includes('humidity')) { + return 'value.humidity'; + } + if (attrLower.includes('pressure')) { + return 'value.pressure'; + } + if (attrLower === 'brightness' || attrLower === 'current_position') { + return 'level.dimmer'; + } + if (attrLower === 'rgb_color' || attrLower === 'xy_color') { + return 'level.color.rgb'; + } + if (attrLower === 'color_temp') { + return 'level.color.temperature'; + } + if (attrLower === 'battery_level' || attrLower === 'battery') { + return 'value.battery'; + } + if (attrLower === 'locked') { + return 'indicator'; + } + if (attrLower === 'volume_level') { + return 'level.volume'; + } + if (attrLower === 'position') { + return 'level'; + } + if (attrLower === 'speed' || attrLower === 'percentage') { + return 'level'; + } + if (attrLower === 'mode' || attrLower === 'preset_mode') { + return 'text'; + } + + switch (type) { + case 'number': + return 'value'; + case 'boolean': + return 'indicator'; + case 'string': + return 'text'; + case 'object': + case 'mixed': + case 'array': + return 'json'; + default: + return 'state'; + } +} + class HassAdapter extends Adapter { declare config: HassAdapterConfig; @@ -29,6 +186,7 @@ class HassAdapter extends Adapter { private hass: HASS | null = null; private readonly hassObjects: Record = {}; private delayTimeout: ReturnType | null = null; + private syncDebounceTimeout: ReturnType | null = null; private stopped: boolean = false; public constructor(options: Partial = {}) { @@ -41,6 +199,29 @@ class HassAdapter extends Adapter { }); } + private debouncedSync(callback?: () => void): void { + if (this.syncDebounceTimeout) { + clearTimeout(this.syncDebounceTimeout); + } + this.syncDebounceTimeout = setTimeout(() => { + this.syncDebounceTimeout = null; + this.hass!.getStates((err, states) => { + if (err) { + this.log.error(`Cannot read states during resync: ${err}`); + return; + } + this.hass!.getServices(async (err, services) => { + if (err) { + this.log.error(`Cannot read services during resync: ${err}`); + return; + } + await this.parseStates(states, services); + callback?.(); + }); + }); + }, 3000); + } + private onStateChange(id: string, state: ioBroker.State | null | undefined): void { if (!state || state.ack) { return; @@ -59,8 +240,35 @@ class HassAdapter extends Adapter { return; } + // Handle boolean state toggle + if (id.endsWith('.state_boolean')) { + const entityId = this.hassObjects[id].native.entity_id; + const domain = entityId + ? entityId.split('.')[0] + : this.hassObjects[id].native.domain || this.hassObjects[id].native.type; + const service = state.val ? 'turn_on' : 'turn_off'; + + this.log.debug(`Processing boolean state change for ${id}`); + this.log.debug( + `Domain: ${domain}, Entity: ${this.hassObjects[id].native.entity_id}, Service: ${service}, Value: ${state.val}`, + ); + + if (domain) { + const serviceData = { entity_id: this.hassObjects[id].native.entity_id }; + this.hass!.callService(service, domain, serviceData, {}, err => { + if (err) { + this.log.error(`Cannot control ${id}: ${err}`); + } else { + this.log.debug(`Successfully sent command to HASS for ${id}`); + } + }); + return; + } + this.log.warn(`No domain found for ${id}`); + } + const serviceData: Record = {}; - const fields: { entity_id: string } = this.hassObjects[id].native.fields; + const fields: Record = this.hassObjects[id].native.fields; const target: Record = {}; let requestFields: Record = {}; @@ -70,7 +278,7 @@ class HassAdapter extends Adapter { try { requestFields = JSON.parse(state.val) || {}; } catch (err) { - this.log.info(`Ignore data for service call ${id} is no valid JSON: ${err.message}`); + this.log.info(`Ignore data for service call ${id} is no valid JSON: ${(err as Error).message}`); requestFields = {}; } } @@ -135,6 +343,10 @@ class HassAdapter extends Adapter { clearTimeout(this.delayTimeout); this.delayTimeout = null; } + if (this.syncDebounceTimeout) { + clearTimeout(this.syncDebounceTimeout); + this.syncDebounceTimeout = null; + } this.hass?.close(); callback?.(); } @@ -150,13 +362,17 @@ class HassAdapter extends Adapter { try { await this.setForeignStateAsync(id, state); } catch (err) { - this.log.error(err.toString()); + this.log.error((err as Error).toString()); } } } } - private async syncObjects(objects: (ioBroker.ChannelObject | ioBroker.StateObject)[]): Promise { + private async syncObjects(objects: (ioBroker.ChannelObject | ioBroker.StateObject)[]): Promise<{ + newCount: number; + updatedCount: number; + }> { + const stats = { newCount: 0, updatedCount: 0 }; if (objects?.length) { for (const obj of objects) { this.hassObjects[obj._id] = obj; @@ -167,36 +383,52 @@ class HassAdapter extends Adapter { this.log.debug(`Create "${obj._id}": ${JSON.stringify(obj.common)}`); this.hassObjects[obj._id] = obj; await this.setForeignObjectAsync(obj._id, obj); + stats.newCount++; } 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); + stats.updatedCount++; } } } catch (err) { - this.log.error(err.toString()); + this.log.error((err as Error).toString()); } } } + return stats; } - 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, - ): Promise { + private async deleteStaleObjects(expectedObjects: Set): Promise { + const objectsToDelete: string[] = []; + for (const id in this.hassObjects) { + if ( + Object.prototype.hasOwnProperty.call(this.hassObjects, id) && + id.startsWith(`${this.namespace}.entities.`) && + !expectedObjects.has(id) + ) { + objectsToDelete.push(id); + } + } + + for (const id of objectsToDelete) { + try { + await this.delObjectAsync(id); + delete this.hassObjects[id]; + } catch (err) { + this.log.error(`Error deleting object ${id}: ${err}`); + } + } + + return objectsToDelete.length; + } + + private async parseStates(entities: HassEntity[], services: HassServices): Promise { const objs: (ioBroker.ChannelObject | ioBroker.StateObject)[] = []; const states: { id: string; lc?: number; ts?: number; val: ioBroker.StateValue; ack: boolean }[] = []; + const expectedObjects = new Set(); for (let e = 0; e < entities.length; e++) { const entity = entities[e]; @@ -207,8 +439,11 @@ class HassAdapter extends Adapter { const name = entity.name || entity.attributes?.friendly_name || entity.entity_id; const desc = entity.attributes?.attribution || undefined; + const channelId = `${this.namespace}.entities.${entity.entity_id}`; + expectedObjects.add(channelId); + const channel: ioBroker.ChannelObject = { - _id: `${this.namespace}.entities.${entity.entity_id}`, + _id: channelId, common: { name, }, @@ -227,13 +462,16 @@ class HassAdapter extends Adapter { const ts = entity.last_updated ? new Date(entity.last_updated).getTime() : undefined; if (entity.state !== undefined) { + const stateId = `${channelId}.state`; + expectedObjects.add(stateId); + const obj: ioBroker.StateObject = { - _id: `${this.namespace}.entities.${entity.entity_id}.state`, + _id: stateId, type: 'state', common: { name: `${name} STATE`, type: typeof entity.state as ioBroker.CommonType, - role: 'state', + role: getRoleForState(entity), read: true, write: false, }, @@ -246,69 +484,96 @@ class HassAdapter extends Adapter { 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 }); + + // Create boolean state for on/off entities + const boolStateId = `${channelId}.state_boolean`; + expectedObjects.add(boolStateId); + + if (!objs.find(o => o._id === boolStateId)) { + const booleanObj: ioBroker.StateObject = { + _id: boolStateId, + type: 'state', + common: { + name: `${name} STATE_BOOLEAN`, + type: 'boolean', + read: true, + write: true, + role: 'switch', + }, + native: { + object_id: entity.object_id, + domain: entity.domain, + entity_id: entity.entity_id, + attr: 'state', + type: entity.domain, + }, + }; + objs.push(booleanObj); + states.push({ + id: boolStateId, + lc: lc || Date.now(), + ts: ts || Date.now(), + val: entity.state === 'on', + 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 }); + if ( + !Object.prototype.hasOwnProperty.call(entity.attributes, attr) || + attr === 'friendly_name' || + attr === 'unit_of_measurement' || + attr === 'icon' || + !attr.length + ) { + 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 fullAttrId = `${channelId}.${attrId}`; + expectedObjects.add(fullAttrId); + + const obj: ioBroker.StateObject = { + _id: fullAttrId, + 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]]; + common.role ??= getRoleForAttribute(attr, entity.attributes[attr], common.type); + + objs.push(obj); + + let val = entity.attributes[attr]; + if ((typeof val === 'object' && val !== null) || Array.isArray(val)) { + val = JSON.stringify(val); + } + + states.push({ id: obj._id, lc, ts, val, ack: true }); } } @@ -318,8 +583,11 @@ class HassAdapter extends Adapter { const service = services[serviceType]; for (const s in service) { if (Object.prototype.hasOwnProperty.call(service, s)) { + const serviceId = `${channelId}.${s}`; + expectedObjects.add(serviceId); + const obj: ioBroker.StateObject = { - _id: `${this.namespace}.entities.${entity.entity_id}.${s}`, + _id: serviceId, type: 'state', common: { name: entity.entity_id, @@ -327,7 +595,7 @@ class HassAdapter extends Adapter { read: false, write: true, type: 'mixed' as ioBroker.CommonType, - role: 'state', + role: 'button', }, native: { object_id: entity.object_id, @@ -339,18 +607,26 @@ class HassAdapter extends Adapter { }, }; - this.log.debug( - `Found Entity service ${obj._id}: ${JSON.stringify(obj.common)} / ${JSON.stringify(obj.native)}`, - ); - objs.push(obj); } } } } - await this.syncObjects(objs); + const deletedCount = await this.deleteStaleObjects(expectedObjects); + const syncStats = await this.syncObjects(objs); await this.syncStates(states); + + if (syncStats.newCount > 0 || deletedCount > 0) { + const changes: string[] = []; + if (syncStats.newCount > 0) { + changes.push(`${syncStats.newCount} created`); + } + if (deletedCount > 0) { + changes.push(`${deletedCount} deleted`); + } + this.log.info(`Synchronization completed: ${changes.join(', ')}`); + } } private async main(): Promise { @@ -378,8 +654,18 @@ class HassAdapter extends Adapter { 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.`, + `State changed for unknown object ${id}state. Triggering synchronization to resync the objects.`, ); + this.debouncedSync(); + } + // Update boolean state + if (this.hassObjects[`${this.namespace}.${id}state_boolean`]) { + this.setState(`${id}state_boolean`, { + val: entity.state === 'on', + ack: true, + lc: lc || Date.now(), + ts: ts || Date.now(), + }); } } @@ -403,8 +689,9 @@ class HassAdapter extends Adapter { 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.`, + `State changed for unknown object ${id + attrId}. Triggering synchronization to resync the objects.`, ); + this.debouncedSync(); } } } @@ -439,12 +726,10 @@ class HassAdapter extends Adapter { return; } if (err) { - this.log.error(`Cannot read states: ${err}`); + this.log.error(`Cannot read services: ${err}`); } else { await this.parseStates(states, services); - this.log.debug( - 'Initial parsing of states done, subscribe to ioBroker states', - ); + this.log.info('Initialization completed'); await this.subscribeStatesAsync('*'); } });