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
JavaScript
'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