UNPKG

iobroker.nuki2

Version:
686 lines (580 loc) 21 kB
'use strict'; 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 _uuid = require('uuid/v5'); const Bridge = require('nuki-bridge-api'); const Nuki = require('nuki-web-api'); /* * internal libraries */ const Library = require(__dirname + '/lib/library.js'); const _LOCK = require(__dirname + '/_LOCK.js'); const _NODES = require(__dirname + '/_NODES.js'); /* * variables initiation */ let adapter; let library; let unloaded; let refreshCycle; let setup = []; let nuki = null, bridges = {}, doors = {}, listeners = {}; let listener = false; let callbacks = {}; /* * ADAPTER * */ function startAdapter(options) { options = options || {}; Object.assign(options, { name: adapterName }); adapter = new utils.Adapter(options); library = new Library(adapter, { updatesInLog: true }); unloaded = false; /* * ADAPTER READY * */ adapter.on('ready', main); /* * ADAPTER UNLOAD * */ adapter.on('unload', function(callback) { try { adapter.log.info('Adapter stopped und unloaded.'); unloaded = true; clearTimeout(refreshCycle); callback(); } catch(e) { callback(); } }); /* * STATE CHANGE * */ adapter.on('stateChange', function(node, object) { adapter.log.debug('State of ' + node + ' has changed ' + JSON.stringify(object) + '.'); let state = node.substr(node.lastIndexOf('.')+1); let action = object !== undefined && object !== null ? object.val : 0; // apply an action on the callback if (state === '_delete' && object && object.ack !== true) { adapter.getObject(node, function(err, node) { // get bridge ID and callback ID let bridgeId = node.common.bridgeId || false; let url = node.common.url || false; // error if (err !== null || !bridgeId || !url || url == '{}') { adapter.log.warn('Error deleting callback with URL ' + url + ': ' + (err ? err.message : 'No Callback ID given!')); return; } // delete callback url = JSON.parse(url); let callbackIndex = callbacks[bridgeId].findIndex(cb => cb.url === url); if (callbackIndex > -1) { callbacks[bridgeId][callbackIndex].remove().then(function() { adapter.log.info('Deleted callback with URL ' + url + '.'); // delete objects let path = bridges[bridgeId].data.path + '.callbacks.' + _uuid(url, _uuid.URL); library.del(path, true); // update callback list callbacks[bridgeId].splice(callbackIndex, 1); library._setValue(bridges[bridgeId].data.path + '.callbacks.list', JSON.stringify(callbacks[bridgeId])); }) .catch(function(err) {adapter.log.debug('Error removing callback (' + JSON.stringify(err) + ')!')}); } else adapter.log.warn('Error deleting callback with URL ' + url + ': ' + (err ? err.message : 'No Callback ID given!')); }); } // apply an action on the door if (state === 'action' && Number.isInteger(action) && action > 0 && object.ack !== true) { adapter.setState(node, 0, true); adapter.getObject(node, function(err, node) { let nukiId = node.common.nukiId || false; if (err !== null || !nukiId) { adapter.log.warn('Error triggering action -' + _LOCK.ACTIONS[action] + '- on the Nuki: ' + (err ? err.message : 'No Nuki ID given!')); return; } // log adapter.log.info('Triggered action -' + _LOCK.ACTIONS[action] + '- on Nuki ' + doors[nukiId].name + '.'); // try bridge API let bridge = bridges[doors[nukiId].bridge] !== undefined ? bridges[doors[nukiId].bridge].instance : null; if (bridge !== null) { adapter.log.debug('Action applied on Bridge API.'); bridge.get(nukiId).then(function(device) { device.lockAction(action) .then(function() { adapter.log.info('Successfully triggered action -' + _LOCK.ACTIONS[action] + '- on Nuki ' + doors[nukiId].name + '.'); library._setValue(node, 0); }) .catch(function(e) { adapter.log.warn('Error triggering action -' + _LOCK.ACTIONS[action] + '- on Nuki ' + doors[nukiId].name + '. See debug log for details.'); adapter.log.debug(e.message); }); }) .catch(function(e) { adapter.log.warn('Error triggering action -' + _LOCK.ACTIONS[action] + '- on Nuki ' + doors[nukiId].name + '. See debug log for details.'); adapter.log.debug(e.message); }); } // try Web API else if (nuki !== null) { adapter.log.debug('Action applied on Web API.'); nuki.setAction(nukiId, action).catch(function(e) {adapter.log.debug(e.message)}); } }); } }); /* * 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(function(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(function(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(function(res) { library.msg(msg.from, msg.command, {result: true, token: res.token}, msg.callback); }) .catch(function(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 main() { adapter.log.warn('This is adapter has been replaced by the nuki-extended adapter you will find in latest repository. Please migrate. See https://forum.iobroker.net/topic/12819/neuer-adapter-nuki/144 for more information.'); adapter.log.warn('Dieser Adapter wurde durch den nuki-extended Adapter ersetzt, der im Latest Repository erhältlich ist. Siehe auch https://forum.iobroker.net/topic/12819/neuer-adapter-nuki/144 für mehr Informationen.'); // Check port if (!adapter.config.callbackPort) adapter.config.callbackPort = 51988; 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 51988 now.'); adapter.config.callbackPort = 51988; } /* * WEB API * */ if (!adapter.config.api_token) adapter.log.info('No Nuki Web API token provided.'); else { nuki = new Nuki(adapter.config.api_token); setup.push('web_api'); // get locks updateLocks(); } /* * BRIDGE API * */ // check if bridges have been defined if (adapter.config.bridges === undefined || adapter.config.bridges.length == 0) return library.terminate('No bridges have been defined in settings so far!'); else { setup.push('bridge_api'); // go through bridges let listener = adapter.config.bridges.map(function setupBridge(device, i) { let bridge_ident = device.bridge_name ? 'with name ' + device.bridge_name : (device.bridge_id ? 'with ID ' + device.bridge_id : 'with index ' + i); // check if Bridge is enabled in settings if (!device.active) { adapter.log.info('Bridge ' + bridge_ident + ' is disabled in adapter settings. Thus, ignored.'); return Promise.resolve(false); } // check if API settings are set if (!device.bridge_ip || !device.bridge_token) { adapter.log.warn('IP or API token missing for bridge ' + bridge_ident + '! Please go to settings and fill in IP and the API token first!'); return Promise.resolve(false); } // initialize Nuki Bridge class device.path = 'bridge__' + (device.bridge_name ? device.bridge_name.replace(/ /gi, '_').toLowerCase() : device.bridge_id); let bridge = { 'data': device, 'instance': new Bridge.Bridge(device.bridge_ip, device.bridge_port || 8080, device.bridge_token) }; // index bridge bridges[device.bridge_id] = bridge; // get bridge info getBridgeInfo(bridge); // get current callback URLs return bridge.instance.getCallbacks().then(function(cbs) { adapter.log.debug('Retrieved current callbacks from Nuki Bridge ' + bridge_ident + ': ' + JSON.stringify(cbs)); callbacks[device.bridge_id] = cbs; setCallbackNodes(device.bridge_id); // check for enabled callback if (device.bridge_callback) { let url = 'http://' + _ip.address() + ':' + adapter.config.callbackPort + '/nuki-api-bridge'; // NOTE: https is not supported according to API documentation // attach callback if (callbacks[device.bridge_id].findIndex(cb => cb.url === url) === -1) { adapter.log.debug('Adding callback with URL ' + url + ' to Nuki Bridge ' + bridge_ident + '.'); // set callback on bridge bridge.instance.addCallback(_ip.address(), adapter.config.callbackPort, false) .then(function(res) { adapter.log.info('Callback (with URL ' + res.url + ') attached to Nuki Bridge ' + bridge_ident + '.'); callbacks[device.bridge_id].push(res); setCallbackNodes(device.bridge_id); }) .catch(function(e) { if (e && e.error && e.error.message === 'callback already added') adapter.log.debug('Callback (with URL ' + url + ') already attached to Nuki Bridge ' + bridge_ident + '.'); else { adapter.log.warn('Callback not attached due to error. See debug log for details.'); adapter.log.debug(JSON.stringify(e)); } }); } else adapter.log.debug('Callback (with URL ' + url + ') already attached to Nuki Bridge ' + bridge_ident + '.'); return Promise.resolve(true); } else adapter.log.debug('Callback deactivated for Nuki Bridge ' + bridge_ident + '.'); return Promise.resolve(false); }); }); // 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 Promise.all(listener).then(function(values) { if (values.findIndex(el => el === true) > -1) { adapter.log.info('Listening for Nuki events on port ' + adapter.config.callbackPort + '.'); _http.use(_parser.json()); _http.use(_parser.urlencoded({extended: false})); _http.post('/nuki-api-bridge', function(req, res) { adapter.log.debug('Received payload via callback: ' + JSON.stringify(req.body)); let payload; try { payload = req.body; updateLock({'nukiId': payload.nukiId, 'state': {'state': payload.state, 'batteryCritical': payload.batteryCritical, 'timestamp': new Date()}}); res.sendStatus(200); } catch(e) { res.sendStatus(500); adapter.log.warn('main(): ' + e.message); } }); _http.listen(adapter.config.callbackPort); } else adapter.log.info('Not listening for Nuki events.'); }); } // exit if no API is given if (setup.length == 0) return; library.set(Library.CONNECTION, true); // periodically refresh settings if (!adapter.config.refresh) adapter.config.refresh = 0; else if (adapter.config.refresh > 0 && adapter.config.refresh < 10) { adapter.log.warn('Due to performance reasons, the refresh rate can not be set to less than 10 seconds. Using 10 seconds now.'); adapter.config.refresh = 10; } if (adapter.config.refresh > 0 && !unloaded) { refreshCycle = setTimeout(function updater() { // update nuki if (setup.indexOf('web_api') > -1) updateLocks(); // update bridge if (setup.indexOf('bridge_api') > -1) for (let key in bridges) {getBridgeInfo(bridges[key])} // https://forum.iobroker.net/topic/12819/neuer-adapter-nuki/124 // set interval if (!unloaded) refreshCycle = setTimeout(updater, Math.round(parseInt(adapter.config.refresh)*1000)); }, Math.round(parseInt(adapter.config.refresh)*1000)); } } /** * Retrieve Nuki's. * */ function getBridgeInfo(bridge) { // get current callback URLs bridge.instance.getCallbacks().then(function(cbs) { callbacks[bridge.data.bridge_id] = cbs; setCallbackNodes(bridge.data.bridge_id); }); // get nuki's //adapter.log.info('Retrieving Nuki\'s from Bridge ' + bridge.data.bridge_ip + '..'); bridge.instance.list().then(function gotNukis(nukis) { nukis.forEach(function(n) { // create Nuki n.bridge = bridge.data.bridge_id !== '' ? bridge.data.bridge_id : undefined; n.state = n.lastKnownState; adapter.log.debug('getBridgeInfo(): ' + JSON.stringify(n)); updateLock(n); }); }) .catch(function(e) { adapter.log.warn('Connection settings for bridge incorrect' + (bridge.data.bridge_name ? ' with name ' + bridge.data.bridge_name : (bridge.data.bridge_id ? ' with ID ' + bridge.data.bridge_id : (bridge.data.bridge_ip ? ' with ip ' + bridge.data.bridge_ip : ''))) + '! No connection established. See debug log for more details.'); adapter.log.debug('getBridgeInfo(): ' + e.message); }); // get bridge info bridge.instance.info().then(function gotInfo(info) { // info.ip = bridge.data.bridge_ip; info.port = bridge.data.bridge_port || 8080; // 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 = info.ids.serverId; // update bridge ID in configuration adapter.getForeignObject('system.adapter.' + adapter.namespace, function(err, obj) { obj.native.bridges.forEach(function(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); } }); }); } // create bridge adapter.createDevice(bridge.data.path, {name: 'Bridge '+(bridge.data.bridge_name ? bridge.data.bridge_name+' ' : '')+'(' + bridge.data.bridge_ip + ')'}, {}, function(err) { // create generell states _NODES.BRIDGE.forEach(node => { setInformation(Object.assign({}, node, { node: bridge.data.path + '.' + node.state }), info); }); }); }) .catch(function(e) {adapter.log.debug('getBridgeInfo(): ' + e.message)}); } /** * Refresh Callbacks of the Nuki Bridge. * */ function setCallbackNodes(bridgeId) { let path = bridges[bridgeId].data.path + '.callbacks'; library.del(path, true, function() { let urls = []; callbacks[bridgeId].forEach(function(cb) { let node = path + '.' + _uuid(cb.url, _uuid.URL); urls.push(cb.url); library.set({node: node, description: 'Callback', role: 'channel'}); library.set({node: node + '.url', description: 'URL of the callback', role: 'text'}, cb.url); library.set({node: node + '._delete', description: 'Delete the callback', role: 'button', common: {bridgeId: bridgeId, url: JSON.stringify(cb.url)}}); adapter.subscribeStates(node + '._delete'); // attach state listener }); library.set({node: path, description: 'Callbacks of the Bridge', role: 'channel'}); library.set({node: path + '.list', description: 'List of callbacks', role: 'json'}, JSON.stringify(urls)); }); } /** * Update Nuki Locks. * */ function updateLocks() { if (!nuki) return; //adapter.log.info('Retrieving Nuki\'s from Web API..'); nuki.getSmartlocks().then(function(smartlocks) { smartlocks.forEach(function(smartlock) { smartlock.nukiId = smartlock.smartlockId; if (setup.indexOf('bridge_api') > -1) delete smartlock.state.state; // use state retrieved from bridge instead of this adapter.log.debug('updateLocks(): ' + JSON.stringify(smartlock)); updateLock(smartlock); updateLogs(smartlock.nukiId); // get users nuki.getSmartlockAuth(smartlock.nukiId).then(function(users) { users.forEach(function(user) { let nodePath = doors[smartlock.nukiId].device + '.users.' + user.name.toLowerCase().replace(/ /gi, '_'); library.set({node: nodePath, description: 'User ' + user.name, role: 'channel'}); _NODES.LOCK.USERS.forEach(function(node) { setInformation(Object.assign({}, node, { node: nodePath + '.' + node.state }), user); }); }); }).catch(function(err) {adapter.log.warn('updateLocks(): Error retrieving users: ' + err.message)}); }); }).catch(function(err) {adapter.log.warn('updateLocks(): Error retrieving smartlocks: ' + err.message)}); } /** * Update states of Nuki Door based on payload. * */ function updateLock(payload) { // index Nuki let device; if (doors[payload.nukiId] === undefined) { device = 'door__' + payload.name.toLowerCase().replace(/ /gi, '_'); doors[payload.nukiId] = { device: device, name: payload.name, state: payload.state.state, bridge: null }; } // retrieve Nuki name else device = doors[payload.nukiId].device; // update bridge if (payload.bridge !== undefined) doors[payload.nukiId].bridge = payload.bridge; // create / update device adapter.log.debug('Updating lock ' + device + ' with payload: ' + JSON.stringify(payload)); adapter.createDevice(device, {name: payload.name}, {}, function(err) { if (err) adapter.log.warn('updateLock(): Error setting smartlock: '+err.message); _NODES.LOCK.STATES.forEach(node => { setInformation(Object.assign({}, node, { node: device + '.' + node.state, description: node.description.replace(/%id%/gi, payload.nukiId).replace(/%name%/gi, payload.name) }), payload); }); }); } /** * Set information based on payload. * */ function setInformation(node, payload) { let tmp = {}, status = '', index = ''; try { // action if (node.action !== undefined && listeners[node.node] === undefined) { node.common.nukiId = payload.nukiId; library.set(node, node.def); adapter.subscribeStates(node.node); // attach state listener listeners[node.node] = node; } // status else if (node.status !== undefined) { tmp = Object.assign({}, payload); // copy object status = node.status; // go through response while (status.indexOf('.') > -1) { try { index = status.substr(0, status.indexOf('.')); status = status.substr(status.indexOf('.')+1); tmp = tmp[index]; } catch(e) {adapter.log.debug('setInformation(): ' + e.message);} } // write value if (tmp !== undefined && tmp[status] !== undefined) library.set(node, (node.states !== undefined ? node.states[tmp[status]] : (node.type === 'boolean' && Number.isInteger(tmp[status]) ? (tmp[status] === 1) : tmp[status]))); } // only state / channel creation else library.set(node, ''); } catch(e) {adapter.log.warn('setInformation(): ' + JSON.stringify(e.message))} } /** * Update Nuki Logs. * */ function updateLogs(nukiId) { if (!nuki) return; //adapter.log.info('Retrieving Nuki Log\'s from Web API..'); nuki.getSmartlockLogs(nukiId, { limit: 1000 }).then(function(log) { library.set({node: doors[nukiId].device + '.logs', description: 'Logs / History of Nuki'}, JSON.stringify(log.slice(0, 250))); }).catch(function(e) {adapter.log.debug('updateLogs(): ' + e.message)}); } /* * 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