UNPKG

iobroker.esphome

Version:

Control your ESP8266/ESP32 with simple yet powerful configuration files created and managed by ESPHome

1,338 lines (1,146 loc) 66 kB
'use strict'; /* * Created with @iobroker/create-adapter v1.31.0 */ // The adapter-core module gives you access to the core ioBroker functions // you need to create an adapter const utils = require('@iobroker/adapter-core'); const clientDevice = require('./lib/helpers.js'); // @ts-ignore Client is just missing in index.d.ts file const {Client, Discovery} = require('@2colors/esphome-native-api'); const stateAttr = require(__dirname + '/lib/stateAttr.js'); // Load attribute library const disableSentry = false; // Ensure to set to true during development! const warnMessages = {}; // Store warn messages to avoid multiple sending to sentry const fs = require('fs'); const {clearTimeout} = require('timers'); const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args)); const resetTimers = {}; // Memory allocation for all running timers let autodiscovery, dashboardProcess, createConfigStates, discovery; const clientDetails = {}; // Memory cache of all devices and their connection status const newlyDiscoveredClient = {}; // Memory cache of all newly discovered devices and their connection status const dashboardVersions = []; class Esphome extends utils.Adapter { /** * @param {Partial<utils.AdapterOptions>} [options={}] */ constructor(options) { super({ ...options, name: 'esphome', }); this.on('ready', this.onReady.bind(this)); this.on('stateChange', this.onStateChange.bind(this)); // this.on('objectChange', this.onObjectChange.bind(this)); this.on('message', this.onMessage.bind(this)); this.on('unload', this.onUnload.bind(this)); this.deviceStateRelation = {}; // Memory array of an initiated device by Device Identifier (name) and IP this.createdStatesDetails = {}; // Array to store information of created states this.messageResponse = {}; // Array to store messages from admin and provide proper message to add/remove devices } /** * Is called when databases are connected and adapter received configuration. */ async onReady() { await this.setStateAsync('info.connection', {val: true, ack: true}); try { //ToDo: store default data into clientDetails object instead of global variable // Store settings in global variables // defaultApiPass = this.config.apiPass; autodiscovery = this.config.autodiscovery; // reconnectInterval = this.config.reconnectInterval * 1000; createConfigStates = this.config.configStates; // Ensure all online states are set to false during adapter start await this.resetOnlineStates(); // Try connecting to already known devices await this.tryKnownDevices(); // Get current available versions and start ESPHome Dashboard process (if enabled) await this.espHomeDashboard(); // Start MDNS discovery when enabled if (autodiscovery) { if (resetTimers['autodiscovery']) resetTimers['autodiscovery'] = clearTimeout(resetTimers['autodiscovery']); // this.log.info(`Adapter ready, automatic Device Discovery will be activated in 30 seconds.`); resetTimers['autodiscovery'] = setTimeout(async () => { this.deviceDiscovery(); // Start bonjour service autodiscovery }, (5000)); } else { this.log.warn(`Auto Discovery disabled, new devices (or IP changes) will NOT be detected automatically!`); } // Create & Subscribe to button handling offline Device cleanup this.extendObject('esphome.0.info.deviceCleanup', { 'type': 'state', 'common': { 'role': 'button', 'name': 'Device or service connected', 'type': 'boolean', 'read': false, 'write': true, 'def': false } }); this.subscribeStates('esphome.0.info.deviceCleanup'); } catch (e) { this.log.error(`[Adapter start] Fatal error occurred ${e}`); } } // ToDo: move to separate module async espHomeDashboard() { try { // Create Channel to store ESPHomeDashboard related Data await this.extendObjectAsync('_ESPHomeDashboard', { type: 'channel', common: { name: 'ESPHome Dashboard details', }, native: {}, }); // Get all current available ESPHome Dashboard versions let content; let lastUsed; let useDashBoardVersion = ''; // Get data from state which version was used previous Time try { lastUsed = await this.getStateAsync(`_ESPHomeDashboard.selectedVersion`); if (lastUsed && lastUsed.val) { lastUsed = lastUsed.val; } } catch (e) { // State does nto exist } // Try to get all current available versions try { const response = await fetch('https://api.github.com/repos/esphome/esphome/releases'); content = await response.json(); } catch (error) { this.errorHandler(`[espHomeDashboard-VersionCall]`, error); } // If the response was successful, write versions names to a memory array if (content) { await this.stateSetCreate(`_ESPHomeDashboard.versionCache`, 'versionCache', JSON.stringify(content)); for (const version in content) { dashboardVersions.push(content[version].name); } await this.stateSetCreate(`_ESPHomeDashboard.newestVersion`, 'newestVersion', content[0].name); } else { // Not possible to load latest versions, use fallback this.log.warn(`Unable to retrieve current Dashboard release versions, using cached values. Check your internet connection`); let cachedVersions = await this.getStateAsync(`_ESPHomeDashboard.versionCache`); if (cachedVersions && cachedVersions.val){ cachedVersions = JSON.parse(cachedVersions.val); for (const version in cachedVersions) { dashboardVersions.push(cachedVersions[version].name); } } } // Use latest available version if (this.config.ESPHomeDashboardVersion && this.config.ESPHomeDashboardVersion !== '' && this.config.ESPHomeDashboardVersion !== 'Always last available') { useDashBoardVersion = this.config.ESPHomeDashboardVersion; } else if (this.config.ESPHomeDashboardVersion === 'Always last available'){ if (content) useDashBoardVersion = content[0].name; } if (useDashBoardVersion !== '') { await this.stateSetCreate(`_ESPHomeDashboard.selectedVersion`, 'selectedVersion', useDashBoardVersion); } else if (lastUsed != null) { // @ts-ignore useDashBoardVersion = lastUsed; } // Start Dashboard Process if (this.config.ESPHomeDashboardEnabled) { this.log.info(`Native Integration of ESPHome Dashboard enabled, making environment ready`); try { // @ts-ignore const {getVenv} = await import('autopy'); let python; try { // Create a virtual environment with esphome installed. python = await getVenv({ name: 'esphome', pythonVersion: '3.13.2', // Use any Python 3.13.x version. requirements: [{name: 'esphome', version: `==${useDashBoardVersion}`}, {name: 'pillow', version: '==10.4.0'}], // Use latest esphome }); } catch (error) { this.log.error(`Fatal error starting ESPHomeDashboard | ${error} | ${error.stack}`); return; } // Define directory to store configuration files const dataDir = utils.getAbsoluteDefaultDataDir(); try { fs.mkdir(`${dataDir}esphome.${this.instance}`, (err) => { if (err) { return console.log(`ESPHome directory exists`); } console.log(`ESPHome directory created`); }); // ); } catch (e) { // Directory has issues reading/writing data, iob fix should be executed this.log.warn(`ESPHome DDashboard is unable to access directory to store YAML configuration data, please run ioBroker fix`); } this.log.info(`Starting ESPHome Dashboard`); const dashboardProcess = python('esphome', ['dashboard', '--port', this.config.ESPHomeDashboardPort, `${dataDir}esphome.${this.instance}`]); this.log.debug(`espHomeDashboard_Process ${JSON.stringify(dashboardProcess)}`); dashboardProcess.stdout?.on('data', (data) => { this.log.info(`[dashboardProcess - Data] ${data}`); }); dashboardProcess.stderr?.on('data', (data) => { // this.log.warn(`[dashboardProcess ERROR] ${data}`); if (data.includes('INFO')) { if (data.includes('Starting')) { this.log.info(`[ESPHome - Console] ${data}`); } else { this.log.debug(`[ESPHome - Console] ${data}`); } } else { // console.debug(`[espHomeDashboard] Unknown logging data : ${JSON.stringify(data)}`); } }); dashboardProcess.on('message', (code, signal) => { this.log.info(`[dashboardProcess MESSAGE] Exit code is: ${code} | ${signal}`); }); // eslint-disable-next-line no-unused-vars dashboardProcess.on('exit', (code, signal) => { this.log.warn(`ESPHome Dashboard stopped`); }); dashboardProcess.on('error', (data) => { if (data.message.includes('INFO')) { this.log.info(`[dashboardProcess Info] ${data}`); } else if (data.message.includes('ERROR')) { this.log.error(`[dashboardProcess Warn] ${data}`); } else { this.log.error(`[dashboardProcess Error] ${data}`); } }); } catch (error) { this.errorHandler(`[espHomeDashboard-Process]`, error); } } else { this.log.info(`Native Integration of ESPHome Dashboard disabled `); } } catch (error) { this.errorHandler(`[espHomeDashboard-Function]`, error); } } // Try to contact and read data of already known devices async tryKnownDevices() { try { // Get all current devices from adapter tree const knownDevices = await this.getDevicesAsync(); // Cancel operation if no devices are found if (!knownDevices) return; // Get connection data of known devices and to connect for (const i in knownDevices) { const deviceDetails = knownDevices[i].native; // Create a memory object and store mandatory connection data clientDetails[deviceDetails.ip] = new clientDevice(); clientDetails[deviceDetails.ip].storeExistingDetails( deviceDetails.ip, deviceDetails.encryptionKeyUsed ? deviceDetails.encryptionKeyUsed : false, `${deviceDetails.mac}`, `${deviceDetails.deviceName}`, `${deviceDetails.name}`, !deviceDetails.encryptionKeyUsed ? deviceDetails.apiPassword ? deviceDetails.apiPassword : deviceDetails.passWord : null, deviceDetails.encryptionKeyUsed ? deviceDetails.encryptionKey : null ); // Start connection to this device this.connectDevices(deviceDetails.ip); } } catch (error) { this.errorHandler(`[tryKnownDevices]`, error); } } // MDNS discovery handler for ESPHome devices deviceDiscovery() { try { // Get a list of IP-Addresses from Adapter config to exclude by autodiscovery const excludedIP = []; // Prepare an array to easy processing containing all IP addresses to be excluded from device discovery //ToDo: Check function doesn't look correct for (const entry in this.config.ignoredDevices) { if (this.config.ignoredDevices[entry] && this.config.ignoredDevices[entry]['IP-Address'] && !excludedIP.includes(this.config.ignoredDevices[entry]['IP-Address'])){ excludedIP.push(this.config.ignoredDevices[entry]['IP-Address']); } } // Start device discovery discovery = new Discovery({interface: this.config.discoveryListeningAddress ? this.config.discoveryListeningAddress : '0.0.0.0'}); discovery.run(); discovery.on('info', ( message ) => { this.log.debug(`ESPHome Device found on ${message.address} | ${JSON.stringify(message)}`); if (!excludedIP.includes(message.address) && !newlyDiscoveredClient[message.address] && !clientDetails[message.address]){ this.log.info(`New ESPHome Device discovered: ${message.friendly_name ? message.friendly_name : message.host} on ${message.address}`); if(message.mac == null){ this.log.warn(`Discovered device with undefined mac. ignoring: ${JSON.stringify(message)}`); return; } // Store device data into memory to allow adoption by admin interface newlyDiscoveredClient[message.address] = { ip: message.address, mac: message.mac.toUpperCase(), deviceFriendlyName: message.friendly_name ? message.friendly_name : message.host }; } }); } catch (error) { this.errorHandler(`[deviceDiscovery]`, error); } } /** * Handle Socket connections * @param {string} host IP address of a device */ connectDevices(host) { try { this.log.info(`Try to connect to ${host}`); // Cancel procedure if connection try or action to delete this device is already in progress or if (clientDetails[host] && (clientDetails[host].connecting || clientDetails[host].deletionRequested)) return; this.updateConnectionStatus(host,false,true, 'connecting'); // Generic client settings const clientSettings = { host: host, clientInfo: `${this.host}`, clearSession: true, initializeDeviceInfo: true, initializeListEntities: true, initializeSubscribeStates: false, // initializeSubscribeLogs: false, //ToDo: Make configurable by adapter settings reconnect: true, reconnectInterval: 5000, pingInterval: 5000, //ToDo: Make configurable by adapter settings pingAttempts: 1, //ToDo: Make configurable by adapter settings // port: espDevices[device].port //ToDo: Make configurable by adapter settings }; // Add an encryption key or apiPassword to the settings object if (!clientDetails[host].encryptionKeyUsed) { clientSettings.password = clientDetails[host].apiPassword ? this.decrypt(clientDetails[host].apiPassword) : ''; } else { clientSettings.encryptionKey = this.decrypt(clientDetails[host].encryptionKey); } // Start connection to a client, if connection fails process wil try to reconnect every "reconnection" // interval setting until clientDetails[host].client.disconnect() is called clientDetails[host].client = new Client(clientSettings); // Connection listener clientDetails[host].client.on('connected', async () => { try { await this.updateConnectionStatus(host, true, false, 'Connected', false); this.log.info(`ESPHome client ${host} connected`); // Clear possible present warning messages for devices from previous connection delete warnMessages[host]; // Check if device connection is caused by adding device from admin, if yes send OK message if (this.messageResponse[host]) { this.sendTo(this.messageResponse[host].from, this.messageResponse[host].command, {result: 'OK - Device successfully connected, initializing configuration. Refresh table to show all known devices'}, this.messageResponse[host].callback); delete this.messageResponse[host]; } } catch (e) { this.log.error(`connection error ${e}`); } }); clientDetails[host].client.on('disconnected', async () => { try { if (clientDetails[host].deviceName != null) { await this.updateConnectionStatus(host, false, false, 'disconnected', false); delete clientDetails[host].deviceInfo; // Cleanup all known states in memory related to this device for (const state in this.createdStatesDetails) { // Remove states from cache if (state.split('.')[0] === clientDetails[host].deviceName) { delete this.createdStatesDetails[state]; } } this.log.warn(`ESPHome client ${clientDetails[host].deviceFriendlyName} | ${clientDetails[host].deviceName} | on ${host} disconnected`); } else { this.log.warn(`ESPHome client ${host} disconnected`); } } catch (e) { this.log.debug(`ESPHome disconnect error : ${e}`); } }); clientDetails[host].client.on('initialized', async () => { this.log.info(`ESPHome client ${clientDetails[host].deviceFriendlyName} on ip ${host} initialized`); clientDetails[host].initialized = true; clientDetails[host].connectStatus = 'initialized'; await this.updateConnectionStatus(host, true, false, 'initialized', false); // Start timer to clean up unneeded objects if (resetTimers[host]) resetTimers[host] = clearTimeout(resetTimers[host]); resetTimers[host] = setTimeout(async () => { await this.objectCleanup(host); }, (10000)); }); // Log message listener clientDetails[host].client.connection.on('message', (/** @type {object} */ message) => { this.log.debug(`[ESPHome Device Message] ${host} client log ${message}`); }); clientDetails[host].client.connection.on('data', (/** @type {object} */ data) => { this.log.debug(`[ESPHome Device Data] ${host} client data ${data}`); }); // Handle device information when connected or information updated clientDetails[host].client.on('deviceInfo', async (/** @type {object} */ deviceInfo) => { try { this.log.info(`ESPHome Device info received for ${deviceInfo.name}`); this.log.debug(`DeviceData: ${JSON.stringify(deviceInfo)}`); // Store device information into memory const deviceName = this.replaceAll(deviceInfo.macAddress, `:`, ``); clientDetails[host].mac = deviceInfo.macAddress; clientDetails[host].deviceName = deviceName; clientDetails[host].deviceFriendlyName = deviceInfo.name; await this.updateConnectionStatus(host, true, false, 'Initializing', false); clientDetails[host].deviceInfo = deviceInfo; this.deviceStateRelation[deviceName] = {'ip': host}; this.log.debug(`DeviceInfo ${clientDetails[host].deviceFriendlyName}: ${JSON.stringify(clientDetails[host].deviceInfo)}`); // Create Device main structure await this.extendObjectAsync(deviceName, { type: 'device', common: { name: deviceInfo.name, statusStates: { onlineId: `${this.namespace}.${deviceName}.info._online` }, //@ts-ignore js-controller issue erstellt desc: deviceInfo.friendlyName, }, native: { ip: host, name: clientDetails[host].deviceInfoName, mac: deviceInfo.macAddress, deviceName: deviceName, deviceFriendlyName : deviceInfo.name, apiPassword: clientDetails[host].apiPassword, encryptionKey: clientDetails[host].encryptionKey, encryptionKeyUsed : clientDetails[host].encryptionKeyUsed }, }); // Read JSON and handle states await this.traverseJson(deviceInfo, `${deviceName}.info`); // Check if device connection is caused by adding device from admin, if yes send OK message // ToDo rebuild to new logic if (this.messageResponse[host]) { const massageObj = { 'type': 'info', 'message': 'success' }; // @ts-ignore this.respond(massageObj, this.messageResponse[host]); this.messageResponse[host] = null; } } catch (error) { this.errorHandler(`[deviceInfo]`, error); } }); // Initialise data for states clientDetails[host].client.on('newEntity', async entity => { this.log.debug(`EntityData: ${JSON.stringify(entity.config)}`); try { // Store relevant information into memory object clientDetails[host][entity.id] = { config: entity.config, name: entity.name, type: entity.type, unit: entity.config.unitOfMeasurement !== undefined ? entity.config.unitOfMeasurement || '' : '' }; if (clientDetails[host][entity.id].config.deviceClass) { this.log.info(`${clientDetails[host].deviceFriendlyName} announced ${clientDetails[host][entity.id].config.deviceClass} "${clientDetails[host][entity.id].config.name}"`); } else { this.log.info(`${clientDetails[host].deviceFriendlyName} announced ${clientDetails[host][entity.id].type} "${clientDetails[host][entity.id].config.name}"`); } // Create Device main structure await this.extendObjectAsync(`${clientDetails[host].deviceName}.${entity.type}`, { type: 'channel', common: { name: entity.type, }, native: {}, }); // Cache created channel in device memory if (!clientDetails[host].adapterObjects.channels.includes(`${this.namespace}.${clientDetails[host].deviceName}.${entity.type}`)) { clientDetails[host].adapterObjects.channels.push(`${this.namespace}.${clientDetails[host].deviceName}.${entity.type}`); } // Create state specific channel by id await this.extendObjectAsync(`${clientDetails[host].deviceName}.${entity.type}.${entity.id}`, { type: 'channel', common: { name: entity.config.name }, native: {}, }); // Create a channel in device memory if (!clientDetails[host].adapterObjects.channels.includes(`${this.namespace}.${clientDetails[host].deviceName}.${entity.type}.${entity.id}`)) { clientDetails[host].adapterObjects.channels.push(`${this.namespace}.${clientDetails[host].deviceName}.${entity.type}.${entity.id}`); } //Check if a config channel should be created if (!createConfigStates) { // Delete folder structure if already present try { const obj = await this.getObjectAsync(`${clientDetails[host].deviceName}.${entity.type}.${entity.id}.config`); if (obj) { await this.delObjectAsync(`${clientDetails[host].deviceName}.${entity.type}.${entity.id}.config`, {recursive: true}); } } catch (error) { // do nothing } } else { // Create config channel await this.extendObjectAsync(`${clientDetails[host].deviceName}.${entity.type}.${entity.id}.config`, { type: 'channel', common: { name: 'Configuration data' }, native: {}, }); // Store channel in device memory if (!clientDetails[host].adapterObjects.channels.includes(`${this.namespace}.${clientDetails[host].deviceName}.${entity.type}.${entity.id}.config`)) { clientDetails[host].adapterObjects.channels.push(`${this.namespace}.${clientDetails[host].deviceName}.${entity.type}.${entity.id}.config`); } // Handle Entity JSON structure and write related config channel data await this.traverseJson(entity.config, `${clientDetails[host].deviceName}.${entity.type}.${entity.id}.config`); } await this.createNonStateDevices(host, entity); // Request current state values await clientDetails[host].client.connection.subscribeStatesService(); this.log.debug(`[DeviceInfoData] ${clientDetails[host].deviceFriendlyName} ${JSON.stringify(clientDetails[host].deviceInfo)}`); // Listen to state changes and write values to states (create state if not yet exists) entity.on(`state`, async (/** @type {object} */ state) => { clientDetails[host].connectStatus = 'connected'; await this.updateConnectionStatus(host, true, false, 'connected', false); this.log.debug(`StateData: ${JSON.stringify(state)}`); try { this.log.debug(`[entityStateConfig] ${JSON.stringify(clientDetails[host][entity.id])}`); this.log.debug(`[entityStateData] ${JSON.stringify(state)}`); const deviceDetails = `DeviceType ${clientDetails[host][entity.id].type} | State-Keys ${JSON.stringify(state)} | [entityStateConfig] ${JSON.stringify(clientDetails[host][entity.id])}`; // Ensure proper initialization of the state switch (clientDetails[host][entity.id].type) { case 'BinarySensor': await this.handleRegularState(`${host}`, entity, state, false); break; case 'Climate': await this.handleStateArrays(`${host}`, entity, state); break; case 'Cover': // esphome send position and tilt as 0-1 value await this.stateSetCreate(`${clientDetails[host].deviceName}.${entity.type}.${entity.id}.position`, `Position`, state.position * 100, `%`, true); await this.stateSetCreate(`${clientDetails[host].deviceName}.${entity.type}.${entity.id}.tilt`, `Tilt`, state.tilt * 100, `%`, true); await this.stateSetCreate(`${clientDetails[host].deviceName}.${entity.type}.${entity.id}.stop`, `Stop`, false, ``, true); break; case 'Fan': await this.handleRegularState(`${host}`, entity, state, false); break; case 'Light': await this.handleStateArrays(`${host}`, entity, state); break; case 'Sensor': await this.handleRegularState(`${host}`, entity, state, false); break; case 'TextSensor': await this.handleRegularState(`${host}`, entity, state, true); break; case 'Switch': await this.handleRegularState(`${host}`, entity, state, true); break; case 'Number': await this.handleRegularState(`${host}`, entity, state, true); break; case 'Text': { await this.handleRegularState(`${host}`, entity, state, true); break; } case 'Select': { await this.handleRegularState(`${host}`, entity, state, true); break; } default: if (!warnMessages[clientDetails[host][entity.id].type]) { this.log.warn(`DeviceType ${clientDetails[host][entity.id].type} not yet supported`); this.log.warn(`Please submit git issue with all information from next line`); this.log.warn(`DeviceType ${clientDetails[host][entity.id].type} | State-Keys ${JSON.stringify(state)} | [entityStateConfig] ${JSON.stringify(clientDetails[host][entity.id])}`); warnMessages[clientDetails[host][entity.id].type] = deviceDetails; } } } catch (error) { this.errorHandler(`[connectHandler NewEntity]`, error); } }); entity.connection.on(`destroyed`, async (/** @type {object} */ state) => { try { this.log.warn(`Connection destroyed for ${state}`); } catch (e) { this.log.error(`State handle error ${e}`); } }); entity.on(`error`, async (/** @type {object} */ name) => { this.log.error(`Entity error: ${name}`); }); } catch (e) { this.log.error(`Connection issue for ${entity.name} ${e} | ${e.stack}`); } }); // Connection data handler clientDetails[host].client.on('error', async (error) => { try { let optimisedError = error.message; // Optimise error messages if ((error.message && (error.message.includes('EHOSTUNREACH') || error.message.includes('EHOSTDOWN'))) || (error.code && error.code.includes('ETIMEDOUT'))) { optimisedError = `Client ${host} unreachable !`; if (!clientDetails[host].connectionError) { this.log.error(optimisedError); await this.updateConnectionStatus(host, false, false, 'unreachable', true); } } else if (error.message.includes('Invalid password')) { optimisedError = `Client ${host} incorrect password !`; if (!clientDetails.connectionError) { this.log.error(optimisedError); await this.updateConnectionStatus(host, false, false, 'API password incorrect', true); } } else if (error.message.includes('Encryption expected')) { optimisedError = `Client ${host} requires encryption key which has not been provided, please enter encryption key in adapter settings for this device !`; if (!clientDetails[host].connectionError) { this.log.error(optimisedError); await this.updateConnectionStatus(host, false, false, 'Encryption Key Missing', true); } } else if (error.message.includes('ECONNRESET')) { optimisedError = `Client ${host} Connection Lost, will reconnect automatically when device is available!`; if (!clientDetails[host].connectionError) { this.log.warn(optimisedError); await this.updateConnectionStatus(host, false, false, 'connection lost', true); } } else if (error.message.includes('timeout')) { optimisedError = `Client ${host} Timeout, will reconnect automatically when device is available!`; if (!clientDetails[host].connectionError) { this.log.warn(optimisedError); await this.updateConnectionStatus(host, false, false, 'unreachable', true); } } else if (error.message.includes('ECONNREFUSED')) { optimisedError = `Client ${host} not yet ready to connect, will try again!`; await this.updateConnectionStatus(host, false, true, 'initializing', true); this.log.warn(optimisedError); } else if (error.message.includes('ENETUNREACH')) { optimisedError = `Network not ready to connect to client ${host}`; if (!clientDetails[host].connectionError) { await this.updateConnectionStatus(host, false, true, 'No Network', true); this.log.warn(optimisedError); } } else if (error.message.includes('write after end')) { // Ignore error } else { this.log.error(`ESPHome client ${host} ${error}`); } // Check if device connection is caused by adding device from admin, if yes send OK message if (this.messageResponse[host]) { this.sendTo(this.messageResponse[host].from, this.messageResponse[host].command, {error: `${optimisedError}`}, this.messageResponse[host].callback); delete this.messageResponse[host]; } } catch (error) { this.errorHandler(`[connectedDevice onError]`, error); } }); //ToDo: Review should not be needed as reconnect process already takes care of it // connect to socket try { this.log.debug(`trying to connect to ${host}`); clientDetails[host].client.connect(); } catch (e) { this.log.error(`Client ${host} connect error ${e}`); } } catch (e) { this.log.error(`ESP device error for ${host} | ${e} | ${e.stack}`); } } /** * Handle regular state values * @param {string} host IP-Address of client * @param {object} entity Entity-Object of value * @param {object} state State-Object * @param {boolean} writable Indicate if state should be writable */ async handleRegularState(host, entity, state, writable) { try { // Round value to digits as known by configuration let stateVal = state.state; if (clientDetails[host][entity.id].config.accuracyDecimals != null) { const rounding = `round(${clientDetails[host][entity.id].config.accuracyDecimals})`; this.log.debug(`Value "${stateVal}" for name "${entity}" before function modify with method "round(${clientDetails[host][entity.id].config.accuracyDecimals})"`); stateVal = this.modify(rounding, stateVal); this.log.debug(`Value "${stateVal}" for name "${entity}" after function modify with method "${rounding}"`); } //ToDo review this code section /** @type {ioBroker.StateCommon} */ const stateCommon = { }; if(entity.config.optionsList != null) { stateCommon.states = entity.config.optionsList; } await this.stateSetCreate(`${clientDetails[host].deviceName}.${entity.type}.${entity.id}.state`, `State of ${entity.config.name}`, stateVal, clientDetails[host][entity.id].unit, writable, stateCommon); } catch (error) { this.errorHandler(`[espHomeDashboard]`, error); } } /** * Handle state values * @param {string} host IP-Address of client * @param {object} entity Entity-Object of value * @param {object} state State-Object */ async handleStateArrays(host, entity, state) { try { clientDetails[host][entity.id].states = state; for (const stateName in clientDetails[host][entity.id].states) { let unit = ''; let writable = true; let writeValue = state[stateName]; // Define if state should be writable switch (stateName) { case 'currentTemperature': unit = `°C`; writable = false; clientDetails[host][entity.id].states.currentTemperature = this.modify('round(2)', state[stateName]); break; case 'oscillating': // Sensor type = Fan, write not supported writable = false; break; case 'speed': // Sensor type = Fan, write not supported writable = false; break; } // Add unit to temperature states if (stateName === `targetTemperature` || stateName === `targetTemperatureLow` || stateName === `targetTemperatureHigh`) { unit = `°C`; } // Add unit to states if (stateName === `brightness` || stateName === `blue` || stateName === `green` || stateName === `red` || stateName === `white` || stateName === `colorTemperature`) { writeValue = Math.round((state[stateName] * 100) * 2.55); // Create transitionLength state only ones if (clientDetails[host][entity.id].states.transitionLength == null) { // Check if state already exists let transitionLength; try { // Try to get current state transitionLength = await this.getStateAsync(`${clientDetails[host].deviceName}.${entity.type}.${entity.id}.transitionLength`); // Check if state contains value if (transitionLength) { clientDetails[host][entity.id].states.transitionLength = transitionLength.val; // Run create state routine to ensure state is cached in memory await this.stateSetCreate(`${clientDetails[host].deviceName}.${entity.type}.${entity.id}.transitionLength`, `${stateName} of ${entity.config.name}`, transitionLength.val, `ms`, writable); } else { // Else create it await this.stateSetCreate(`${clientDetails[host].deviceName}.${entity.type}.${entity.id}.transitionLength`, `${stateName} of ${entity.config.name}`, 0, `ms`, writable); clientDetails[host][entity.id].states.transitionLength = 0; } } catch (e) { // Else create it await this.stateSetCreate(`${clientDetails[host].deviceName}.${entity.type}.${entity.id}.transitionLength`, `${stateName} of ${entity.config.name}`, 0, `ms`, writable); clientDetails[host][entity.id].states.transitionLength = 0; } } } if (stateName !== 'key') { await this.stateSetCreate(`${clientDetails[host].deviceName}.${entity.type}.${entity.id}.${stateName}`, `${stateName} of ${entity.config.name}`, writeValue, unit, writable); } } // Convert RGB to HEX and write to state if (clientDetails[host][entity.id].states.red != null && clientDetails[host][entity.id].states.blue != null && clientDetails[host][entity.id].states.green != null) { const hexValue = this.rgbToHex( Math.round((clientDetails[host][entity.id].states.red * 100) * 2.55), Math.round((clientDetails[host][entity.id].states.green * 100) * 2.55), Math.round((clientDetails[host][entity.id].states.blue * 100) * 2.55), ); await this.stateSetCreate(`${clientDetails[host].deviceName}.${entity.type}.${entity.id}.colorHEX`, `ColorHEX of ${entity.config.name}`, hexValue, '', true); } } catch (error) { this.errorHandler(`[espHomeDashboard]`, error); } } /** * Traverses the json-object and provides all information for creating/updating states * @param {object} jObject Json-object to be added as states * @param {string | null} parent Defines the parent object in the state tree; default=root * @param {boolean} replaceName Steers if name from child should be used as name for structure element (channel); default=false * @param {boolean} replaceID Steers if ID from child should be used as ID for structure element (channel); default=false; * @param {number} state_expire expire time for the current setState in seconds; default is no expire */ async traverseJson(jObject, parent = null, replaceName = false, replaceID = false, state_expire = 0) { let id = null; let value = null; let name = null; try { for (const i in jObject) { name = i; if (!!jObject[i] && typeof (jObject[i]) === 'object' && jObject[i] === '[object Object]') { if (parent == null) { id = i; if (replaceName) { if (jObject[i].name) name = jObject[i].name; } if (replaceID) { if (jObject[i].id) id = jObject[i].id; } } else { id = parent + '.' + i; if (replaceName) { if (jObject[i].name) name = jObject[i].name; } if (replaceID) { if (jObject[i].id) id = parent + '.' + jObject[i].id; } } // Avoid channel creation for empty arrays/objects if (Object.keys(jObject[i]).length !== 0) { // console.log(`park`); await this.setObjectAsync(id, { 'type': 'channel', 'common': { 'name': name, }, 'native': {}, }); await this.traverseJson(jObject[i], id, replaceName, replaceID, state_expire); } else { this.log.debug('State ' + id + ' received with empty array, ignore channel creation'); } } else { value = jObject[i]; if (parent == null) { id = i; } else { id = parent + '.' + i; } if (typeof (jObject[i]) == 'object') value = JSON.stringify(value); //avoid state creation if empty if (value !== '[]') { this.log.debug('create id ' + id + ' with value ' + value + ' and name ' + name); await this.stateSetCreate(id, name, value); } } } } catch (error) { this.errorHandler(`[traverseJson]`, error); } } /** * Function to handle state creation * proper object definitions * rounding of values * @param {string} objName ID of the object * @param {string} name Name of state (also used for stattAttrlib!) * @param {boolean | string | number | null} [value] Value of the state * @param {string} [unit] Unit to be set * @param {boolean} [writable] state writable ? * @param {object} initialStateCommon Additional attributes for state.common */ async stateSetCreate(objName, name, value, unit, writable, /** @type Partial<ioBroker.StateCommon> **/ initialStateCommon = {}) { this.log.debug('Create_state called for : ' + objName + ' with value : ' + value); try { // Try to get details from state lib, if not use defaults. Throw warning if states are not known in an attribute list /** @type {Partial<ioBroker.StateCommon>} */ const common = initialStateCommon; // const entityID = objName.split('.'); // common.modify = {}; if (!stateAttr[name]) { const warnMessage = `State attribute definition missing for '${name}'`; if (warnMessages[name] !== warnMessage) { warnMessages[name] = warnMessage; // Send information to Sentry // this.sendSentry(warnMessage); } } common.name = stateAttr[name] !== undefined ? stateAttr[name].name || name : name; common.type = typeof (value); common.role = stateAttr[name] !== undefined ? stateAttr[name].role || 'state' : 'state'; common.read = true; common.unit = unit !== undefined ? unit || '' : ''; // common.write = stateAttr[name] !== undefined ? stateAttr[name].write || false : false; common.write = writable !== undefined ? writable || false : false; // common.modify = stateAttr[name] !== undefined ? stateAttr[name].modify || '' : ''; // this.log.debug(`MODIFY to ${name}: ${JSON.stringify(common.modify)}`); if ((!this.createdStatesDetails[objName]) || (this.createdStatesDetails[objName] && ( common.name !== this.createdStatesDetails[objName].name || common.name !== this.createdStatesDetails[objName].name || common.type !== this.createdStatesDetails[objName].type || common.role !== this.createdStatesDetails[objName].role || common.read !== this.createdStatesDetails[objName].read || common.unit !== this.createdStatesDetails[objName].unit || common.write !== this.createdStatesDetails[objName].write ) )) { // console.log(`An attribute has changed : ${state}`); // @ts-ignore values are correctly provided by state Attribute definitions, error can be ignored await this.extendObjectAsync(objName, { type: 'state', common, native: {} }); } else { // console.log(`Nothing changed do not update object`); } // Store current object definition to memory this.createdStatesDetails[objName] = common; // // Set value to state if (value != null) { await this.setStateAsync(objName, { val: value, ack: true }); } // Subscribe on state changes if writable common.write && this.subscribeStates(objName); } catch (error) { this.errorHandler(`[stateSetCreate]`, error); } } /** * Handles error messages for log and Sentry * @param {string} codepart Function were exception occurred * @param {any} error Error message */ errorHandler(codepart, error) { let errorMsg = error; if (error instanceof Error && error.stack != null) errorMsg = error.stack; try { if (!disableSentry) { this.log.info(`[Error caught and send to Sentry, thank you collaborating!] error: ${errorMsg}`); if (this.supportsFeature && this.supportsFeature('PLUGINS')) { const sentryInstance = this.getPluginInstance('sentry'); if (sentryInstance && typeof sentryInstance.getSentryObject === 'function') { const sentryObject = sentryInstance.getSentryObject(); if (sentryObject) { sentryObject.captureException(errorMsg); } } } } else { this.log.error(`Sentry disabled, error caught : ${errorMsg}`); } } catch (error) { this.log.error(`Error in function sendSentry: ${error}`); } } /** * Helper replace function * @param {string} string String to replace invalid chars */ escapeRegExp(string) { return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } /** * Helper replace function * @param {string} str String to replace invalid chars * @param {string} find String to find for replace function * @param {string} replace String to replace */ replaceAll(str, find, replace) { return str.replace(new RegExp(this.escapeRegExp(find), 'g'), replace); } /** * Analysis modify an element in stateAttr.js and execute command * @param {string} method defines the method to be executed (e.g. round()) * @param {string | number | boolean} value value to be executed */ modify(method, value) { this.log.debug(`Function modify with method "${method}" and value "${value}"`); let result = null; try { if (method.match(/^custom:/gi) != null) { //check if starts with "custom:" value = eval(method.replace(/^custom:/gi, '')); //get value without "custom:" } else if (method.match(/^multiply\(/gi) != null) { //check if starts with "multiply(" const inBracket = parseFloat(method.match(/(?<=\()(.*?)(?=\))/g)); //get value in brackets value = value * inBracket; } else if (method.match(/^divide\(/gi) != null) { //check if starts with "divide(" const inBracket = parseFloat(method.match(/(?<=\()(.*?)(?=\))/g)); //get value in brackets value = value / inBracket; } else if (method.match(/^round\(/gi) != null) { //check if starts with "round(" const inBracket = parseInt(method.match(/(?<=\()(.*?)(?=\))/g)); //get value in brackets value = Math.round(value * Math.pow(10, inBracket)) / Math.pow(10, inBracket); } else if (method.match(/^add\(/gi) != null) { //check if starts with "add(" const inBracket = parseFloat(method.match(/(?<=\()(.*?)(?=\))/g)); //get value in brackets value = parseFloat(value) + inBracket; } else if (method.match(/^substract\(/gi) != null) { //check if starts with "substract(" const inBracket = parseFloat(method.match(/(?<=\()(.*?)(?=\))/g)); //get value in brackets value = parseFloat(value) - inBracket; } else { const methodUC = method.toUpperCase(); switch (methodUC) { case 'UPPERCASE': if (typeof value == 'string') result = value.toUpperCase(); break; case 'LOWERCASE': if (typeof value == 'string') result = value.toLowerCase(); break; case 'UCFIRST': if (typeof value == 'string') result = value.substring(0, 1).toUpperCase() + value.substring(1).toLowerCase(); break; default: result = value; } } if (!result) return value; return result; } catch (error) { this.errorHandler(`[modify]`, error); return value; } } /** * Is called when adapter shuts down - callback has to be called under any circumstances! * @param {() => void} callback */ onUnload(callback) { try { // this.log.debug(JSON.stringify(clientDetails)); // Set all online states to false for (const device in clientDetails) { // Ensure all known online states are set to false if (clientDetails[device].mac != null) { const deviceName = this.replaceAll(clientDetails[device].mac, `:`, ``); if (clientDetails[device].connectStatus !== 'newly discovered') this.setState(`${deviceName}.info._online`, {val: false, ack: true}); } if (discovery) { discovery.destroy(); } try { clientDetails[device].client.disconnect(); } catch (e) { this.log.debug(`[onUnload] ${JSON.stringify(e)}`); } } // Ensure all possible running timers are cleared for (const timer in resetTimers) { if (resetTimers[timer]) resetTimers[timer] = clearTimeout(resetTimers[timer]); } try { if (dashboardProcess) { dashboardProcess.kill('SIGTERM', { forceKillAfterTimeout: 2000 }); } } catch (e) { this.log.error(`[onUnload - dashboardProcess] ${JSON.stringify(e)}`); } callback(); } catch (error) { this.errorHandler(`[onUnload]`, error); callback(); } } /** * Validate a proper format of IP-Address * @param {string} ipAddress */ // eslint-disable-next-line no-case-declarations,no-inner-declarations validateIPAddress(ipAddress) { return /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(ipAddress); } /** * Some message was sent to this instance over message box. Used by email, pushover, text2speech, ... * Using this method requires "common.message" property to be set to true in io-package.json * @param {ioBroker.Message} obj */ async onMessage(obj) { this.log.debug('Data from configuration received : ' + JSON.stringify(obj)); try { switch (obj.command) { //ToDo previous add function to be removed case 'addDevice': // eslint-disable-next-line no-case-declarations const ipValid = this.validateIPAddress(obj.message['device-ip']); if (!ipValid) { this.log.warn(`You entered an incorrect IP-Address ${obj.message['device-ip']}, cannot add device !`); const massageObj = { 'type': 'error', 'message': 'connection failed' }; // @ts-ignore this.respond(massageObj, obj); } else { this.log.info(`Valid IP address received`); this.connectDevices(obj.message['device-ip']); } break; case 'loadDevices': { let data = {}; const knownDeviceTable = []; const discoveredDeviceTable = []; //Create table for all known devices for (const device in clientDetails) { knownDeviceTable.push({ 'MACAddress' : clientDetails[device].mac, 'deviceName' : clientDetails[device].deviceFriendlyName, 'ip' : clientDetails[device].ip, 'connectState' : clientDetails[device].connectStatus }); } //Create table for newlyDiscovered Devices for (const device in newlyDiscoveredClient) { discoveredDeviceTable.push({ 'MACAddress' : newlyDiscoveredClient[device].mac, 'deviceName' : newlyDiscoveredClient[device].deviceFriendlyName, 'ip' : newlyDiscoveredClient[device].ip, }); } data = { native: { existingDevicesTable: knownDeviceTable, newDevicesTable: discoveredDeviceTable, }, }; this.sendTo(obj.from, obj.command, data, obj.callback); } break; // Front End message handler to load IP-Address dropDown with all current known devices case 'getDeviceIPs': { const dropDownEntry = []; for (const device in clientDetails) { dropDownEntry.push({label: device, value: clientDetails[device].ip}); } for (const device in newlyDiscoveredClient) { dropDownEntry.push({label: device, value: newlyDiscoveredClient[device].ip}); } this.sendTo(obj.from, obj.command, dropDownEntry, obj.callback); } break; // Front End message handler to load ESPHome Dashboard dropDown with all available versions case 'getESPHomeDashboardVersion': { const dropDownEntry = []; dropDownEntry.push('Always last available'); for (const versions in dashboardVersions) { dropDownEntry.push({label: dashboardVersions[versions], value: dashboardVersions[versions]}); } this.sendTo(obj.from, obj.command, dropDownEntry, obj.callback); } break; // Front End message handler to host IP-Address(es) case 'getHostIp': { // Get all current known host IP-Addresses from System object const hostIP = await this.getForeignObjectAsync(`system.host.${this.host}`); const ip4List = []; // Only show IP4 in dropdown if (hostIP) { for (const ip in hostIP.common.address) { console.log(hostIP.common.address[ip]); if (this.validateIPAddress(hostIP.common.address[ip])) ip4List.push({label: hostIP.common.address[ip], value: hostIP.common.address[ip]}); } } // console.log(`IP4 List ${ip4List}`); this.sendTo(obj.from, obj.command, ip4List, obj.callback); } break; // Handle front-end messages to ADD / Modify a devices case '_addUpdateDevice': // console.log(JSON.stringify(obj)); // IP input validation if (obj.message.ip === 'undefined'){ this.sendTo(obj.from, obj.command, {error: 'To add/modify a device, please enter the IP-Address accordingly'}, o