UNPKG

iobroker.esphome

Version:

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

1,030 lines (920 loc) 118 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'); const YamlFileManager = require('./lib/yamlFileManager.js'); // @ts-expect-error 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('node:fs'); const path = require('node:path'); const os = require('node:os'); const { clearTimeout } = require('node:timers'); 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 = []; const pillowVersions = []; // Memory cache for available Pillow versions class Esphome extends utils.Adapter { /** * @param {Partial<utils.AdapterOptions>} [options] - Adapter configuration 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 // Initialize YAML file manager this.yamlFileManager = new YamlFileManager(this); } /** * Is called when databases are connected and adapter received configuration. */ async onReady() { await this.setStateAsync('info.connection', { val: true, ack: true }); try { // Migrate from older adapter versions that only had ESPHomeDashboardIP await this.migrateConfig(); //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('info.deviceCleanup', { type: 'state', common: { role: 'button', name: 'Device or service connected', type: 'boolean', read: false, write: true, def: false, }, }); this.subscribeStates('info.deviceCleanup'); // Create & Subscribe to button for clearing autopy cache this.extendObject('info.clearAutopyCache', { type: 'state', common: { role: 'button', name: 'Clear Autopy Cache', type: 'boolean', read: false, write: true, def: false, }, }); this.subscribeStates('info.clearAutopyCache'); } catch (e) { this.log.error(`[Adapter start] Fatal error occurred ${e}`); } } /** * Migrate configuration from older adapter versions * Automatically set ESPHomeDashboardUrl if ESPHomeDashboardIP is set but URL is empty */ async migrateConfig() { try { // Check if migration is needed: // ESPHomeDashboardIP is not empty AND ESPHomeDashboardUrl is empty if (this.config.ESPHomeDashboardIP && !this.config.ESPHomeDashboardUrl) { const calculatedUrl = `http://${this.config.ESPHomeDashboardIP}:${this.config.ESPHomeDashboardPort}`; this.log.info(`Migrating configuration: Setting ESPHomeDashboardUrl to ${calculatedUrl}`); const adapterObj = await this.getForeignObjectAsync(`system.adapter.${this.namespace}`); if (!adapterObj) { this.log.error( `Configuration migration failed: Could not retrieve adapter configuration object for ${this.namespace}`, ); return; } if (!adapterObj.native) { this.log.error( `Configuration migration failed: Adapter configuration object has no native property`, ); return; } adapterObj.native.ESPHomeDashboardUrl = calculatedUrl; await this.setForeignObject(adapterObj._id, adapterObj); this.log.info(`Configuration migrated successfully. ESPHomeDashboardUrl set to: ${calculatedUrl}`); // adapter will restart } } catch (error) { this.log.error( `Error during configuration migration from ESPHomeDashboardIP to ESPHomeDashboardUrl: ${error.message || error}`, ); } } // 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 { // State does not exist } try { const headers = {}; if (process.env.GITHUB_TOKEN) { headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; } const response = await fetch('https://api.github.com/repos/esphome/esphome/releases', { headers }); if (!response.ok) { throw new Error( `GitHub releases request failed with status ${response.status}: ${response.statusText}`, ); } 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; } else if (dashboardVersions.length > 0) { // Use first cached version if available useDashBoardVersion = dashboardVersions[0]; } } if (useDashBoardVersion !== '') { await this.stateSetCreate(`_ESPHomeDashboard.selectedVersion`, 'selectedVersion', useDashBoardVersion); } else if (lastUsed != null) { // @ts-expect-error lastUsed may be string or number depending on adapter version useDashBoardVersion = lastUsed; } // Fetch Pillow versions from PyPI and cache them const versions = await this.fetchAndCachePillowVersions(); pillowVersions.length = 0; // Clear array pillowVersions.push(...versions); // Determine Pillow version to use let usePillowVersion = ''; // Use configured version unless "Always last available" is selected if ( this.config.PillowVersion && this.config.PillowVersion !== '' && this.config.PillowVersion !== 'Always last available' ) { usePillowVersion = this.config.PillowVersion; } else if (this.config.PillowVersion === 'Always last available' && versions.length > 0) { // Mirror dashboard behavior: pick newest fetched/cached release usePillowVersion = versions[0]; } this.log.debug(`Using Pillow version: ${usePillowVersion}`); // Start Dashboard Process if (this.config.ESPHomeDashboardEnabled) { this.log.info(`Native Integration of ESPHome Dashboard enabled, making environment ready`); try { // @ts-expect-error autopy types are incomplete 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: `==${usePillowVersion}` }, ], // 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 (error) { // 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: ${error}`, ); } 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}`); }); 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('reconnect', async () => { this.log.debug(`Trying to reconnect to ESPHome client ${host}`); }); 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', message => { this.log.debug(`[ESPHome Device Message] ${host} client log ${message}`); }); clientDetails[host].client.connection.on('data', 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 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; 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-expect-error js-controller issue - desc should be string but friendlyName may be undefined 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, }, }); // Create info channel explicitly with proper type await this.extendObjectAsync(`${deviceName}.info`, { type: 'channel', common: { name: 'Device Information', }, native: {}, }); // Store channel in device memory if ( !clientDetails[host].adapterObjects.channels.includes( `${this.namespace}.${clientDetails[host].deviceName}.info`, ) ) { clientDetails[host].adapterObjects.channels.push( `${this.namespace}.${clientDetails[host].deviceName}.info`, ); } await this.updateConnectionStatus(host, true, false, 'Initializing', false); // 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-expect-error massageObj type mismatch with respond method signature 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 { // 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 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.handleStateArrays(`${host}`, entity, state); 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; } case 'Lock': { const deviceName = clientDetails[host].deviceName; // Lock state: 0=NONE, 1=LOCKED, 2=UNLOCKED, 3=JAMMED, 4=LOCKING, 5=UNLOCKING await this.stateSetCreate( `${deviceName}.${entity.type}.${entity.id}.state`, `LockState`, state.state, ``, false, ); // Lock command: 0=UNLOCK, 1=LOCK, 2=OPEN await this.stateSetCreate( `${deviceName}.${entity.type}.${entity.id}.command`, `LockCommand`, null, // No default to prevent accidental triggers ``, 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 state => { try { this.log.warn(`Connection destroyed for ${state}`); } catch (e) { this.log.error(`State handle error ${e}`); } }); entity.on(`error`, async 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 const stateCommon = {}; if (entity.config.optionsList != null) { stateCommon.states = entity.config.optionsList; } await this.stateSetCreate( `${clientDetails[host].deviceName}.${entity.type}.${entity.id}.sta