iobroker.lovelace
Version:
With this adapter you can build visualization for ioBroker with Home Assistant Lovelace UI
962 lines (888 loc) • 136 kB
JavaScript
const fs = require('fs');
const crypto = require('crypto');
const WebSocket = require('ws');
const bodyParser = require('body-parser');
const SERVICES = require('./services');
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 utils = require('./converters/utils');
const processBlind = require('./converters/cover').processBlind;
const processSocket = require('./converters/switch').processSocket;
const converterLight = require('./converters/light');
const processBinarySensors = require('./converters/binary_sensor');
const processSensors = require('./converters/sensor');
const processMediaPlayer = require('./converters/media_player').processMediaPlayer;
const processThermostat = require('./converters/climate').processThermostat;
const converterWeather = require('./converters/weather');
const processImage = require('./converters/camera').processImage;
const processLock = require('./converters/lock').processLock;
const processLocation = require('./converters/geo_location').processLocation;
const bindings = require('./bindings');
const TIMEOUT_PASSWORD_ENTER = 180000; // 3 min
const TIMEOUT_AUTH_CODE = 10000; // 10sec
const {Types, ChannelDetector} = require('iobroker.type-detector');
const ignoreIds = [
/^system\./,
/^script\./,
];
const express = require('express');
const ROOT_DIR = '../hass_frontend';
const VERSION = '0.102.1';
const NO_TOKEN = 'no_token';
function getRootPath() {
if (ROOT_DIR.match(/^\w:/) || ROOT_DIR.startsWith('/')) {
return ROOT_DIR + '/';
} else {
return `${__dirname}/${ROOT_DIR}/`;
}
}
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);
});
});
};
function padding(num) {
return num < 10 ? '0' + num : num;
}
/*
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
- alarm_control_panel => STATE disarmed/armed/armed_home/armed_away/armed_night/armed_custom_bypass/pending/arming/disarming/triggered, attributes: [code_format], commands: alarm_arm_away, alarm_arm_home, alarm_arm_night, alarm_arm_custom_bypass, alarm_disarm (code will be sent)
- 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}, {...}]
*/
class WebServer {
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 = 'en';
this.detector = new ChannelDetector();
this.config.ttl = parseInt(this.config.ttl, 10) || 3600;
this.words = options.words || {};
this._notifications = [];
this._subscribed = [];
this._entities = [];
this._entity2ID = {};
this._ID2entity = {};
this._entityIconUrls = []; //stores icon urls that may be accessed without token.
this._server = options.server;
this._app = options.app;
this._auth_flows = {};
this.templateStates = {};
this._processCommon = utils._processCommon.bind(this); //make processCommon operate on our members.
this._addID2entity = utils._addID2entity.bind(this); //make addID2entity operate on our members.
this._themes = {}; //themes storage
this._currentTheme = this.config.defaultTheme || 'default';
this._currentThemeDark = this.config.defaultThemeDark || 'default';
this.converter = {
[Types.socket]: processSocket.bind(this),
[Types.light]: converterLight.processLight.bind(this),
[Types.dimmer]: converterLight.processLightAdvanced.bind(this),
[Types.ct]: converterLight.processLightAdvanced.bind(this),
[Types.hue]: converterLight.processLightAdvanced.bind(this),
[Types.rgb]: converterLight.processLightAdvanced.bind(this),
[Types.rgbSingle]: converterLight.processLightAdvanced.bind(this),
[Types.motion]: processBinarySensors.processMotion.bind(this),
[Types.window]: processBinarySensors.processWindow.bind(this),
[Types.windowTilt]: processSensors.processWindowTilt.bind(this),
[Types.door]: processBinarySensors.processDoor.bind(this),
[Types.button]: processSocket.bind(this),
[Types.temperature]: processSensors.processTemperature.bind(this),
[Types.humidity]: processSensors.processHumidity.bind(this),
[Types.lock]: processLock.bind(this),
[Types.thermostat]: processThermostat.bind(this),
[Types.blind]: processBlind.bind(this),
[Types.blindButtons]: processBlind.bind(this),
[Types.weatherForecast]: converterWeather.processWeather.bind(this),
[Types.accuWeatherForecast]: converterWeather.processAccuWeather.bind(this),
[Types.location]: processLocation.bind(this),
[Types.location_one]: processLocation.bind(this),
[Types.media]: processMediaPlayer.bind(this),
[Types.image]: processImage.bind(this),
};
this.adapter.getForeignObjectAsync('system.config')
.then(config => {
this.lang = config.common.language;
this.systemConfig = config.common;
this._updateConstantEntities();
return this.adapter.getObjectAsync('configuration');
})
.then(config => {
if (config && config.native && config.native.title) {
this._lovelaceConfig = config.native;
this._lovelaceConfig.hideToolbar = this.config.hideHeader;
} else {
this._lovelaceConfig = require('./defaultConfig');
}
return this._readNotifications();
})
.then(() => this._readAllEntities())
.then(() => {
this.adapter.subscribeObjects('configuration');
this.adapter.subscribeStates('control.*');
this.adapter.subscribeStates('notifications.*');
this.adapter.subscribeStates('conversation');
return this._init();
})
.then(() => {
//setup theme selection button:
try {
try {
this._themes = yaml.safeLoad(this.config.themes || '') || {};
} catch (depError) {
if (depError.message.includes('yaml.safeLoad') && depError.message.includes('removed')) {
this._themes = yaml.load(this.config.themes || '') || {};
} else {
throw depError;
}
}
} catch (e) {
this.log.error(`Cannot parse themes: ${e}`);
this._themes = {};
}
const states = {'default': 'default'};
for (const themeName of Object.keys(this._themes)) {
states[themeName] = themeName;
}
return this.adapter.extendObjectAsync(this.adapter.namespace + '.control.theme', {common: {states}})
.then(() => this.adapter.extendObjectAsync(this.adapter.namespace + '.control.themeDark', { common: {states}}));
})
.then(() => this.adapter.getStateAsync(this.adapter.namespace + '.control.theme'))
.then(state => {
//remember currently selected theme, if valid. Select default otherwise.
if (state && (this._themes[state.val] || state.val === 'default')) {
this._currentTheme = state.val;
} else {
this._currentTheme = this.config.defaultTheme || 'default';
return this.adapter.setStateAsync(this.adapter.namespace + '.control.theme', this._currentTheme, true);
}
})
.then(() => this.adapter.getStateAsync(this.adapter.namespace + '.control.themeDark'))
.then(state => {
//remember currently selected theme, if valid. Select default otherwise.
if (state && (this._themes[state.val] || state.val === 'default')) {
this._currentThemeDark = state.val;
} else {
this._currentThemeDark = this.config.defaultThemeDark || 'default';
this.adapter.setStateAsync(this.adapter.namespace + '.control.themeDark', this._currentThemeDark, true);
}
})
.then(() => {
// check every minute
if (this.config.auth !== false) {
this._clearInterval = setInterval(() => this.clearAuth(), 60000);
}
});
}
async _readAllEntities() {
const smartDevices = await this._updateDevices();
for (const dev of smartDevices) {
const foundIndex = this._entities.findIndex(x => x.entity_id === dev.entity_id);
if (foundIndex !== -1) {
this._entities[foundIndex] = dev;
} else {
this._entities.push(dev);
}
}
await this._getAllEntities(); //creates manual entities.
//now all entities are created. Check for icon urls:
for (const entity of this._entities) {
if (entity.attributes.entity_picture && !entity.attributes.entity_picture.match(/^data:image\//)) {
const url = entity.attributes.entity_picture.replace(/^\./, '');
if (!this._entityIconUrls.includes(url)) {
this._entityIconUrls.push(url);
}
}
}
await this._getAllStates();
this._manageSubscribesFromConfig();
await this.adapter.setStateAsync('info.entitiesUpdated', true, true);
}
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();
}
_setJsonAttribute(obj, parts, index, value) {
if (parts.length - 1 === index) {
obj[parts[index]] = value;
} else {
// if a number
if (typeof obj[parts[index]] !== 'object') {
if (parts.length - 2 >= index && parts[index + 1] >= '0' && parts[index + 1] <= '9') {
obj[parts[index]] = [];
} else {
obj[parts[index]] = {};
}
}
this._setJsonAttribute(obj[parts[index]], parts, index + 1, value);
}
}
setJsonAttribute(obj, path, value) {
if (!path) {
this.log.error('Invalid attribute name for ' + JSON.stringify(obj) + ' = ' + value);
return;
}
const parts = path.split('.');
if (parts.length === 1) {
obj[path] = value;
} else {
this._setJsonAttribute(obj, parts, 0, value);
}
}
async _getAllEntities() {
try {
const doc = await this.adapter.getObjectViewAsync('custom', 'state', {});
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');
await this._processEntities(ids, this._entities);
} catch (e) {
this.adapter.log.error(`Could not get object view for getAllEntities: ${e.toString()} - ${e.stack}`);
}
}
_getSmartName(states, id) {
if (!id) {
if (!this.adapter.config.noCommon) {
return states.common.smartName;
} else {
return (states &&
states.common &&
states.common.custom &&
states.common.custom[this.adapter.namespace]) ?
states.common.custom[this.adapter.namespace].smartName : undefined;
}
} else
if (!this.adapter.config.noCommon) {
return states[id] && states[id].common ? states[id].common.smartName : null;
} else {
return (states[id] &&
states[id].common &&
states[id].common.custom &&
states[id].common.custom[this.adapter.namespace]) ?
states[id].common.custom[this.adapter.namespace].smartName || null : null;
}
}
// ------------------------------- START OF CONVERTERS ---------------------------------------- //
_iobState2EntityState(id, val, type) {
type = type || this._ID2entity[id][0].context.type;
const pos = type.lastIndexOf('.');
if (pos !== -1) {
type = type.substring(pos + 1);
}
if (type === 'light' || type === 'switch' || type==='input_boolean') {
return val ? 'on' : 'off';
} else
if (type === 'binary_sensor') {
return val ? 'on' : 'off';
} else
if (type === 'lock') {
return val ? 'unlocked' : 'locked';
} else
if (type === 'alarm_control_panel') {
return val ? 'armed' : 'disarmed';
} else {
return val === null || val === undefined ? 'unknown' : val;
}
}
// Process manually created entity
async _processManualEntity(id) {
try {
const obj = await this.adapter.getForeignObjectAsync(id);
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';
}
const entityType = obj.common.custom[this.adapter.namespace].entity;
const idPart = obj.common.custom[this.adapter.namespace].name;
const entity_id = idPart && typeof idPart === 'string' ? entityType + '.' + idPart : null;
const entity = this._processCommon(null, null, null, obj, entityType, entity_id);
entity.context.STATE = {getId: id, setId: id};
entity.isManual = true;
if (entityType === 'light') {
if (obj.common.type === 'number') {
entity.attributes.supported_features = 1;
let iobMaxValue = 100;
if (obj.common.max) {
iobMaxValue = obj.common.max;
} else {
this.adapter.log.warn(`no max value for light object '${id}' defined -> using fallback max = 100`);
}
entity.attributes.iob_max = iobMaxValue;
const state = await this.adapter.getForeignStateAsync(id);
entity.attributes.brightness = state ? state.val || 0 : 0;
entity.context.ATTRIBUTES = [{attribute: 'brightness', getId: id}];
entity.context.COMMANDS = [{
service: 'turn_on',
setId: id,
on: iobMaxValue,
parseCommand: (entity, command, data, user) => {
let val = command.on;
if (data.service_data.brightness >= 0) {
entity.attributes.brightness = data.service_data.brightness;
entity.attributes.brightness_pct = data.service_data.brightness / 255;
val = data.service_data.brightness / 255 * iobMaxValue;
}
if (data.service_data.brightness_pct >= 0) {
entity.attributes.brightness = (data.service_data.brightness_pct / 100) * 255;
entity.attributes.brightness_pct = data.service_data.brightness_pct;
val = data.service_data.brightness_pct / 100 * iobMaxValue;
}
return this.adapter.setForeignStateAsync(command.setId, val, false, {user});
}
}, {
service: 'turn_off',
setId: id,
off: obj.common.min || 0,
parseCommand: (entity, command, data, user) => {
return this.adapter.setForeignStateAsync(command.setId, command.off, false, {user});
}
}];
}
} 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', (Math.random() * 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') {
// - alarm_control_panel => STATE disarmed/armed/armed_home/armed_away/armed_night/armed_custom_bypass/pending/arming/disarming/triggered, attributes: [code_format], commands: alarm_arm_away, alarm_arm_home, alarm_arm_night, alarm_arm_custom_bypass, alarm_disarm (code will be sent)
// we support only armed/disarmed
entity.attributes.code_format = obj.common.custom[this.adapter.namespace].code_format || 'number';
// the code must be in the object in native as alarm_code
} 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 = 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 = this._iobState2EntityState(null, state.val, entityType);
}
else if (entityType === 'input_select') {
if (obj.common.states ) {
if (typeof obj.common.states === 'string') {
this.log.warn(obj._id + ': states is of type string. Problems might occur. Please fix states to be of type object.');
entity.context.STATE.map = {};
for (const kv of obj.common.states.split(';')) {
const [key, value] = kv.split(':');
entity.context.STATE.map[key] = value;
}
} else {
entity.context.STATE.map = obj.common.states ;
}
entity.attributes.options = Object.values(entity.context.STATE.map);
}
const state = await this.adapter.getForeignStateAsync(id);
if (state) {
if (entity.context.STATE.map && entity.context.STATE.map[state.val]) {
entity.attributes.initial = entity.context.STATE.map[state.val];
}
}
}
else if (entityType === 'switch') {
entity.context.STATE.setId = id;
entity.context.STATE.getId = id;
entity.attributes.icon = 'mdi:light-switch';
entity.attributes.assumed_state = true;
} 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')}`;
}
}
}];
}
this.log.debug(`Create manual device: ${entity.entity_id} - ${id}`);
this._addID2entity(id, entity);
return [entity];
} catch (e) {
this.adapter.log.error(`Could not process manual entity ${id}: ${e.toString()} - ${e.stack}`);
}
}
async _processSingleCall(ws, data, entity_id) {
const user = await this._getUserId(ws.__auth.username || this.config.defaultUser);
const entity = this._entity2ID[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}));
}
}
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, data.service_data.value, false, {user}, () =>
this._sendResponse(ws, data.id));
} else
if (data.service === 'trigger' || data.service === 'turn_on' || data.service === 'unlock') {
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, 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}`);
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.instance', {
val: self._instance,
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
this.adapter.setForeignState(id, data.service_data[data.service.substring(4)], 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_option')) {
this.log.debug(`${data.service}: ${id} = ${data.service_data[data.service.substring(7)]}`);
//get list of options;
this.adapter.getForeignObject(id, (err, obj) => {
if (obj.common.states) {
const valToState = Object.keys(obj.common.states).find(key => obj.common.states[key] === data.service_data.option);
this.adapter.setForeignState(id, valToState, false, {user}, () =>
this._sendResponse(ws, data.id));
}
});
//this.adapter.setForeignState(id, data.service_data[data.service.substring(7)], 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
this.adapter.setForeignState(id, data.service_data[data.service.substring(7)], false, {user}, () =>
this._sendResponse(ws, data.id));
} else
if (data.service.startsWith('alarm_')) {
// alarm_arm_away, alarm_arm_home, alarm_arm_night, alarm_arm_custom_bypass, alarm_disarm (code will be sent)
this.log.debug(data.service + ': ' + id + ' = ' + data.service_data.code ? 'XXX' : 'none');
this.adapter.getForeignObject(id, (err, obj) => {
if (obj.native.alarm_code && obj.native.alarm_code.toString() !== data.service_data.code.toString()) {
this._sendResponse(ws, data.id, {result: false, error: 'invalid code'});
} else {
this.log.warn(`No code is defined! To provide the code add to object ${id} native.alarm_code with desired code`);
this.adapter.setForeignState(id, data.service.startsWith('alarm_arm'), 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)})`);
// close_cover, open_cover
// set_datetime => service_data.date, service_data.time
//{'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.'}}));
}
}
async _processCall(ws, data) {
if (!data.service) {
this.log.warn('Invalid service call. Make sure service looks like domain.service_name');
return;
}
if (data.service === 'dismiss') {
this.log.debug('dismiss ' + data.service_data.notification_id);
return this._clearNotification(data.service_data.notification_id).then(() =>
this._sendResponse(ws, data.id));
}
if(data.domain === 'system_log' && data.service === 'write') {
this.log.info('Log from UI ' + data.service_data.message);
return this._sendResponse(ws, data.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 (!this._entity2ID[id]) {
this.log.warn(`Unknown entity: ${id} for service call ${JSON.stringify(data)}`);
} else {
await this._processSingleCall(ws, data, id);
}
}
}
async _getAllStates() {
let entity = this._entities.find(e => e.state === undefined);
while (entity) {
entity.state = 'unknown';
if (entity.context.STATE && entity.context.STATE.getId) {
try {
const user = await this._getUserId(this.config.defaultUser); //TODO: why is this always defaultUser?
const state = await this.adapter.getForeignStateAsync(entity.context.STATE.getId, {user});
if (state) {
this.onStateChange(entity.context.STATE.getId, state);
} else {
entity.state = 'unknown';
try {
entity.last_changed = new Date().toISOString();
} catch (e) {
this.adapter.log.warn(`Invalid last changed for ${entity.context.STATE.getId}`);
}
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 = this._entities.find(e => e.state === undefined);
}
}
async onStateChange(id, state, forceUpdate = false) {
if (state) {
if (id === this.adapter.namespace + '.control.shopping_list') {
return this._sendUpdate('shopping_list_updated');
} else if (id === this.adapter.namespace + '.notifications.list') {
if (!state.ack) {
await this._readNotifications();
}
return this._sendUpdate('persistent_notifications_updated');
} else if (id === this.adapter.namespace + '.notifications.add') {
return !state.ack &&
this.addNotification(state.val).then(() =>
this._sendUpdate('persistent_notifications_updated'));
} else if (id === this.adapter.namespace + '.notifications.clear') {
return !state.ack &&
this._clearNotification(state.val).then(() =>
this._sendUpdate('persistent_notifications_updated'));
} else 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 + '.conversation') {
if (state.ack) {
// send answer to conversation dialog
this._wss && this._wss.clients.forEach(client => {
if (client.__conversations && client.readyState === WebSocket.OPEN) {
Object.keys(client.__conversations).forEach(conversation_id => {
client.__conversations[conversation_id].timer && clearTimeout(client.__conversations[conversation_id].timer);
const answer = {
id: client.__conversations[conversation_id].id,
type: 'result',
success: true,
result: {
speech: {
plain: {
speech: state.val,
extra_data: null
}
},
card: {}
}
};
client.send(JSON.stringify(answer));
});
}
});
}
}
}
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 = this._ID2entity[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;
try {
entity.last_changed = new Date(state.lc).toISOString();
} catch (e) {
this.adapter.log.warn(`Invalid last changed for ${entity.context.STATE.getId}`);
}
try {
entity.last_updated = new Date(state.ts).toISOString();
} catch (e) {
this.adapter.log.warn(`Invalid timestamp for ${entity.context.STATE.getId}`);
}
if (entity.context.STATE.getParser) {
entity.context.STATE.getParser(entity, 'state', state);
} else {
if (entity.context.type === 'light' && typeof (state.val) === 'number') {
// is dimmer
entity.attributes.brightness = state.val * 255 / entity.attributes.iob_max;
entity.state = state.val > 0 ? 'on' : 'off';
} else if (entity.context.type === 'input_select') {
if (entity.context.STATE.map) {
entity.attributes.initial = this._iobState2EntityState(id, entity.context.STATE.map[state.val]);
entity.state = this._iobState2EntityState(id, entity.context.STATE.map[state.val]);
}
} else {
entity.state = this._iobState2EntityState(id, 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;
try {
entity.last_changed = new Date(state.lc).toISOString();
} catch (e) {
this.adapter.log.warn(`Invalid last changed for ${attr.getId}`);
}
try {
entity.last_updated = new Date(state.ts).toISOString();
} catch (e) {
this.adapter.log.warn(`Invalid timestamp for ${attr.getId}`);
}
if (attr.getParser) {
attr.getParser(entity, attr, state);
} else {
if (entity.context.type === 'light' && typeof(state.val) === 'number'){
// is dimmer
this.setJsonAttribute(entity.attributes, attr.attribute, this._iobState2EntityState(null, state.val * 255 / entity.attributes.iob_max, attr.attribute), this.log);
} else {
this.setJsonAttribute(entity.attributes, attr.attribute, this._iobState2EntityState(null, state.val, attr.attribute), this.log);
}
}
}
}
}
if (!updated && !forceUpdate) {
return; //nothing happened -> do not notify UI.
}
let time_fired;
try {
time_fired = new Date(state ? state.ts : undefined).toISOString();
} catch (e) {
time_fired = new Date().toISOString();
}
const t = {
type: 'event',
event: {
event_type: 'state_changed',
data: {
entity_id: entity.entity_id,
new_state: entity
},
origin: 'LOCAL',
time_fired
}
};
const foundIndex = this._entities.findIndex(x => x.entity_id === entity.entity_id);
if (foundIndex !== -1) {
th