iobroker.nuki-extended
Version:
790 lines (683 loc) • 30.5 kB
JavaScript
;
const adapterName = require('./io-package.json').common.name;
const utils = require('@iobroker/adapter-core'); // Get common adapter utils
const _request = require('request-promise');
const _http = require('express')();
const _parser = require('body-parser');
const _ip = require('ip');
const Bridge = require('nuki-bridge-api');
/*
* internal libraries
*/
const Library = require(`${__dirname}/lib/library.js`);
const _LOCK = require(`${__dirname}/_LOCK.js`);
const _OPENER = require(`${__dirname}/_OPENER.js`);
const _NODES = require(`${__dirname}/_NODES.js`);
const WebApiHandler = require('./lib/web-api');
const NukiTools = require('./lib/nuki-tools');
/*
* constants & variables initiation
*/
const MAX_RETRY_ACTIONS = 3;
let adapter;
let library;
let unloaded;
let refreshCycleBridgeApi;
let setup = [];
let BRIDGES = {};
let nukiWebApi = new WebApiHandler(),
listener = false;
/*
* ADAPTER
*
*/
function startAdapter(options) {
options = options || {};
adapter = new utils.Adapter({ ...options, name: adapterName });
/*
* ADAPTER READY
*
*/
adapter.on('ready', function () {
_http.on('error', err => adapter.log.error(err));
library = new Library(adapter, { nodes: _NODES, updatesInLog: adapter.config.debugLog || false });
unloaded = false;
// Check Node.js Version
let version = parseInt(process.version.substring(1, process.version.indexOf('.')));
if (version <= 6) {
return library.terminate(
`This Adapter is not compatible with your Node.js Version ${process.version} (must be >= Node.js v7).`,
true,
);
}
// Check port
if (!adapter.config.callbackPort) {
adapter.config.callbackPort = 51989;
}
if (adapter.config.callbackPort < 10000 || adapter.config.callbackPort > 65535) {
adapter.log.warn(
`The callback port (${adapter.config.callbackPort}) is incorrect. Provide a port between 10.000 and 65.535! Using port 51989 now.`,
);
adapter.config.callbackPort = 51989;
}
adapter.config.additionalWebApiTimeout = parseInt(adapter.config.additionalWebApiTimeout, 10) || 3;
adapter.config.additionalWebApiCall =
adapter.config.additionalWebApiCall !== undefined ? adapter.config.additionalWebApiCall : true;
// retrieve all values from states to avoid message "Unsubscribe from all states, except system's, because over 3 seconds the number of events is over 200 (in last second 0)"
adapter.getStates(`${adapterName}.${adapter.instance}.*`, (err, states) => {
if (err || !states) {
return;
}
for (let state in states) {
library.setDeviceState(
state.replace(`${adapterName}.${adapter.instance}.`, ''),
states[state] && states[state].val,
);
}
// start
library.set(Library.CONNECTION, true);
initNukiAPIs();
});
});
/*
* ADAPTER UNLOAD
*
*/
adapter.on('unload', function (callback) {
try {
adapter.log.info('Adapter stopped und unloaded.');
nukiWebApi.unload();
unloaded = true;
if (refreshCycleBridgeApi) {
clearTimeout(refreshCycleBridgeApi);
}
callback();
} catch {
callback();
}
});
/*
* STATE CHANGE
*
*/
adapter.on('stateChange', function (node, object) {
adapter.log.debug(`State of ${node} has changed ${JSON.stringify(object)}.`);
let state = node.substring(node.lastIndexOf('.') + 1);
let action = object !== undefined && object !== null ? object.val : 0;
let path = node.substring(0, node.lastIndexOf('.')).replace(`${adapterName}.${adapter.instance}.`, '');
let root = path.split('.').slice(0, 2).join('.');
// apply an action on the callback
if (state === '_delete' && object && object.ack !== true) {
let bridgeId = library.getDeviceState(`${root}.bridgeId`);
let url = library.getDeviceState(`${path}.url`);
// ID or url could not be retrived
if (!bridgeId || !url || url === '{}') {
adapter.log.warn('Error deleting callback: No Bridge ID or URL given!');
return;
}
// delete callback
let callbackIndex = BRIDGES[bridgeId].callbacks.findIndex(cb => cb.url === url);
if (callbackIndex > -1) {
BRIDGES[bridgeId].callbacks[callbackIndex]
.remove()
.then(() => {
adapter.log.info(`Deleted callback with URL ${url}.`);
// delete objects
let path = `${BRIDGES[bridgeId].data.path}.callbacks.${BRIDGES[bridgeId].callbacks[callbackIndex].getCallbackId()}`;
library.del(path, true);
// update callback list
BRIDGES[bridgeId].callbacks.splice(callbackIndex, 1);
library._setValue(
`${BRIDGES[bridgeId].data.path}.callbacks.list`,
JSON.stringify(
BRIDGES[bridgeId].callbacks.map(cb => {
return { id: cb.id, url: cb.url };
}),
),
);
})
.catch(err => adapter.log.debug(`Error removing callback (${err.message})!`));
} else {
adapter.log.warn(`Error deleting callback with URL ${url}: callback entry could not be resolved.`);
}
}
// apply an action on the door
let lockAction = Object.values(_LOCK.ACTIONS).indexOf(state.replace(/_/g, ' '));
let openerAction = Object.values(_OPENER.ACTIONS).indexOf(state.replace(/_/g, ' '));
if (state !== '_ACTION' && (lockAction > -1 || openerAction > -1)) {
state = '_ACTION';
action = lockAction > -1 ? lockAction : openerAction;
path = path.substr(0, path.lastIndexOf('.'));
}
if (state === '_ACTION' && Number.isInteger(action) && action > 0 && object && object.ack !== true) {
library._setValue(node, false, true);
let nukiHexId = library.getDeviceState(`${path}.hex`);
// ID or type could not be retrived
if (!nukiHexId) {
adapter.log.warn('Error triggering action via Nuki Bridge API: No Nuki Hex ID given!');
return false;
}
// get smartlock
let device = NukiTools.DEVICES[nukiHexId];
action = { id: action };
switch ((device.type && device.type.toLowerCase()) || false) {
case 'smartlock':
action.name = _LOCK.ACTIONS[action.id];
break;
case 'opener':
action.name = _OPENER.ACTIONS[action.id];
break;
case 'smart door':
action.name = _LOCK.ACTIONS[action.id];
break;
case 'box':
default:
adapter.log.warn('Error triggering action via Nuki Bridge API: Wrong Nuki type given!');
return false;
}
// apply action
setAction(device, action, nukiWebApi.active && adapter.config.bridges.length === 0 ? 'web' : 'bridge');
}
// configuration
if (
(node.indexOf('.config.') > -1 ||
node.indexOf('.advancedConfig.') > -1 ||
node.indexOf('.openerAdvancedConfig.') > -1) &&
nukiWebApi.active &&
object &&
object.ack !== true
) {
library.set({ node: node.replace(`${adapter.name}.${adapter.instance}.`, '') }, object.val);
path = path.substr(0, path.lastIndexOf('.'));
let nukiHexId = library.getDeviceState(`${path}.hex`);
// ID or type could not be retrived
if (!nukiHexId) {
adapter.log.warn('Error triggering action via Nuki Web API: No Nuki Hex ID given!');
return false;
}
// get smartlock
let device = NukiTools.DEVICES[nukiHexId];
// change config
if (node.indexOf('.config.') > -1) {
nukiWebApi.setConfig(device.smartlockId, { ...device.config, [state]: action }, state);
}
// change advancedConfig
else if (node.indexOf('.advancedConfig.') > -1) {
nukiWebApi.setAdvancedConfig(device.smartlockId, { ...device.advancedConfig, [state]: action }, state);
}
// change openerAdvancedConfig
else if (node.indexOf('.openerAdvancedConfig.') > -1) {
nukiWebApi.setAdvancedConfig(
device.smartlockId,
{ ...device.openerAdvancedConfig, [state]: action },
state,
);
}
} else if (state !== '_ACTION' && state !== '_delete' && !nukiWebApi.active && object && object.ack !== true) {
adapter.log.info('Nuki Web API needs to be configured to change configuration!');
}
});
/*
* HANDLE MESSAGES
*
*/
adapter.on('message', function (msg) {
adapter.log.debug(`Message: ${JSON.stringify(msg)}`);
switch (msg.command) {
case 'discover':
adapter.log.info('Discovering bridges..');
_request({ url: 'https://api.nuki.io/discover/bridges', json: true })
.then(res => {
let discovered = res.bridges;
adapter.log.info(`Bridges discovered: ${discovered.length}`);
adapter.log.debug(JSON.stringify(discovered));
library.msg(msg.from, msg.command, { result: true, bridges: discovered }, msg.callback);
})
.catch(err => {
adapter.log.warn(`Error while discovering bridges: ${err.message}`);
library.msg(msg.from, msg.command, { result: false, error: err.message }, msg.callback);
});
break;
case 'auth':
adapter.log.info('Authenticate bridge..');
_request({ url: `http://${msg.message.bridgeIp}:${msg.message.bridgePort}/auth`, json: true })
.then(res => {
library.msg(msg.from, msg.command, { result: true, token: res.token }, msg.callback);
})
.catch(err => {
adapter.log.warn(`Error while authenticating bridge: ${err.message}`);
library.msg(msg.from, msg.command, { result: false, error: err.message }, msg.callback);
});
break;
}
});
return adapter;
}
/**
* Main function
*
*/
function initNukiAPIs() {
library.set(library.getNode('bridgeApiCallback'), false);
library.set(library.getNode('bridgeApiSync'), false);
library.set(library.getNode('webApiSync'), false);
const hasBridge = adapter.config.bridges !== undefined && adapter.config.bridges.length > 0;
const hasWebApi = adapter.config.refreshWebApi !== 0;
/*
* BRIDGE API
*
*/
// check if bridges have been defined
if (!hasBridge && !hasWebApi) {
return library.terminate('You either need a bridge or WebApi.');
}
if (nukiWebApi.initialize(adapter, library)) {
setup.push('web_api');
}
if (!hasBridge) {
return;
}
setup.push('bridge_api');
// go through bridges
listener = adapter.config.bridges.map(device => {
// check if API settings are set
if (!device.bridge_name || !device.bridge_ip || !device.bridge_token) {
adapter.log.warn(
`Name, IP or API token missing for bridge ${device.bridge_name}! Please go to settings and fill in IP and the API token first!`,
);
return Promise.resolve(false);
}
// check if Bridge is enabled in settings
if (!device.active) {
adapter.log.info(`Bridge with name ${device.bridge_name} is disabled in adapter settings. Thus, ignored.`);
return Promise.resolve(false);
}
// initialize Nuki Bridge class
library.set(library.getNode('bridges'));
device.path = `bridges.${library.clean(device.bridge_name, true, '_')}`;
try {
let bridge = {
data: device,
callbacks: [],
instance: new Bridge.Bridge(device.bridge_ip, device.bridge_port || 8080, device.bridge_token, {
forcePlainToken: adapter.config.hashedToken !== true,
}),
};
// index bridge
BRIDGES[bridge.data.bridge_id] = bridge;
// get bridge info
getBridgeApi(bridge);
// get current callback URLs
return getCallbacks(bridge);
} catch (err) {
adapter.log.error(
`Error while initializing bridge ${device.bridge_name} on ${device.bridge_ip}:${device.bridge_port || 8080}: ${err.message}`,
);
return Promise.resolve(false);
}
});
// everything ok
library.set(Library.CONNECTION, true);
// attach server to listen (only one listener for all Nuki Bridges)
// @see https://stackoverflow.com/questions/9304888/how-to-get-data-passed-from-a-form-in-express-node-js/38763341#38763341
return Promise.all(listener)
.then(values => {
// attach callback
if (values.findIndex(el => el === 'attachListener') > -1) {
_http.use(_parser.json());
_http.use(_parser.urlencoded({ extended: false }));
_http.post('/nuki-api-bridge', (req, res) => {
if (req && req.body) {
let payload = {
nukiId: req.body.nukiId,
state: { ...req.body, timestamp: `${new Date().toISOString().substr(0, 19)}+00:00` },
};
if (payload.state.nukiId) {
delete payload.state.nukiId;
}
if (payload.state.deviceType) {
delete payload.state.deviceType;
}
adapter.log.debug(`Received payload via callback: ${JSON.stringify(payload)}`);
library.set(
library.getNode('bridgeApiLast'),
`${new Date().toISOString().substr(0, 19)}+00:00`,
);
library.set(library.getNode('bridgeApiCallback'), true);
NukiTools.updateLock(payload, library, adapter);
res.sendStatus(200);
res.end();
// update Web API as well
// update Web API as well if enabled
if (adapter.config.additionalWebApiCall) {
setTimeout(() => {
nukiWebApi.getWebApi();
}, adapter.config.additionalWebApiTimeout * 1000);
}
} else {
adapter.log.warn(
`main(): Unable to process callback request payload (${JSON.stringify(req && req.body)}).`,
);
res.sendStatus(500);
res.end();
}
});
try {
_http.listen(adapter.config.callbackPort, () =>
adapter.log.info(`Listening for Nuki events on port ${adapter.config.callbackPort}.`),
);
} catch (e) {
adapter.log.error(`Can not start webserver: ${e.message}`);
}
}
// no callback
else if (
values.findIndex(el => el === 'attachListener') === -1 &&
values.findIndex(el => el === 'doNotAttachListener') > -1
) {
adapter.log.info('Not listening for Nuki events.');
}
// no bridges
else {
adapter.log.info(
'No bridges are sufficiently defined! Name, IP or token missing or all bridges deactivated!',
);
}
// everything ok
library.set(Library.CONNECTION, true);
})
.catch(err => adapter.log.debug(`Error resolving listeners (${JSON.stringify(err)})!`));
}
/**
*
*
* @param bridge
*/
function getCallbacks(bridge) {
return bridge.instance
.getCallbacks()
.then(cbs => {
adapter.log.debug(`Retrieved current callbacks from Nuki Bridge with name ${bridge.data.bridge_name}.`);
BRIDGES[bridge.data.bridge_id].callbacks = cbs;
// check for enabled callback
if (adapter.config.refreshBridgeApiType === 'callback') {
library.set(library.getNode('bridgeApiCallback'), true);
let url = `http://${adapter.config.callbackIp || _ip.address()}:${adapter.config.callbackPort}/nuki-api-bridge`; // NOTE: https is not supported according to API documentation
// attach callback
if (BRIDGES[bridge.data.bridge_id].callbacks.findIndex(cb => cb.url === url) === -1) {
adapter.log.debug(
`Adding callback with URL ${url} to Nuki Bridge with name ${bridge.data.bridge_name}.`,
);
// set callback on bridge
bridge.instance
.addCallback(adapter.config.callbackIp || _ip.address(), adapter.config.callbackPort, false)
.then(res => {
if (!res || !res.url) {
throw new Error(JSON.stringify(res));
}
adapter.log.info(
`Callback (with URL ${res.url}) attached to Nuki Bridge with name ${bridge.data.bridge_name}.`,
);
BRIDGES[bridge.data.bridge_id].callbacks.push(res);
setCallbackNodes(bridge.data.bridge_id);
})
.catch(err => {
if (err && err.error && err.error.message === 'callback already added') {
adapter.log.debug(
`Callback (with URL ${url}) already attached to Nuki Bridge with name ${bridge.data.bridge_name}.`,
);
} else if (BRIDGES[bridge.data.bridge_id].callbacks.length >= 3) {
adapter.log.warn(
'Callback not attached because too many Callbacks attached to the Nuki Bridge already! Please delete a callback!',
);
} else {
adapter.log.warn('Callback not attached due to error. See debug log for details.');
adapter.log.debug(err.error.message);
}
});
} else {
adapter.log.debug(
`Callback (with URL ${url}) already attached to Nuki Bridge with name ${bridge.data.bridge_name}.`,
);
setCallbackNodes(bridge.data.bridge_id);
}
return Promise.resolve('attachListener');
}
return Promise.resolve('doNotAttachListener');
})
.catch(err => adapter.log.debug(`Error retrieving callbacks (${JSON.stringify(err)})!`));
}
/**
* Retrieve from Bridge API.
*
* @param bridge
*/
function getBridgeApi(bridge) {
library.set(library.getNode('bridgeApiSync'), true, { force: true });
library.set(library.getNode('bridgeApiLast'), `${new Date().toISOString().substr(0, 19)}+00:00`);
// get nuki devices from bridge
adapter.log.silly(
`Retrieving from Nuki Bridge API (Bridge ${bridge.data.bridge_ip}${adapter.config.hashedToken ? ' using hashed token' : ''})..`,
);
bridge.instance
.list()
.then(nukis => {
adapter.log.debug(
`getBridgeApi() [forcePlainToken: ${bridge.instance.forcePlainToken === true}]: ${JSON.stringify(nukis)}`,
);
nukis.forEach(nuki => {
// remap states
nuki.bridge = bridge.data.bridge_id !== '' ? bridge.data.bridge_id : undefined;
nuki.state = nuki.lastKnownState;
nuki.deviceType = nuki.deviceType || 0;
delete nuki.lastKnownState;
NukiTools.updateLock(nuki, library, adapter);
});
})
.catch(err => {
adapter.log.warn(
`Failed retrieving /list from Nuki Bridge with name ${bridge.data.bridge_name} (forcePlainToken: ${bridge.instance.forcePlainToken === true})!`,
);
adapter.log.debug(`getBridgeApi(): ${err.message}`);
adapter.log.debug(`_getTokenParams(): ${JSON.stringify(bridge.instance._getTokenParams())}`);
if ((err.message.indexOf('503') > -1 || err.message.indexOf('socket hang up') > -1) && !unloaded) {
adapter.log.info('Trying again in 10s..');
setTimeout(getBridgeApi, 10 * 1000, bridge);
}
});
// get bridge info
bridge.instance
.info()
.then(payload => {
// enrich payload
payload.name = bridge.data.bridge_name;
payload.ip = bridge.data.bridge_ip;
payload.port = bridge.data.bridge_port || 8080;
// get log
/*
bridge.instance.log()
.then(log => {
adapter.log.warn(JSON.stringify(log));
})
.catch(err => {
adapter.log.warn('Failed retrieving /log from Nuki Bridge with name ' + bridge.data.bridge_name + ' (forcePlainToken: ' + (bridge.instance.forcePlainToken === true) + ')!');
adapter.log.debug('getBridgeApi(): ' + err.message);
});
*/
// add bridge actions
payload.actions = payload.actions || {};
payload.actions.clearLog = false;
payload.actions.firmwareUpdate = false;
payload.actions.reboot = false;
// get bridge ID if not given
if (bridge.data.bridge_id === undefined || bridge.data.bridge_id === '') {
adapter.log.debug(`Adding missing Bridge ID for bridge with IP ${bridge.data.bridge_ip}.`);
bridge.data.bridge_id = payload.ids.serverId;
// update bridge ID in configuration
adapter.getForeignObject(`system.adapter.${adapter.namespace}`, (err, obj) => {
obj.native.bridges.forEach((entry, i) => {
if (entry.bridge_ip === bridge.data.bridge_ip) {
obj.native.bridges[i].bridge_id = bridge.data.bridge_id;
adapter.setForeignObject(obj._id, obj);
}
});
});
}
// set payload for bridge
library.set({
node: bridge.data.path,
description: `Bridge ${bridge.data.bridge_name ? `${bridge.data.bridge_name} ` : ''}(${bridge.data.bridge_ip})`,
role: 'channel',
});
library.readData('', payload, bridge.data.path);
})
.catch(err => {
adapter.log.warn(
`Failed retrieving /info from Nuki Bridge with name ${bridge.data.bridge_name} (forcePlainToken: ${bridge.instance.forcePlainToken === true})!`,
);
adapter.log.debug(`getBridgeApi(): ${err.message}`);
});
}
/**
* Apply an action on either via Nuki Bridge API or via Nuki Web API.
*
* @param device
* @param action
* @param api
* @param retry
*/
function setAction(device, action, api = 'bridge', retry = 0) {
adapter.log.info(
`Trigger action -${action.name}- on Nuki ${device.type} ${device.name} (via ${library.ucFirst(api)} API).`,
);
// try Bridge API
if (device.instance !== undefined && device.instance !== null && api === 'bridge') {
adapter.log.debug('Action applied on Bridge API.');
device.instance
.lockAction(action.id)
.then(() => {
adapter.log.info(
`Successfully triggered action -${action.name}- on Nuki ${device.type} ${device.name} (via Bridge API).`,
);
return Promise.resolve(true);
})
.catch(err => {
adapter.log.warn(
`Error triggering action -${action.name}- on Nuki ${device.type} ${device.name} (via Bridge API). See debug log for details.`,
);
adapter.log.debug(err.message);
// retry
retry++;
if (retry >= MAX_RETRY_ACTIONS) {
return Promise.resolve(false);
}
if (nukiWebApi) {
adapter.log.info(`Try again (${retry}x) with Nuki Web API..`);
return setAction(device, action, 'web', retry);
}
adapter.log.info(`Try again (${retry}x) in 10s with Nuki Bridge API..`);
setTimeout(() => {
return setAction(device, action, 'bridge', retry);
}, 10 * 1000);
});
}
// try Web API
else if (nukiWebApi !== null && nukiWebApi.api && api === 'web') {
adapter.log.debug('Action applied on Web API.');
nukiWebApi.api
.setAction(device.smartlockId, action.id)
.then(() => {
adapter.log.info(
`Successfully triggered action -${action.name}- on Nuki ${device.type} ${device.name} (via Web API).`,
);
return Promise.resolve(true);
})
.catch(err => {
adapter.log.warn(
`Error triggering action -${action.name}- on Nuki ${device.type} ${device.name} (via Web API). See debug log for details.`,
);
adapter.log.debug(err.message);
// retry
retry++;
if (retry >= MAX_RETRY_ACTIONS) {
return Promise.resolve(false);
}
adapter.log.info(`Try again (${retry}x) in 10s with Nuki Bridge API..`);
setTimeout(() => {
return setAction(device, action, 'bridge', retry);
}, 10 * 1000);
});
}
// No API given
else {
adapter.log.warn('Neither Bridge API or Web API initialized!');
adapter.log.debug(`DEVICE:${JSON.stringify(device)}`);
adapter.log.debug(`api: ${api}`);
adapter.log.debug(`Bridge API:${JSON.stringify(device.instance)}`);
//adapter.log.debug(`Web API: ${JSON.stringify(nukiWebApi)}`);
return Promise.resolve(false);
}
}
/**
* Refresh Callbacks of the Nuki Bridge.
*
* @param bridgeId
*/
function setCallbackNodes(bridgeId) {
let path = `${BRIDGES[bridgeId].data.path}.callbacks`;
let urls = {};
// compare callback list with states
adapter.getStates(`${path}.*`, (err, states) => {
// index states
for (let state in states) {
if (state.substr(-4) === '.url' && states[state] && states[state].val) {
urls[state.substr(-5, 1)] = states[state].val;
}
}
// add new callbacks
let index = null,
cbs = [];
BRIDGES[bridgeId].callbacks.forEach(cb => {
// search for callback URL
index = Object.values(urls).indexOf(cb.url);
cbs.push({ id: cb.callbackId, url: cb.url });
// add new URL
if (index === -1) {
let node = `${path}.${cb.callbackId.toString()}`;
library.set({ ...library.getNode('callbacks.callback'), node: node });
library.set(
{ ...library.getNode('callbacks.callback.id'), node: `${node}.id` },
parseInt(cb.callbackId),
);
library.set({ ...library.getNode('callbacks.callback.url'), node: `${node}.url` }, cb.url);
library.set({ ...library.getNode('callbacks.callback.delete'), node: `${node}._delete` });
}
// keep existing URLs
else {
let key = Object.keys(urls)[index];
delete urls[key];
}
});
// create channel and callback list
library.set({ ...library.getNode('callbacks'), node: path });
library.set({ ...library.getNode('callbacks.list'), node: `${path}.list` }, JSON.stringify(cbs));
// attach state listener
adapter.subscribeStates('*.callbacks.*._delete');
// remove old callbacks
for (let key in urls) {
library.del(`${path}.${key}`, true, () =>
adapter.log.debug(`Deleted states for callback with URL ${urls[key]}.`),
);
}
});
}
/*
* COMPACT MODE
* If started as allInOne/compact mode => return function to create instance
*
*/
if (module && module.parent) {
module.exports = startAdapter;
} else {
startAdapter();
} // or start the instance directly