iobroker.lovelace
Version:
With this adapter you can build visualization for ioBroker with Home Assistant Lovelace UI
1,071 lines (997 loc) • 155 kB
JavaScript
const fs = require('fs');
const crypto = require('crypto');
const WebSocket = require('ws');
const bodyParser = require('body-parser');
const PANELS = require('./panels');
const multer = require('multer');
const mime = require('mime');
const yaml = require('js-yaml');
const axios = require('axios');
const jstz = require('jstimezonedetect');
const handleAutoEntitiesCard = require('./modules/autoEntities').handleAutoEntitiesCard;
const utils = require('./entities/utils');
const BrowserModModule = require('./modules/browser_mod');
const processBlind = require('./converters/cover').processBlind;
const converterSwitch = require('./converters/switch');
const converterLight = require('./converters/light');
const converterBinarySensors = require('./converters/binary_sensor');
const converterSensors = require('./converters/sensor');
const processMediaPlayer = require('./converters/media_player').processMediaPlayer;
const converterClimate = require('./converters/climate');
const converterWeather = require('./converters/weather');
const processImage = require('./converters/camera').processImage;
const processLock = require('./converters/lock').processLock;
const converterGeoLocation = require('./converters/geo_location');
const converterDatetime = require('./converters/input_datetime');
const converterAlarmCP = require('./converters/alarm_control_panel');
const converterInputSelect = require('./converters/input_select');
const convertFan = require('./converters/fan');
const entityData = require('./dataSingleton');
const bindings = require('./bindings');
const getFriendlyName = require('./entities/friendly_name').getFriendlyName;
const iobState2EntityState = require('./converters/genericConverter').iobState2EntityState;
const NUMERIC_DEVICE_CLASSES = require('./converters/genericConverter').numericDeviceClasses;
const HistoryModule = require('./modules/history');
const ConversationModule = require('./modules/conversation');
const LogbookModule = require('./modules/logbook');
const PersistentNotifications = require('./modules/persistentNotifications');
const TodoModule = require('./modules/todo');
const PersonModule = require('./modules/person');
const StatisticsRecorderModule = require('./modules/statisticsRecorder');
const EntityRegistry = require('./modules/entityRegistry');
const DashboardModule = require('./modules/dashboard');
const DeviceRegistryModule = require('./modules/deviceRegistry');
const AreaRegistryModule = require('./modules/areaRegistry');
const EXIT_CODES = require('@iobroker/adapter-core').EXIT_CODES;
const TIMEOUT_PASSWORD_ENTER = 180000; // 3 min
const TIMEOUT_AUTH_CODE = 10000; // 10sec
const ChannelDetector = require('@iobroker/type-detector').default;
const { Types } = require('@iobroker/type-detector');
const path = require('node:path');
const ignoreIds = [/^system\./, /^script\./];
const ROOT_DIR = '../hass_frontend';
//frontend expects only YYYY.MM.DD -> omit the rest.
const VERSION = fs
.readFileSync(`${getRootPath()}version.txt`, 'utf8')
.replace(/(\d{4})(\d{2})(\d{2})\.(\d).*/s, '$1.$2.$3');
const NO_TOKEN = 'no_token';
function getRootPath() {
if (ROOT_DIR.match(/^\w:/) || ROOT_DIR.startsWith('/')) {
return `${ROOT_DIR}/`;
}
return path.resolve(`${__dirname}/${ROOT_DIR}`) + path.sep;
}
const generateRandomToken = function (callback) {
crypto.randomBytes(256, (ex, buffer) => {
crypto.randomBytes(32, (ex, secret) => {
if (ex) {
return callback('server_error');
}
const token = crypto.createHmac('sha256', secret).update(buffer).digest('hex');
callback(false, token);
});
});
};
/*
possible HASS entity types:
- fan
- input_boolean
- light => STATE on/off, attributes: [brightness, hs_color([h,s]), min_mireds, max_mireds, color_temp, white_value, effect_list, effect, supported_features ], commands: turn_on(brightness_pct, hs_color, brightness, color_temp, white_value, effect), turn_off, toggle
supported_features: brightness=0x01, colorTemp=0x02, effectList=0x04, color=0x10, whiteValue=0x80
- switch => STATE on/off, attributes: [brightness, hs_color], commands: turn_on, turn_off, toggle
- group
- automation
- climate => STATE on/off, attributes: [current_temperature, operation_mode, operation_list, target_temp_step, target_temp_low, target_temp_high,min_temp, max_temp, temperature], commands:
- cover
- configurator
- input_select
- input_number
- input_text
- lock
- media_player =>
STATE on/off/playing/paused/idle/standby/unknown,
attributes: [media_content_type(music/game/music/tvshow/...), entity_picture(as cover), media_duration, supported_features, is_volume_muted, volume_level, media_duration, media_position, media_position_updated_at, media_title, media_artist, media_series_title, media_season, media_episode, app_name, source, source_list, sound_mode, sound_mode_list],
commands: media_play_pause, media_next_track, media_play_pause, media_previous_track, volume_set(volume_level), turn_off, turn_on, volume_down, volume_mute(is_volume_muted), volume_up, select_source(source), select_sound_mode(sound_mode),
features for supported_features: PAUSE 0x1, volume_set 0x4, volume_mute 0x8, media_previous_track 0x10, media_next_track 0x20, turn_on 0x80, turn_off 0x100, play_media 0x200, volume_down/volume_up 0x400, select_source 0x800, select_sound_mode (0x10000), play (0x4000)
- scene
- script
- timer => STATE idle/paused/active, attributes: [remaining]
- vacuum
- water_heater
- weblink
- camera
- history_graph
- input_datetime
- sun
- updater
- binary_sensor => STATE on/off
- geo_location => attributes: [latitude, longitude, passive, icon, radius, entity_picture, gps_accuracy, source]
- weather => STATE any-text(no icon)/clear-night/cloudy/fog/hail/lightning/lightning-rainy/partlycloudy/pouring/rainy/snowy/snowy-rainy/sunny/windy/windy-variant, attributes: [temperature, pressure, humidity, wind_speed, wind_bearing, forecast]
forecast is an array with max 5 items [{datetime: something for new Date(aa), temperature, templow, condition(see STATE), precipitation}, {...}]
*/
const staticOptions = {
maxAge: 2678400 * 1000, // 31 days
};
/**
* WebServer class, handles incomming requests and manages websocket connections.
*/
class WebServer {
/**
* Constructor of the WebServer class.
*
* @param options object with options from the adapter.
*/
constructor(options) {
this._lovelaceConfig = null;
this._ressourceConfig = []; //new place to store custom cards (modules) and stuff.
this.adapter = options.adapter;
this.config = this.adapter.config;
this.log = this.adapter.log;
this.lang = this.config.language || 'en';
this.detector = new ChannelDetector();
this.config.ttl = parseInt(this.config.ttl, 10) || 3600;
this.words = options.words || {};
//setup entityData:
entityData.adapter = this.adapter;
entityData.log = this.adapter.log;
entityData.words = this.words;
entityData.server = this;
//files that might be requested from frontend and are delivered using readFile. No authority check is applied here!
this._requestableFiles = [];
this._subscribed = [];
this._server = options.server;
this._app = options.app;
this._auth_flows = {};
this.templateStates = {};
this._themes = {}; //themes storage
this._currentTheme = this.config.defaultTheme || 'default';
this._currentThemeDark = this.config.defaultThemeDark || 'default';
this._darkMode = false;
//object data for updates:
this._objectData = {
objects: {}, //id -> object storage
ids: [], //array of object ids.
rooms: [],
functions: [],
roomNames: {}, //id -> name storage
funcNames: {},
updatedObjects: [], //id + object pairs on updates to handle burst updates after burst.
usedKeys: [], //temporary storage for used keys (type-detector)
};
//initialize modules.
this._modules = {
browserMod: new BrowserModModule({
adapter: this.adapter,
objects: this._objectData.objects,
}),
conversation: new ConversationModule({
adapter: this.adapter,
sendResponse: this._sendResponse,
lang: this.lang,
words: this.words,
}),
logbook: new LogbookModule({
adapter: this.adapter,
getUsedEntityIDs: () => {
const entities = [];
this._flatJSON(this._lovelaceConfig ? this._lovelaceConfig.views : {}, entities);
return entities;
},
webSocketServer: this._wss,
}),
notifications: new PersistentNotifications({
adapter: this.adapter,
server: this,
}),
todo: new TodoModule({
adapter: this.adapter,
entityData: entityData,
server: this, //for legacy shopping list.. is that still used at all?
getWebsocketServer: () => this._wss,
}),
person: new PersonModule({
adapter: this.adapter,
}),
entityRegistry: new EntityRegistry({
adapter: this.adapter,
entityData: entityData,
sendResponse: this._sendResponse,
sendUpdate: this._sendUpdate.bind(this),
}),
dashboard: new DashboardModule({
adapter: this.adapter,
sendResponse: this._sendResponse,
sendUpdate: this._sendUpdate.bind(this),
}),
deviceRegistry: new DeviceRegistryModule({
adapter: this.adapter,
entityData,
sendResponse: this._sendResponse,
}),
areaRegistry: new AreaRegistryModule({
adapter: this.adapter,
rooms: this._objectData.rooms,
sendResponse: this._sendResponse,
sendUpdate: this._sendUpdate.bind(this),
}),
};
this._modules.history = new HistoryModule({
adapter: this.adapter,
entityData: entityData,
personModule: this._modules.person,
});
this._modules.statisticsRecorder = new StatisticsRecorderModule({
adapter: this.adapter,
server: this,
log: this.log,
personModule: this._modules.person,
dataSingleton: entityData,
});
this.converter = {
[Types.socket]: converterSwitch.processSocket,
[Types.light]: converterLight.processLight,
[Types.dimmer]: converterLight.processLightAdvanced,
[Types.ct]: converterLight.processLightAdvanced,
[Types.hue]: converterLight.processLightAdvanced,
[Types.rgb]: converterLight.processLightAdvanced,
[Types.rgbSingle]: converterLight.processLightAdvanced,
[Types.motion]: converterBinarySensors.processMotion,
[Types.window]: converterBinarySensors.processWindow,
[Types.windowTilt]: converterSensors.processWindowTilt,
[Types.door]: converterBinarySensors.processDoor,
[Types.button]: converterSwitch.processSocket,
[Types.temperature]: converterSensors.processTemperature,
[Types.humidity]: converterSensors.processHumidity,
[Types.lock]: processLock,
[Types.airCondition]: converterClimate.processThermostatOrAirConditioning,
[Types.thermostat]: converterClimate.processThermostatOrAirConditioning,
[Types.blind]: processBlind,
[Types.blindButtons]: processBlind,
[Types.weatherForecast]: converterWeather.processWeather,
[Types.accuWeatherForecast]: converterWeather.processAccuWeather,
[Types.location]: converterGeoLocation.processLocation,
[Types.location_one]: converterGeoLocation.processLocation,
[Types.media]: processMediaPlayer,
[Types.image]: processImage,
[Types.fireAlarm]: converterBinarySensors.processFireAlarm,
};
if (this.adapter.config.updateTimeout !== undefined) {
this.adapter.config.updateTimeout = Math.max(100, Math.min(this.adapter.config.updateTimeout, 30000));
}
const concurrentPromises = [
this._modules.todo.init(),
this._modules.person.init(),
this._modules.entityRegistry.init(),
this._modules.dashboard.init(),
this.adapter
.getForeignObjectAsync('system.config')
.then(config => {
this.lang = this.config.language || config.common.language;
entityData.lang = this.lang;
this.systemConfig = config.common;
this.systemConfig.ts = config.ts;
this._updateConstantEntities();
return this.adapter.getObjectAsync('configuration');
})
.then(config => {
if (config && config.native && config.native.title) {
this._lovelaceConfig = config.native;
} else {
this._lovelaceConfig = require('./defaultConfig');
}
})
.then(() => this._modules.browserMod.init(this._lovelaceConfig)),
this._readAllEntities(),
this._listFiles(),
this._initThemes(),
];
Promise.all(concurrentPromises)
.then(() => {
this.adapter.subscribeObjects('configuration');
this.adapter.subscribeStates('control.*');
this.adapter.subscribeStates('notifications.*');
this.adapter.subscribeStates('instances.*');
this.adapter.subscribeStates('conversation');
this._init();
for (const module of Object.values(this._modules)) {
if (typeof module.augmentServices === 'function') {
module.augmentServices(entityData.services);
}
}
// check every minute
if (this.config.auth !== false) {
this._clearInterval = setInterval(() => this.clearAuth(), 60000);
}
this.adapter.setState('info.readyForClients', true, true);
this.log.debug('Initialization done.');
})
.catch(err => {
this.log.error(`Initialization error: ${err}`);
if (typeof this.adapter.terminate === 'function') {
this.adapter.terminate(EXIT_CODES.INVALID_ADAPTER_CONFIG);
} else {
process.exit(EXIT_CODES.INVALID_ADAPTER_CONFIG);
}
});
}
/**
* Generate all entities from object database using type-detector and custom settings.
*
* @returns {Promise<void>} resolves, when done.
*/
async _readAllEntities() {
const smartDevices = await this._updateDevices();
for (const entity of smartDevices) {
//fill entity into
utils.fillEntityIntoCaches(entity);
}
await this._getManualEntities(); //creates manual entities.
//now all entities are created. Check for icon urls:
for (const entity of entityData.entities) {
if (entity.attributes.entity_picture && !entity.attributes.entity_picture.match(/^data:image\//)) {
const url = entity.attributes.entity_picture.replace(/^\./, '');
if (!entityData.entityIconUrls.includes(url)) {
entityData.entityIconUrls.push(url);
}
}
}
await this._getAllStates();
await this._manageSubscribesFromConfig();
this.log.debug('entitiesUpdated for startup.');
await this.adapter.setStateAsync('info.entitiesUpdated', true, true);
}
/**
* Remove auth sessions that expired.
*/
clearAuth() {
const now = Date.now();
let changed = false;
Object.keys(this._auth_flows).forEach(flowId => {
const flow = this._auth_flows[flowId];
if (flow.auth_ttl) {
if (now - flow.ts > flow.auth_ttl) {
this.log.debug(`Deleted old flowId ${flow.username} ${flowId}`);
delete this._auth_flows[flowId];
changed = true;
}
} else {
if (now - flow.ts > TIMEOUT_PASSWORD_ENTER) {
this.log.debug(`Deleted old flowId (no password) ${flowId}`);
delete this._auth_flows[flowId];
changed = true;
}
}
});
changed && this._saveAuth();
}
/**
* Generates entities from custom settings (TODO: name is misguiding!!)
*
* @returns {Promise<void>} resolves, when done.
*/
async _getManualEntities() {
try {
const doc = await this.adapter.getObjectViewAsync('system', 'custom', {});
const ids = [];
if (doc && doc.rows) {
for (let i = 0, l = doc.rows.length; i < l; i++) {
if (doc.rows[i].value) {
const id = doc.rows[i].id;
if (doc.rows[i].value[this.adapter.namespace]) {
ids.push(id);
}
}
}
}
ids.push(`${this.adapter.namespace}.control.alarm`);
const created = [];
for (const id of ids) {
const entities = await this._processManualEntity(id);
for (const entity of entities) {
created.push(entity);
utils.fillEntityIntoCaches(entity);
}
}
this._modules.entityRegistry.handleUpdatedEntities(created, false);
} catch (e) {
this.adapter.log.error(`Could not get object view for getAllEntities: ${e.toString()} - ${e.stack}`);
}
}
// ------------------------------- START OF CONVERTERS ---------------------------------------- //
// Process manually created entity
/**
* Create manual entities from custom-part. Process one object here.
*
* @param {string} id of ioBroker object
* @returns {Promise<{context: {id: string, type: string}, attributes: {friendly_name: string}, entity_id: string}[]|entity[]|*[]>} manual entity
*/
async _processManualEntity(id) {
try {
const obj = await this.adapter.getForeignObjectAsync(id);
if (!this._objectData.objects[id]) {
this._objectData.objects[id] = obj;
}
if (id === `${this.adapter.namespace}.control.alarm`) {
obj.common.custom = obj.common.custom || {};
obj.common.custom[this.adapter.namespace] = obj.common.custom[this.adapter.namespace] || {};
obj.common.custom[this.adapter.namespace].name =
obj.common.custom[this.adapter.namespace].name || 'defaultAlarm';
obj.common.custom[this.adapter.namespace].entity = 'alarm_control_panel';
obj.common.custom[this.adapter.namespace].states = {
state: id,
arm_state: `${this.adapter.namespace}.control.alarm_arm_state`,
};
}
const custom = obj.common.custom[this.adapter.namespace] || {};
const entityType = custom.entity || utils.autoDetermineEntityType(obj);
const forcedEntityId = this._modules.entityRegistry.getEntityId(id);
const entity_id = forcedEntityId || utils.createEntityNameFromCuston(obj, this.adapter.namespace);
const entity = utils.processCommon(null, null, null, obj, entityType, entity_id);
if (
custom.attr_assumed_state &&
['switch', 'light', 'cover', 'climate', 'fan', 'humidifier', 'group', 'water_heater'].includes(
entityType,
)
) {
entity.attributes.assumed_state = true;
}
entity.context.STATE = { getId: id, setId: id };
if (obj && obj.common && obj.common.states && ['string', 'number'].includes(obj.common.type)) {
entity.context.STATE.map2lovelace = obj.common.states;
if (!(obj.common.states instanceof Array)) {
entity.context.STATE.map2iob = {};
Object.keys(obj.common.states).forEach(
k => (entity.context.STATE.map2iob[obj.common.states[k]] = k),
);
}
}
utils.addID2entity(id, entity);
if (custom.states && custom.states.stateRead) {
entity.context.STATE.getId = custom.states.stateRead;
utils.addID2entity(custom.states.stateRead, entity);
}
entity.isManual = true;
if (custom.states) {
if (custom.states.state && custom.states.state !== id) {
this.log.error(
`Please define custom settings on main object ${custom.states.state} and not on ${id}. Entity skipped`,
);
return [];
}
custom.states.state = id;
//get objects of all necessary additional ids here:
for (const stateId of Object.values(custom.states)) {
if (!this._objectData.objects[stateId]) {
try {
this._objectData.objects[stateId] = await this.adapter.getForeignObjectAsync(stateId);
} catch (e) {
this.adapter.log.warn(
`Could not get object ${stateId} for manual entity ${entity_id} please check config in ${id}. Error: ${e}`,
);
}
}
}
utils.fillEntityFromStates(custom.states, entity);
}
for (const key of Object.keys(custom)) {
if (key.startsWith('attr_')) {
const attributeName = key.substring('attr_'.length);
entity.attributes[attributeName] = custom[key];
}
}
this.log.debug(`Create manual ${entityType} device: ${entity.entity_id} - ${id}`);
if (entityType === 'light') {
return converterLight.processManualEntity(id, obj, entity, this._objectData.objects, custom);
} else if (entityType === 'input_datetime') {
return converterDatetime.processManualEntity(id, obj, entity, this._objectData.objects, custom);
} else if (entityType === 'binary_sensor') {
return converterBinarySensors.processManualEntity(id, obj, entity, this._objectData.objects, custom);
} else if (entityType === 'sensor') {
return converterSensors.processManualEntity(id, obj, entity, this._objectData.objects, custom);
} else if (entityType === 'climate') {
return converterClimate.processManualEntity(id, obj, entity, this._objectData.objects, custom);
} else if (entityType === 'geo_location') {
return converterGeoLocation.processManualEntity(id, obj, entity, this._objectData.objects, custom);
} else if (entityType === 'camera') {
entity.context.STATE = { getValue: 'on' };
entity.context.ATTRIBUTES = [{ getId: id, attribute: 'url' }];
entity.attributes.code_format = 'number';
entity.attributes.access_token = crypto
.createHmac(
'sha256',
(crypto.webcrypto.getRandomValues(new Uint32Array(1))[0] * 1000000000).toString(),
)
.update(Date.now().toString())
.digest('hex');
entity.attributes.model_name = 'Simulated URL';
entity.attributes.brand = 'ioBroker';
entity.attributes.motion_detection = false;
} else if (entityType === 'alarm_control_panel') {
return converterAlarmCP.processManualEntity(id, obj, entity, this._objectData.objects, custom);
} else if (entityType === 'input_number') {
entity.attributes.min = obj.common.min !== undefined ? obj.common.min : 0;
entity.attributes.max = obj.common.max !== undefined ? obj.common.max : 100;
entity.attributes.step = obj.common.step || 1;
entity.attributes.mode =
entity.attributes.mode || obj.common.custom[this.adapter.namespace].mode || 'slider'; // or box, will become input box.
const state = await this.adapter.getForeignStateAsync(id);
entity.attributes.initial = state ? state.val || 0 : 0;
} else if (entityType === 'input_boolean') {
const state = await this.adapter.getForeignStateAsync(id);
entity.attributes.initial = iobState2EntityState(entity, state ? state.val : undefined, 'initial');
} else if (entityType === 'input_select') {
return converterInputSelect.processManualEntity(id, obj, entity, this._objectData.objects, custom);
} else if (entityType === 'fan') {
return convertFan.processManualEntity(id, obj, entity, this._objectData.objects, custom);
} else if (entityType === 'todo') {
return this._modules.todo.processManualEntity(id, obj, entity, this._objectData.objects, custom);
} else if (entityType === 'switch') {
return converterSwitch.processManualEntity(id, obj, entity, this._objectData.objects, custom);
} else if (entityType === 'timer') {
// - timer => STATE idle/paused/active, attributes: [remaining]
entity.context.STATE = { getId: null, setId: null }; // will be simulated
entity.context.lastValue = null;
entity.attributes.remaining = 0;
entity.context.ATTRIBUTES = [
{
attribute: 'remaining',
getId: id,
setId: id,
getParser: function (entity, attr, state) {
state = state || { val: null };
// - timer => STATE idle/paused/active, attributes: [remaining]
// if 0 => timer is off
if (!state.val) {
entity.state = 'idle';
} else if (entity.context.lastValue === null) {
entity.state = 'active';
} else if (entity.context.lastValue === state.val) {
// pause
entity.state = 'paused';
} else {
// active
entity.state = 'active';
}
entity.context.lastValue = state.val;
// seconds to D HH:MM:SS
if (typeof state.val === 'string' && state.val.indexOf(':') !== -1) {
entity.attributes.remaining = state.val;
} else {
state.val = parseInt(state.val, 10);
const hours = Math.floor(state.val / 3600);
const minutes = Math.floor((state.val % 3600) / 60);
const seconds = state.val % 60;
entity.attributes.remaining = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
},
},
];
}
utils.addID2entity(id, entity);
return [entity];
} catch (e) {
this.adapter.log.error(`Could not process manual entity ${id}: ${e.toString()} - ${e.stack}`);
}
}
/**
* Process a single service call from the frontend.
*
* @param ws websocket connection to the frontend
* @param {Record<string,any>} data data of the service call
* @param {string} entity_id entity id connected to the call. Required to be a single id in this function.
* @returns {Promise<void>} resolves when done.
*/
async _processSingleCall(ws, data, entity_id) {
const user = this._modules.person.getUserIDFromName(ws.__auth?.username);
const entity = entityData.entityId2Entity[entity_id];
const id = entity.context.STATE.setId;
if (entity.context.COMMANDS) {
const command = entity.context.COMMANDS.find(c => c.service === data.service);
if (command && command.parseCommand) {
return command
.parseCommand(entity, command, data, user)
.then(result => this._sendResponse(ws, data.id, result))
.catch(e => this._sendResponse(ws, data.id, { result: false, error: e.message || e }));
}
}
if (data.service === 'toggle') {
this.log.debug(`toggle ${id}`);
this.adapter.getForeignState(id, { user }, (err, state) =>
this.adapter.setForeignState(id, state ? !state.val : true, false, { user }, () =>
this._sendResponse(ws, data.id),
),
);
} else if (data.service === 'volume_set') {
this.log.debug(`volume_set ${id}`);
this.adapter.setForeignState(id, Number(data.service_data.value), false, { user }, () =>
this._sendResponse(ws, data.id),
);
} else if (
data.service === 'trigger' ||
data.service === 'turn_on' ||
data.service === 'unlock' ||
data.service === 'press'
) {
this.log.debug(`${data.service} ${id}`);
this.adapter.setForeignState(id, true, false, { user }, () => this._sendResponse(ws, data.id));
} else if (data.service === 'turn_off' || data.service === 'lock') {
this.log.debug(`${data.service} ${id}`);
this.adapter.setForeignState(id, false, false, { user }, () => this._sendResponse(ws, data.id));
} else if (data.service === 'set_temperature') {
this.log.debug(`set_temperature ${data.service_data.temperature}`);
if (data.service_data.temperature !== undefined) {
if (entity.context.ATTRIBUTES) {
const attr = entity.context.ATTRIBUTES.find(attr => attr.attribute === 'temperature');
if (attr) {
return this.adapter.setForeignState(
attr.setId,
Number(data.service_data.temperature),
false,
{ user },
() => this._sendResponse(ws, data.id),
);
}
}
}
this.log.warn(`Cannot find attribute temperature in ${entity_id}`);
this._sendResponse(ws, data.id);
} else if (data.service === 'set_operation_mode') {
this.log.debug(`set_operation_mode ${data.service_data.operation_mode}`);
//TODO: just sending false here probably is wrong. The call is supported only be Waterheater entity. So... not really used, right now?
this.adapter.setForeignState(id, false, false, { user }, () => this._sendResponse(ws, data.id));
} else if (data.service === 'set_page') {
this.log.debug(`set_page ${JSON.stringify(data.service_data.page)}`);
if (typeof data.service_data.page === 'object') {
this.adapter.setState(
'control.data',
{
val: data.service_data.page.title,
ack: true,
},
() => {
this.adapter.setState('control.command', {
val: 'changedView',
ack: true,
});
},
);
}
} else if (data.service.startsWith('set_') && data.service !== 'set_datetime') {
this.log.debug(`${data.service}: ${id} = ${data.service_data[data.service.substring(4)]}`);
// set_value => service_data.value
// set_operation_mode => service_data.operation_mode
// set_temperature => service_data.temperature
// set_speed => service_data.speed
let val = data.service_data[data.service.substring(4)];
if (!val && ['datetime'].includes(entity.context.type)) {
val = data.service_data.datetime;
}
if (entity.context.STATE.map2iob) {
val = Number(entity.context.STATE.map2iob[val]);
if (!val && val !== 0) {
val = data.service_data[data.service.substring(4)]; //fallback if undefined.
}
}
if (entity.context.stateType === 'number') {
val = Number(val);
}
this.adapter.setForeignState(id, val, false, { user }, () => this._sendResponse(ws, data.id));
} else if (data.service === 'volume_mute') {
this.log.debug(`volume_mute ${id} = ${data.service_data.is_volume_muted}`);
// volume_mute => service_data.is_volume_muted
this.adapter.setForeignState(id, data.service_data.is_volume_muted, false, { user }, () =>
this._sendResponse(ws, data.id),
);
} else if (data.service.startsWith('select_')) {
this.log.debug(`${data.service}: ${id} = ${data.service_data[data.service.substring(7)]}`);
// select_option => service_data.option
// select_source => service_data.source
let val = data.service_data[data.service.substring(7)];
if (entity.context.STATE.map2iob) {
val = Number(entity.context.STATE.map2iob[val]);
if (!val && val !== 0) {
val = data.service_data[data.service.substring(7)]; //fallback if undefined.
}
}
this.adapter.setForeignState(id, val, false, { user }, () => this._sendResponse(ws, data.id));
} else if (data.service.endsWith('_say')) {
this.adapter.setForeignState(id, data.service_data.message, false, { user }, () => {
this._sendResponse(ws, data.id);
});
} else {
this.log.warn(`Unknown service: ${data.service} (${JSON.stringify(data)})`);
//{'id": 21, "type": "result", "success": false, "error": {"code": "not_found", "message": "Service not found."}}
ws.send(
JSON.stringify({
id,
type: 'result',
success: false,
error: { code: 'not_found', message: 'Service not found.' },
}),
);
}
}
/**
* Process service calls. Extracts entity_id from data. Can be an array, too. Also hands the call to modules, if
* they process service calls.
*
* @param ws websocket connection to the frontend
* @param data data of the service call
* @returns {Promise<void>} resolves when done.
*/
async _processCall(ws, data) {
if (!data.service) {
this.log.warn('Invalid service call. Make sure service looks like domain.service_name');
return;
}
if (data.domain === 'system_log' && data.service === 'write') {
this.log.info(`Log from UI ${data.service_data.message}`);
return this._sendResponse(ws, data.id);
}
//do that here, because no entity_id in service call!
let handled = false;
for (const module of Object.values(this._modules)) {
if (typeof module.processServiceCall === 'function') {
handled = (await module.processServiceCall(ws, data)) || handled;
}
}
if (handled) {
return; //already processed.
}
//handle new format and convert to old:
if (data.target && data.target.entity_id) {
data.service_data.entity_id = data.service_data.entity_id || data.target.entity_id;
}
let ids = [data.service_data.entity_id];
if (data.service_data.entity_id instanceof Array) {
ids = data.service_data.entity_id;
}
delete data.service_data.entity_id; //make sure we do not use entity_id array in processSingleCall -> use param entity_id there.
for (const id of ids) {
if (!entityData.entityId2Entity[id]) {
this.log.warn(`Unknown entity: ${id} for service call ${JSON.stringify(data)}`);
} else {
await this._processSingleCall(ws, data, id);
}
}
}
/**
* Read states from iobroker state database and fill entity states / attributes.
* Usually done to read initial values.
*
* @returns {Promise<void>} resolves when done.
*/
async _getAllStates() {
let entity = entityData.entities.find(e => e.state === undefined);
while (entity) {
entity.state = 'unknown';
if (entity.context.STATE && entity.context.STATE.getId) {
try {
const user = this.config.defaultUser;
const state = await this.adapter.getForeignStateAsync(entity.context.STATE.getId, { user });
if (state) {
await this.onStateChange(entity.context.STATE.getId, state);
} else {
entity.state = 'unknown';
entity.last_changed = Date.now() / 1000;
entity.last_updated = entity.last_changed;
}
} catch (e) {
this.adapter.log.error(`Could not get state ${entity.context.STATE.getId}: ${e} - ${e.stack}`);
}
} else if (entity.context.type === 'switch') {
entity.state = 'off';
} else if (entity.context.STATE.getValue !== undefined) {
entity.state = entity.context.STATE.getValue;
} else if (entity.context.type === 'climate') {
entity.state = 'on';
}
//handle attributes:
if (entity.context.ATTRIBUTES) {
const ids = entity.context.ATTRIBUTES.map(entry => entry.getId || '');
try {
const states = await this.adapter.getForeignStatesAsync(ids);
if (ids && ids.length) {
entity.attributes = entity.attributes || {};
ids.forEach((id, i) => {
const attribute = entity.context.ATTRIBUTES[i].attribute;
if (attribute === 'remaining' && entity.context.type === 'timer') {
if (!states[id].val) {
entity.state = 'idle';
} else {
entity.state = 'active';
}
entity.context.lastValue = states[id].val;
} else {
this.onStateChange(id, states[id]);
}
});
}
} catch (e) {
this.adapter.log.error(`Could not update state: ${e} - ${e.stack}`);
}
}
entity = entityData.entities.find(e => e.state === undefined);
}
}
/**
* Handle a state change.
*
* @param id id of the state
* @param state new state object
* @param forceUpdate force entity.state update of all clients
* @returns {Promise<void>} resolves when done.
*/
async onStateChange(id, state, forceUpdate = false) {
if (state) {
if (
id === `${this.adapter.namespace}.control.theme` ||
id === `${this.adapter.namespace}.control.themeDark`
) {
const dark = id.includes('Dark');
if (this._themes[state.val] || state.val === 'default') {
this[dark ? '_currentThemeDark' : '_currentTheme'] = state.val;
this._sendUpdate('themes_updated');
}
} else if (id === `${this.adapter.namespace}.control.darkMode`) {
if (this._darkMode !== state.val) {
this._darkMode = !!state.val;
this._sendUpdate('themes_updated');
}
}
}
const changedStates = {};
this._wss &&
this._wss.clients.forEach(client => {
if (client.__templates && client.readyState === WebSocket.OPEN) {
client.__templates.forEach(t => {
if (t.ids.includes(id)) {
const _state = state || { val: null };
if (
changedStates[id] ||
(this.templateStates[id] && this.templateStates[id].val !== _state.val)
) {
this.templateStates[id] = _state;
changedStates[id] = true;
const event = {
id: t.id,
type: 'event',
event: {
result: bindings.formatBinding(t.template, this.templateStates),
},
};
client.send(JSON.stringify(event));
}
}
});
}
});
const entities = entityData.iobID2entity[id];
if (entities) {
entities.forEach(entity => {
let updated = false;
if (state) {
// {id: 2, type: "event", "event": {"event_type": "state_changed", "data": {"entity_id": "sun.sun", "old_state": {"entity_id": "sun.sun", "state": "above_horizon", "attributes": {"next_dawn": "2019-05-17T02:57:08+00:00", "next_dusk": "2019-05-16T19:44:32+00:00", "next_midnight": "2019-05-16T23:21:40+00:00", "next_noon": "2019-05-17T11:21:38+00:00", "next_rising": "2019-05-17T03:36:58+00:00", "next_setting": "2019-05-16T19:04:54+00:00", "elevation": 54.81, "azimuth": 216.35, "friendly_name": "Sun"}, "last_changed": "2019-05-16T09:09:53.424242+00:00", "last_updated": "2019-05-16T12:46:30.001390+00:00", "context": {id: "05356b1a7df54b2f939d3c7f8a3e05b4", "parent_id": null, "user_id": null}}, "new_state": {"entity_id": "sun.sun", "state": "above_horizon", "attributes": {"next_dawn": "2019-05-17T02:57:08+00:00", "next_dusk": "2019-05-16T19:44:32+00:00", "next_midnight": "2019-05-16T23:21:40+00:00", "next_noon": "2019-05-17T11:21:38+00:00", "next_rising": "2019-05-17T03:36:58+00:00", "next_setting": "2019-05-16T19:04:54+00:00", "elevation": 54.71, "azimuth": 216.72, "friendly_name": "Sun"}, "last_changed": "2019-05-16T09:09:53.424242+00:00", "last_updated": "2019-05-16T12:47:30.000414+00:00", "context": {id: "e738dc26af1d48b4964c6d9805179595", "parent_id": null, "user_id": null}}}, "origin": "LOCAL", "time_fired": "2019-05-16T12:47:30.000414+00:00", "context": {id: "e738dc26af1d48b4964c6d9805179595", "parent_id": null, "user_id": null}}}
if (entity.context.STATE.getId === id) {
updated = true;
utils.updateTimestamps(entity, state);
if (entity.context.STATE.getParser) {
entity.context.STATE.getParser(entity, 'state', state);
} else {
entity.state = iobState2EntityState(entity, state.val);
}
}
//can have identical id for state and attributes.
if (entity.context.ATTRIBUTES) {
const attributes = entity.context.ATTRIBUTES.filter(e => e.getId === id);
for (const attr of attributes) {
updated = true;
//only update if newer than already present time.
utils.updateTimestamps(entity, state);
if (attr.getParser) {
attr.getParser(entity, attr, state);
} else {
utils.setJsonAttribute(
entity.attributes,
attr.attribute,
iobState2EntityState(entity, state.val, attr.attribute),
this.log,
);
}
}
}
}
if (!updated && !forceUpdate) {
return; //nothing happened -> do not notify UI.
}
this.updateEntityInFrontend(entity, state);
});
}
//check modules:
for (const module of Object.values(this._modules)) {
if (typeof module.onStateChange === 'function') {
module.onStateChange(id, state, this._wss);
}
}
}
/**
* Send entity update to all clients / frontends.
*
* @param entity entity that changed.
* @param state ioBroker state, used to get the timestamp.
*/
updateEntityInFrontend(entity, state) {
const t = {
type: 'event',
event: {
a: {},
event_type: 'subscribe_entities',
origin: 'LOCAL',
time_fired: (state ? state.ts : Date.now()) / 1000,
},
};
t.event.a[entity.entity_id] = this._getShortEntity(entity);
this._wss &&
this._wss.clients.forEach(ws => {
if (ws._subscribes && ws._subscribes.subscribe_entities) {
ws._subscribes.subscribe_entities.forEach(id => {
t.id = id;
ws.send(JSON.stringify(t));
});
}
});
}
// ------------------------------- END OF CONVERTERS ---------------------------------------- //
/**
* Generate entities from indicators for a device, found by type detector. Store "deviceId" in context for later use.
*
* @param mainEntity main entity of the device
* @param control control object from type detector
* @param friendlyName friendly name of the device
* @param room room name of the device
* @param func function name of the device
* @param objects all ioBroker objects
* @returns {*[]} array of generated entities
*/
_generateEntitiesFromIndicators(mainEntity, control, friendlyName, room, func, objects) {
const entities = [];
const baseName = mainEntity.entity_id.split('.')[1];
//make battery have sensible entity id and make sure it is different from "host" device:
const battery = converterBinarySensors.processBattery(
control,
friendlyName,
room,
func,
objects,
`binary_sensor.${baseName}_BatteryWarning`,
);
if (battery) {
battery.context.deviceId = mainEntity.context.id;
entities.push(battery);
}
//create binary sensor from online/offline indicator:
const online = converterBinarySensors.connectivityIndicator(
control,
friendlyName,
room,
func,
objects,
`binary_sensor.${baseName}_Connectivity`,
);
if (online) {
online.context.deviceId = mainEntity.context.id;
entities.push(online);
}
//error:
const error = converterBinarySensors.processError(
control,
friendlyName,
room,
func,
objects,
`binary_sensor.${baseName}_Error`,
);
if (error) {
error.context.deviceId = mainEntity.context.id;
entities.push(error);
}