iobroker.nuki-extended
Version:
Nuki Smart Lock 2.0 / 3.0
714 lines (626 loc) • 20.8 kB
JavaScript
'use strict';
const _LOCK = require("../_LOCK.js");
const _OPENER = require("../_OPENER.js");
/**
* Library
* !!Extended with two own functions (readData, convertNode)!!
* @description Library of general functions as well as helping functions handling ioBroker
* @author Zefau <https://github.com/Zefau/>
* @license MIT License
* @version 0.28.0
* @date 2020-02-09
*
*/
class Library
{
static get CONNECTION() {
return { node: 'info.connection', description: 'Adapter Connection Status', role: 'indicator.connected', type: 'boolean' };
}
/**
* Constructor.
*
* @param {object} adapter ioBroker adpater object
*
*/
constructor(adapter, options)
{
this._adapter = adapter;
this.options = options || {};
this._nodes = this.options.nodes || {};
this.options.updatesInLog = this.options.updatesInLog || false;
this.options.updatesExceptions = this.options.updatesExceptions || ['timestamp', 'datetime', 'UTC', 'localtime', 'last_use_date', 'lastSeen'];
this._STATES = {};
this.subscriptions = [];
this.set({ 'node': 'info', 'description': 'Adapter Information', 'role': 'channel' });
this.set(Library.CONNECTION, false);
}
/**
* Gets a node.
*
* @param {string} node Node identifier
* @return {object} Node
*
*/
getNode(node, lowerCase)
{
return JSON.parse(JSON.stringify(
this._nodes[this.clean(node, lowerCase)] ||
this._nodes[this.clean(node.replace(RegExp(/\.\d+\./, 'g'), '.'), lowerCase)] ||
{ 'description': '(no description given)', 'role': 'state', 'type': 'string', 'convert': null }
));
}
/**
* Terminate adapter.
*
* @param {string} [message=Terminating adapter due to error!] Message to display
* @param {boolean} [kill=false] Whether to kill the adapter (red lights) or not (yellow lights)
* @param {integer} [reason=11] Reason code for exit
* @return void
*
*/
terminate(message, kill, reason)
{
this.resetStates();
this.set(Library.CONNECTION, false);
message = message ? message : 'Terminating adapter due to error!';
// yellow lights
if (!kill)
this._adapter.log.warn(message);
// red lights
else if (kill === true)
{
this._adapter.log.error(message);
// delay necessary to actually show the error message
setTimeout(() => this._adapter && this._adapter.terminate ? this._adapter.terminate(message, reason || 11) : process.exit(reason || 11), 2000);
}
return false;
}
/**
* Remove specials characters from string.
*
* @param {string} string String to proceed
* @param {boolean} [lowerCase=false] If String shall be return in lower case
* @param {string} [replacement=''] Characters shall be replaced with this character
* @param {array} [characters=['<', '>', ' ', ',', ';', '!', '?', '[', ']', '*', '\'', '"', '\\', '&', '^', '$', '(', ')', '/']] Characters to be removed from string
* @return {string} Cleaned String
*
*/
clean(string, lowerCase, replacement, characters)
{
if (!string && typeof string != 'string')
return string;
characters = characters ? characters : ['<', '>', ' ', ',', ';', '!', '?', '[', ']', '*', '\'', '"', '\\', '&', '^', '$', '(', ')', '/'];
characters.forEach(character =>
{
string = string.replace(RegExp('\\'+character, 'gi'), replacement ? replacement : '')
});
return lowerCase ? string.toLowerCase() : string;
}
/**
* Waits for a specific time before invoking a callback.
*
* @param {number} time Time to wait before invoking the callback
* @param {function} callback Callback to be invoked
* @return void
*
*/
wait(time, callback)
{
setTimeout(() => callback, time);
}
/**
* Encode a string.
*
* @param {string} key Key used for encoding
* @param {string} string String to encode
* @return {string} Encoded String
*
*/
encode(key, string)
{
let result = '';
for (let i = 0; i < string.length; i++)
result += String.fromCharCode(key[i % key.length].charCodeAt(0) ^ string.charCodeAt(i));
return result;
}
/**
* Decode a string.
*
* @param {string} key Key used for decoding
* @param {string} string String to decode
* @return {string} Decoded String
*
*/
decode(key, string)
{
return this.encode(key, string);
}
/**
* Get a random key.
*
* @param {integer} length Length of key
* @return {string} Key
*
*/
getKey(length)
{
length = length || 8;
let key = '';
while (key.length < length)
key += Math.random().toString().substring(2,3) >= 5 ? Math.random().toString(36).substring(2, 4) : Math.random().toString(36).substring(2, 4).toUpperCase();
return key.substr(0, length);
}
/**
* Convert an integer to IP.
*
* @param {integer} number Number to be converted to IP address
* @return {string} Converted IP address
*
*/
getIP(number)
{
let ip = [];
ip.push(number & 255);
ip.push((number >> 8) & 255);
ip.push((number >> 16) & 255);
ip.push((number >> 24) & 255);
ip.reverse();
return ip.join('.');
}
/**
* Sends a message to another adapter.
*
* @param {string} receiver
* @param {string} command
* @param {*} message Message to send to receiver, shall be an object and will be converted to such if another is given
* @param {function} (optional) Callback
* @return void
*
*/
msg(receiver, command, message, callback)
{
this._adapter.sendTo(
receiver,
command,
typeof message !== 'object' ? {message: message} : message,
callback === undefined ? function() {} : callback
);
}
/**
* Capitalize first letter of a string
*
* @param {string} str String to capitalize
* @return {string}
*
*/
ucFirst(str)
{
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Convert a date to timestamp.
*
* @param {date} date Datetime to parse
* @return {integer} parsed Timestamp
*
*/
getTimestamp(date)
{
if (date === undefined || !date)
return 0;
let ts = new Date(date).getTime();
return isNaN(ts) ? 0 : ts;
}
/**
* Convert a timestamp to datetime.
*
* @param {integer} ts Timestamp to be converted to date-time format (in ms)
* @return {string} Timestamp in date-time format
*
*/
getDateTime(ts)
{
if (ts === undefined || ts <= 0 || ts == '')
return '';
let date = new Date(ts);
let day = '0' + date.getDate();
let month = '0' + (date.getMonth() + 1);
let year = date.getFullYear();
let hours = '0' + date.getHours();
let minutes = '0' + date.getMinutes();
let seconds = '0' + date.getSeconds();
return day.substr(-2) + '.' + month.substr(-2) + '.' + year + ' ' + hours.substr(-2) + ':' + minutes.substr(-2) + ':' + seconds.substr(-2);
}
/**
* Get all instances of an adapter.
*
* @param {string} adapter Adapter to get instances of
* @param {function} callback Callback to invoke
* @return void
*
*/
getAdapterInstances(adapter, callback)
{
this._adapter.objects.getObjectView('system', 'instance', { 'startkey': 'system.adapter.' + adapter + '.', 'endkey': 'system.adapter.' + adapter + '.\u9999'}, (err, instances) =>
{
if (instances && instances.rows)
{
let result = [];
instances.rows.forEach(instance => result.push({ 'id': instance.id.replace('system.adapter.', ''), 'config': instance.value.native.type }));
callback(null, result);
}
else
callback('Could not retrieve ' + adapter + ' instances!');
});
}
/**
* Run Garbage Collector and delete outdated states / objects.
*
* @param {object} state Selected state
* @param {number} [ts=null] Objects older than this timespan will be deleted (in seconds)
* @param {boolean} [del=true] Whether to delete the object (or only empty its value)
* @param {number} [offset=60] Offset for the timespan in seconds
* @return void
*
*/
runGarbageCollector(state, del = false, offset = 60, whitelist = [])
{
this._adapter.log.debug('Running Garbage Collector for ' + state + '...');
this._adapter.getStates(state + '.*', (err, states) =>
{
if (err || !states) return;
// loop through states
let key;
for (let state in states)
{
key = state.replace(this._adapter.name + '.' + this._adapter.instance + '.', '');
if (this._STATES[key] && this._STATES[key].ts < (Math.round(Date.now()/1000)-offset) && !(whitelist.length > 0 && RegExp(whitelist.join('|')).test(state)))
{
// apply deletion
this._adapter.log.debug('Garbage Collector: ' + (del ? 'Deleted ' : 'Emptied ') + state + '!');
if (del)
{
this._STATES[key] = undefined;
this._adapter.delObjectAsync(state);
}
else
this._setValue(key, '', { 'force': true });
}
}
});
}
/**
* Get a device state.
*
*
*/
getDeviceState(state, property = 'val')
{
return this._STATES[state] !== undefined ? (this._STATES[state][property] || false) : null;
}
/**
* Set a device state.
*
*
*/
setDeviceState(state, value)
{
if ((this._STATES[state] === null || this._STATES[state] === undefined || this._STATES[state].val != value) &&
this._adapter && this._adapter.log && (
(this.options.updatesInLog && !this.options.updatesExceptions) ||
(this.options.updatesInLog && this.options.updatesExceptions && Array.isArray(this.options.updatesExceptions) && this.options.updatesExceptions.indexOf(state.substr(state.lastIndexOf('.')+1)) == -1)
))
this._adapter.log.debug('Updated state ' + state + ' to value ' + value + ' (from ' + (this._STATES[state] && this._STATES[state].val) + ').');
return this.setDeviceProperties(state, {
'val': value
});
}
/**
* Set a device properties.
*
*
*/
setDeviceProperties(state, properties)
{
this._STATES[state] = { ...this._STATES[state] || {}, ...properties || {}, 'ts': Math.round(Date.now()/1000) };
return true;
}
/**
* Deletes a state / object.
*
* @param {string} state State to be deleted
* @param {boolean} [nested=false] Whether to delete nested states as well
* @param {function} [callback] Callback to be invoked once finished deleting all states
* @return void
*
*/
del(state, nested, callback)
{
let that = this;
// create state to have at least one deletion (in case no states exist at all)
this._createNode({ 'node': state, 'description': 'DELETED' }, () =>
{
// get state tree
that._adapter.getStates(nested ? state + '.*' : state, (err, objects) =>
{
let deleted = 0;
objects = Object.keys(objects);
objects.push(state);
that._adapter.log.silly('Found ' + objects.length + ' objects in state ' + state + ' to delete..');
objects.forEach(object =>
{
that._adapter.delObject(object, err =>
{
that._STATES[object.replace(that._adapter.namespace + '.', '')] = undefined;
deleted++;
if (deleted == objects.length)
{
that._adapter.log.debug('Deleted state ' + state + ' with ' + deleted + ' objects.');
callback && callback();
}
});
});
});
});
}
/**
* Set multiple values and create the necessary nodes for it in case they are missing.
*
* @param {object} values
* @param {object} nodes
* @param {object} options
* @return void
*
*/
setMultiple(nodes, values, options = {})
{
for (let key in values)
{
if (nodes[key] && nodes[key].node && nodes[key].description)
{
let node = nodes[key];
let value = values[key];
// replace options if given
options.placeholders = options.placeholders || {};
for (let placeholder in options.placeholders)
{
node.node = node.node.replace(placeholder, options.placeholders[placeholder]);
node.description = node.description.replace(placeholder, options.placeholders[placeholder]);
}
// convert data if necessary
switch(node.convert)
{
case "string":
if (value && Array.isArray(value))
value = value.join(', ');
break;
case "datetime":
this.set({node: node.node + 'Datetime', description: node.description.replace('Timestamp', 'Date-Time'), common: {"type": "string", "role": "text"}}, value ? this.getDateTime(value * 1000) : '');
break;
}
// set node
this.set(node, value, options);
}
}
}
/**
* Set a value and create the necessary nodes for it in case it is missing.
*
* @param {object} node
* @param {string} node.node Node (= state) to set the value (and create in case it does not exist)
* @param {string} node.description Description of the node (in case it will be created)
* @param {object} node.common Common Details of the node (in case it will be created)
* @param {string} node.common.role Role of the node (in case it will be created)
* @param {string} node.common.type Type of the node (in case it will be created)
* @param {object} node.native Native Details of the node (in case it will be created)
* @param {string} value Value to set (in any case)
* @param {object} [options={}] Additional options
* @return void
*
*/
set(node, value, options = {})
{
// set value
if (node && node.node && this._STATES[node.node] !== undefined) {
this._setValue(node.node, value, options);
if (options.subscribe === true && this.subscriptions.indexOf(node.node) === -1) {
this._adapter.subscribeStates(node.node);
this.subscriptions.push(node.node);
}
}
// create node
else {
// catch error
if (!node || !node.node || (node.name === undefined && node.description === undefined)) {
this._adapter.log.error('Error: State not properly defined (' + JSON.stringify(node) + ')!');
return false;
}
this._createNode(node, () => this._setValue(node.node, value, options), options);
}
return true;
}
/**
* Creates an object (channel or state).
*
* @param {object} node
* @param {string} node.node Node (= state) to set the value (and create in case it does not exist)
* @param {string} node.description Description of the node (in case it will be created)
* @param {object} node.common Common Details of the node (in case it will be created)
* @param {string} node.common.role Role of the node (in case it will be created)
* @param {string} node.common.type Type of the node (in case it will be created)
* @param {object} node.native Native Details of the node (in case it will be created)
* @param {function} callback Callback function to be invoked
* @param {object} [options={}] Additional options
* @return void
*
*/
_createNode(node, callback, options = {})
{
if (!this._adapter)
return Promise.reject('Adapter not defined!');
// remap properties to common
let type = node.role == 'device' || node.role == 'channel' ? (node.role == 'device' ? 'device' : 'channel') : 'state';
let common = {
'name': node.name || node.description,
'role': node.common && node.common.role || node.role || 'state',
'type': node.common && node.common.type || node.type || 'string',
'write': false,
...node.common || {}
};
// special roles
if (common.role.indexOf('button') > -1 || common.role.indexOf('indicator') > -1) {
if (!common.type) {
common = { ...common, 'type': 'boolean'};
}
common = { ...common, 'read': false, 'write': true };
}
else if (common.role == 'device' || common.role == 'channel') {
common = { ...common, 'type': undefined, 'role': undefined };
}
// create object
this._adapter.setObjectNotExists(
node.node,
{
'common': common,
'type': type,
'native': node.native || {}
},
(err, obj) =>
{
this._STATES[node.node] = null;
if (options.subscribe === true && this.subscriptions.indexOf(node.node) === -1) {
this._adapter.subscribeStates(node.node);
this.subscriptions.push(node.node);
}
callback && callback();
}
);
}
/**
* Sets a value of a state.
*
* @param {string} state State the value shall be set
* @param {string} value Value to be set
* @param {object} [options={}] Additional options
* @param {boolean} [options.force=false] Force to set value
* @return void
*
*/
_setValue(state, value, options = {})
{
if (state !== undefined)
{
if (value !== undefined && (options.force || this._STATES[state] === undefined || this._STATES[state] === null || this._STATES[state].val != value))
{
this.setDeviceState(state, value);
this._adapter.setStateAsync(state, { val: value, ts: Date.now(), ack: true });
}
else
this.setDeviceProperties(state);
}
}
/**
* Reset all states.
*
* @param void
* @return void
*
*/
resetStates()
{
this._STATES = {};
}
/**
* Read given data and set states.
*
*/
readData(key, data, prefix) {
// only proceed if data is given
if (data === undefined || data === 'undefined') {
return false;
}
// get node details
key = key ? this.clean(key, false, '_') : '';
let node = this.getNode(prefix && prefix.indexOf('users.') > -1 ? 'users.' + key : key);
// add node details
if (key.indexOf('.state') > -1) {
node = Object.assign({}, node, {'common': {'states': prefix.indexOf('opener') === -1 ? _LOCK.STATES : _OPENER.STATES}});
}
if (key.indexOf('.lastAction') > -1) {
node = Object.assign({}, node, {'common': {'states': prefix.indexOf('opener') === -1 ? _LOCK.ACTIONS : _OPENER.ACTIONS}});
}
// loop nested data
if (data !== null && typeof data === 'object' && (!node.convert && node.convert !== 'array')) {
if (Object.keys(data).length > 0) {
// create channel
if (node.role === 'channel') {
this.set({
node: prefix + '.' + (node.state || key),
role: 'channel',
description: node.description || library.ucFirst(key.substr(key.lastIndexOf('.') + 1))
});
}
// read nested data
for (let nestedKey in data) {
this.readData((key ? key + '.' : '') + nestedKey, data[nestedKey], prefix);
}
}
}
// write to states
else {
// convert data
data = this.convertNode(node, data, prefix);
// skip
if (node.skip) {
return;
}
// create channel if node.state is nested
if (node.state && node.state.indexOf('.') > -1 && (prefix + '.' + node.state.substr(0, node.state.lastIndexOf('.'))) !== (prefix + '.' + key.substr(0, key.lastIndexOf('.')))) {
this.readData(node.state.substr(0, node.state.indexOf('.')), {[node.state.substr(node.state.indexOf('.') + 1)]: data}, prefix);
}
// state
let state = JSON.parse(JSON.stringify(node)); // copy node
state.node = prefix + '.' + (node.state || key);
state.description = node.description || library.ucFirst(key.substr(key.lastIndexOf('.') + 1));
// config state
let config = (state.node.toLowerCase().indexOf('config') > -1);
// set state
state.common = {...node.common || {}, 'write': config};
this.set(state, data, {subscribe: config});
}
}
/**
* Convert.
*
*/
convertNode(node, data, prefix) {
// flatten Array
if (Array.isArray(data)) {
data = data.join(',');
}
const dataType = typeof data;
if (node && node.description === '(no description given)' && node.type === 'string' && !node.convert && dataType !== 'string') {
// it seems like a fallback object definition ... maybe we can fix the type
if (dataType === 'boolean' || dataType === 'number') {
node.type = dataType;
}
}
if (node.type === 'number' && dataType === 'string') {
if (data.includes('.')) {
data = parseFloat(data);
} else {
data = parseInt(data);
}
}
// type is boolean, but states are given
if (node.type === 'boolean' && node.common && node.common.states) {
data = node.common.states[data] === 'true';
}
// type is boolean, but number given
if (node.type === 'boolean' && Number.isInteger(data) && !(node.common && node.common.states)) {
data = data === 1;
}
return data;
}
}
module.exports = Library;