472 lines
18 KiB
JavaScript
472 lines
18 KiB
JavaScript
/* jshint -W097 */
|
|
/* jshint strict: false */
|
|
/* jslint node: true */
|
|
|
|
'use strict';
|
|
|
|
const utils = require('@iobroker/adapter-core');
|
|
const HASS = require('./lib/hass');
|
|
|
|
let connected = false;
|
|
let hass;
|
|
let adapter;
|
|
const hassObjects = {};
|
|
let delayTimeout = null;
|
|
let stopped = false;
|
|
|
|
function startAdapter(options) {
|
|
options = options || {};
|
|
Object.assign(options, { name: 'hass', unload: stop });
|
|
adapter = new utils.Adapter(options);
|
|
|
|
// is called if a subscribed state changes
|
|
adapter.on('stateChange', (id, state) => {
|
|
// you can use the ack flag to detect if it is status (true) or command (false)
|
|
if (state && !state.ack) {
|
|
if (!connected) {
|
|
return adapter.log.warn(`Cannot send command to "${id}", because not connected`);
|
|
}
|
|
/*if (id === adapter.namespace + '.' + '.info.resync') {
|
|
queue.push({command: 'resync'});
|
|
processQueue();
|
|
} else */
|
|
if (hassObjects[id]) {
|
|
if (!hassObjects[id].common.write) {
|
|
adapter.log.warn(`Object ${id} is not writable!`);
|
|
} else {
|
|
const serviceData = {};
|
|
const fields = hassObjects[id].native.fields;
|
|
const target = {};
|
|
|
|
let requestFields = {};
|
|
if (typeof state.val === 'string') {
|
|
state.val = state.val.trim();
|
|
if (state.val.startsWith('{') && state.val.endsWith('}')) {
|
|
try {
|
|
requestFields = JSON.parse(state.val) || {};
|
|
} catch (err) {
|
|
adapter.log.info(`Ignore data for service call ${id} is no valid JSON: ${err.message}`);
|
|
requestFields = {};
|
|
}
|
|
}
|
|
}
|
|
|
|
// If a non-JSON value was set, and we only have one relevant field, use this field as value
|
|
if (fields && Object.keys(requestFields).length === 0) {
|
|
const fieldList = Object.keys(fields);
|
|
if (fieldList.length === 1 && fieldList[0] !== 'entity_id') {
|
|
requestFields[fieldList[0]] = state.val;
|
|
} else if (fieldList.length === 2 && fields.entity_id) {
|
|
requestFields[fieldList[1 - fields.indexOf('entity_id')]] = state.val;
|
|
}
|
|
}
|
|
|
|
adapter.log.debug(
|
|
`Prepare service call for ${id} with (mapped) request parameters ${JSON.stringify(requestFields)} from value: ${JSON.stringify(state.val)}`,
|
|
);
|
|
if (fields) {
|
|
for (const field in fields) {
|
|
if (!Object.prototype.hasOwnProperty.call(fields, field)) {
|
|
continue;
|
|
}
|
|
|
|
if (field === 'entity_id') {
|
|
target.entity_id = hassObjects[id].native.entity_id;
|
|
} else if (requestFields[field] !== undefined) {
|
|
serviceData[field] = requestFields[field];
|
|
}
|
|
}
|
|
}
|
|
const noFields = Object.keys(serviceData).length === 0;
|
|
serviceData.entity_id = hassObjects[id].native.entity_id;
|
|
|
|
adapter.log.debug(
|
|
`Send to HASS for service ${hassObjects[id].native.attr} with ${hassObjects[id].native.domain || hassObjects[id].native.type} and data ${JSON.stringify(serviceData)}`,
|
|
);
|
|
hass.callService(
|
|
hassObjects[id].native.attr,
|
|
hassObjects[id].native.domain || hassObjects[id].native.type,
|
|
serviceData,
|
|
target,
|
|
err => {
|
|
err && adapter.log.error(`Cannot control ${id}: ${err}`);
|
|
if (err && fields && noFields) {
|
|
adapter.log.warn(
|
|
`Please make sure to provide a stringified JSON as value to set relevant fields! Please refer to the Readme for details!`,
|
|
);
|
|
adapter.log.warn(`Allowed field keys are: ${Object.keys(fields).join(', ')}`);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// is called when databases are connected and adapter received configuration.
|
|
// start here!
|
|
adapter.on('ready', main);
|
|
|
|
return adapter;
|
|
}
|
|
|
|
function stop(callback) {
|
|
stopped = true;
|
|
delayTimeout && clearTimeout(delayTimeout);
|
|
hass && hass.close();
|
|
callback && callback();
|
|
}
|
|
|
|
function syncStates(states, cb) {
|
|
if (!states || !states.length) {
|
|
return cb();
|
|
}
|
|
const state = states.shift();
|
|
const id = state.id;
|
|
delete state.id;
|
|
|
|
adapter.setForeignState(id, state, err => {
|
|
if (err) {
|
|
adapter.log.error(err);
|
|
}
|
|
setImmediate(syncStates, states, cb);
|
|
});
|
|
}
|
|
|
|
function syncObjects(objects, cb) {
|
|
if (!objects?.length) {
|
|
return cb();
|
|
}
|
|
const obj = objects.shift();
|
|
hassObjects[obj._id] = obj;
|
|
|
|
adapter.getForeignObject(obj._id, (err, oldObj) => {
|
|
if (err) {
|
|
adapter.log.error(err);
|
|
}
|
|
|
|
if (!oldObj) {
|
|
adapter.log.debug(`Create "${obj._id}": ${JSON.stringify(obj.common)}`);
|
|
hassObjects[obj._id] = obj;
|
|
adapter.setForeignObject(obj._id, obj, err => {
|
|
if (err) {
|
|
adapter.log.error(err);
|
|
}
|
|
setImmediate(() => syncObjects(objects, cb));
|
|
});
|
|
} else {
|
|
hassObjects[obj._id] = oldObj;
|
|
if (JSON.stringify(obj.native) !== JSON.stringify(oldObj.native)) {
|
|
oldObj.native = obj.native;
|
|
|
|
adapter.log.debug(`Update "${obj._id}": ${JSON.stringify(obj.common)}`);
|
|
adapter.setForeignObject(obj._id, oldObj, err => {
|
|
if (err) {
|
|
adapter.log.error(err);
|
|
}
|
|
setImmediate(() => syncObjects(objects, cb));
|
|
});
|
|
} else {
|
|
setImmediate(() => syncObjects(objects, cb));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const knownAttributes = {
|
|
azimuth: { write: false, read: true, unit: '°' },
|
|
elevation: { write: false, read: true, unit: '°' },
|
|
};
|
|
|
|
const mapTypes = {
|
|
string: 'string',
|
|
number: 'number',
|
|
object: 'mixed',
|
|
boolean: 'boolean',
|
|
};
|
|
const skipServices = ['persistent_notification'];
|
|
|
|
function parseStates(entities, services, callback) {
|
|
const objs = [];
|
|
const states = [];
|
|
let obj;
|
|
let channel;
|
|
for (let e = 0; e < entities.length; e++) {
|
|
const entity = entities[e];
|
|
if (!entity) {
|
|
continue;
|
|
}
|
|
|
|
const name =
|
|
entity.name ||
|
|
(entity.attributes && entity.attributes.friendly_name ? entity.attributes.friendly_name : entity.entity_id);
|
|
const desc = entity.attributes && entity.attributes.attribution ? entity.attributes.attribution : undefined;
|
|
|
|
channel = {
|
|
_id: `${adapter.namespace}.entities.${entity.entity_id}`,
|
|
common: {
|
|
name: name,
|
|
},
|
|
type: 'channel',
|
|
native: {
|
|
object_id: entity.object_id,
|
|
entity_id: entity.entity_id,
|
|
},
|
|
};
|
|
if (desc) {
|
|
channel.common.desc = desc;
|
|
}
|
|
objs.push(channel);
|
|
|
|
const lc = entity.last_changed ? new Date(entity.last_changed).getTime() : undefined;
|
|
const ts = entity.last_updated ? new Date(entity.last_updated).getTime() : undefined;
|
|
|
|
if (entity.state !== undefined) {
|
|
obj = {
|
|
_id: `${adapter.namespace}.entities.${entity.entity_id}.state`,
|
|
type: 'state',
|
|
common: {
|
|
name: `${name} STATE`,
|
|
type: typeof entity.state,
|
|
read: true,
|
|
write: false,
|
|
},
|
|
native: {
|
|
object_id: entity.object_id,
|
|
domain: entity.domain,
|
|
entity_id: entity.entity_id,
|
|
},
|
|
};
|
|
if (entity.attributes && entity.attributes.unit_of_measurement) {
|
|
obj.common.unit = entity.attributes.unit_of_measurement;
|
|
}
|
|
adapter.log.debug(
|
|
`Found Entity state ${obj._id}: ${JSON.stringify(obj.common)} / ${JSON.stringify(obj.native)}`,
|
|
);
|
|
objs.push(obj);
|
|
|
|
let val = entity.state;
|
|
if ((typeof val === 'object' && val !== null) || Array.isArray(val)) {
|
|
val = JSON.stringify(val);
|
|
}
|
|
|
|
states.push({ id: obj._id, lc, ts, val, ack: true });
|
|
}
|
|
|
|
if (entity.attributes) {
|
|
for (const attr in entity.attributes) {
|
|
if (Object.prototype.hasOwnProperty.call(entity.attributes, attr)) {
|
|
if (attr === 'friendly_name' || attr === 'unit_of_measurement' || attr === 'icon') {
|
|
continue;
|
|
}
|
|
|
|
let common;
|
|
if (knownAttributes[attr]) {
|
|
common = Object.assign({}, knownAttributes[attr]);
|
|
} else {
|
|
common = {};
|
|
}
|
|
|
|
const attrId = attr.replace(adapter.FORBIDDEN_CHARS, '_').replace(/\.+$/, '_');
|
|
obj = {
|
|
_id: `${adapter.namespace}.entities.${entity.entity_id}.${attrId}`,
|
|
type: 'state',
|
|
common: common,
|
|
native: {
|
|
object_id: entity.object_id,
|
|
domain: entity.domain,
|
|
entity_id: entity.entity_id,
|
|
attr: attr,
|
|
},
|
|
};
|
|
if (!common.name) {
|
|
common.name = `${name} ${attr.replace(/_/g, ' ')}`;
|
|
}
|
|
if (common.read === undefined) {
|
|
common.read = true;
|
|
}
|
|
if (common.write === undefined) {
|
|
common.write = false;
|
|
}
|
|
if (common.type === undefined) {
|
|
common.type = mapTypes[typeof entity.attributes[attr]];
|
|
}
|
|
|
|
adapter.log.debug(
|
|
`Found Entity attribute ${obj._id}: ${JSON.stringify(obj.common)} / ${JSON.stringify(obj.native)}`,
|
|
);
|
|
|
|
objs.push(obj);
|
|
|
|
let val = entity.attributes[attr];
|
|
if ((typeof val === 'object' && val !== null) || Array.isArray(val)) {
|
|
val = JSON.stringify(val);
|
|
}
|
|
|
|
states.push({ id: obj._id, lc, ts, val, ack: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
const serviceType = entity.entity_id.split('.')[0];
|
|
|
|
if (services[serviceType] && !skipServices.includes(serviceType)) {
|
|
const service = services[serviceType];
|
|
for (const s in service) {
|
|
if (Object.prototype.hasOwnProperty.call(service, s)) {
|
|
obj = {
|
|
_id: `${adapter.namespace}.entities.${entity.entity_id}.${s}`,
|
|
type: 'state',
|
|
common: {
|
|
desc: service[s].description,
|
|
read: false,
|
|
write: true,
|
|
type: 'mixed',
|
|
},
|
|
native: {
|
|
object_id: entity.object_id,
|
|
domain: entity.domain,
|
|
fields: service[s].fields,
|
|
entity_id: entity.entity_id,
|
|
attr: s,
|
|
type: serviceType,
|
|
},
|
|
};
|
|
|
|
adapter.log.debug(
|
|
`Found Entity service ${obj._id}: ${JSON.stringify(obj.common)} / ${JSON.stringify(obj.native)}`,
|
|
);
|
|
|
|
objs.push(obj);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
syncObjects(objs, () => syncStates(states, callback));
|
|
}
|
|
|
|
function main() {
|
|
adapter.config.host = adapter.config.host || '127.0.0.1';
|
|
adapter.config.port = parseInt(adapter.config.port, 10) || 8123;
|
|
|
|
adapter.setState('info.connection', false, true);
|
|
|
|
hass = new HASS(adapter.config, adapter.log);
|
|
|
|
hass.on('error', err => adapter.log.error(err));
|
|
|
|
hass.on('state_changed', entity => {
|
|
adapter.log.debug(`HASS-Message: State Changed: ${JSON.stringify(entity)}`);
|
|
if (!entity || typeof entity.entity_id !== 'string') {
|
|
return;
|
|
}
|
|
|
|
const id = `entities.${entity.entity_id}.`;
|
|
const lc = entity.last_changed ? new Date(entity.last_changed).getTime() : undefined;
|
|
const ts = entity.last_updated ? new Date(entity.last_updated).getTime() : undefined;
|
|
if (entity.state !== undefined) {
|
|
if (hassObjects[`${adapter.namespace}.${id}state`]) {
|
|
adapter.setState(`${id}state`, { val: entity.state, ack: true, lc: lc, ts: ts });
|
|
} else {
|
|
adapter.log.info(
|
|
`State changed for unknown object ${`${id}state`}. Please restart the adapter to resync the objects.`,
|
|
);
|
|
}
|
|
}
|
|
if (entity.attributes) {
|
|
for (const attr in entity.attributes) {
|
|
if (
|
|
!Object.prototype.hasOwnProperty.call(entity.attributes, attr) ||
|
|
attr === 'friendly_name' ||
|
|
attr === 'unit_of_measurement' ||
|
|
attr === 'icon' ||
|
|
!attr.length
|
|
) {
|
|
continue;
|
|
}
|
|
let val = entity.attributes[attr];
|
|
if ((typeof val === 'object' && val !== null) || Array.isArray(val)) {
|
|
val = JSON.stringify(val);
|
|
}
|
|
const attrId = attr.replace(adapter.FORBIDDEN_CHARS, '_').replace(/\.+$/, '_');
|
|
if (hassObjects[`${adapter.namespace}.${id}state`]) {
|
|
adapter.setState(id + attrId, { val, ack: true, lc, ts });
|
|
} else {
|
|
adapter.log.info(
|
|
`State changed for unknown object ${id + attrId}. Please restart the adapter to resync the objects.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
hass.on('connected', () => {
|
|
if (!connected) {
|
|
adapter.log.debug('Connected');
|
|
connected = true;
|
|
adapter.setState('info.connection', true, true);
|
|
hass.getConfig(err => {
|
|
if (err) {
|
|
adapter.log.error(`Cannot read config: ${err}`);
|
|
return;
|
|
}
|
|
//adapter.log.debug(JSON.stringify(config));
|
|
delayTimeout = setTimeout(() => {
|
|
delayTimeout = null;
|
|
if (!stopped) {
|
|
hass.getStates((err, states) => {
|
|
if (stopped) {
|
|
return;
|
|
}
|
|
if (err) {
|
|
return adapter.log.error(`Cannot read states: ${err}`);
|
|
}
|
|
//adapter.log.debug(JSON.stringify(states));
|
|
delayTimeout = setTimeout(() => {
|
|
delayTimeout = null;
|
|
if (!stopped) {
|
|
hass.getServices((err, services) => {
|
|
if (stopped) {
|
|
return;
|
|
}
|
|
if (err) {
|
|
adapter.log.error(`Cannot read states: ${err}`);
|
|
} else {
|
|
//adapter.log.debug(JSON.stringify(services));
|
|
parseStates(states, services, () => {
|
|
adapter.log.debug(
|
|
'Initial parsing of states done, subscribe to ioBroker states',
|
|
);
|
|
adapter.subscribeStates('*');
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}, 100);
|
|
});
|
|
}
|
|
}, 100);
|
|
});
|
|
}
|
|
});
|
|
|
|
hass.on('disconnected', () => {
|
|
if (connected) {
|
|
adapter.log.debug('Disconnected');
|
|
connected = false;
|
|
adapter.setState('info.connection', false, true);
|
|
}
|
|
});
|
|
|
|
hass.connect();
|
|
}
|
|
|
|
// If started as allInOne/compact mode => return function to create instance
|
|
if (module && module.parent) {
|
|
module.exports = startAdapter;
|
|
} else {
|
|
// or start the instance directly
|
|
startAdapter();
|
|
}
|