diff --git a/lib/api3/alarmSocket.js b/lib/api3/alarmSocket.js new file mode 100644 index 00000000000..5fe30a620c1 --- /dev/null +++ b/lib/api3/alarmSocket.js @@ -0,0 +1,152 @@ +'use strict'; + +const apiConst = require('./const'); +const forwarded = require('forwarded-for'); + +function getRemoteIP (req) { + const address = forwarded(req, req.headers); + return address.ip; +} + +/** + * Socket.IO broadcaster of alarm and annoucements + */ +function AlarmSocket (app, env, ctx) { + + const self = this; + + var levels = ctx.levels; + + const LOG_GREEN = '\x1B[32m' + , LOG_MAGENTA = '\x1B[35m' + , LOG_RESET = '\x1B[0m' + , LOG = LOG_GREEN + 'ALARM SOCKET: ' + LOG_RESET + , LOG_ERROR = LOG_MAGENTA + 'ALARM SOCKET: ' + LOG_RESET + , NAMESPACE = '/alarm' + ; + + + /** + * Initialize socket namespace and bind the events + * @param {Object} io Socket.IO object to multiplex namespaces + */ + self.init = function init (io) { + self.io = io; + + self.namespace = io.of(NAMESPACE); + self.namespace.on('connection', function onConnected (socket) { + + const remoteIP = getRemoteIP(socket.request); + console.log(LOG + 'Connection from client ID: ', socket.client.id, ' IP: ', remoteIP); + + socket.on('disconnect', function onDisconnect () { + console.log(LOG + 'Disconnected client ID: ', socket.client.id); + }); + + socket.on('subscribe', function onSubscribe (message, returnCallback) { + self.subscribe(socket, message, returnCallback); + }); + + }); + + ctx.bus.on('notification', self.emitNotification); + }; + + + /** + * Authorize Socket.IO client and subscribe him to authorized rooms + * + * Support webclient authorization with api_secret is added + * + * @param {Object} socket + * @param {Object} message input message from the client + * @param {Function} returnCallback function for returning a value back to the client + */ + self.subscribe = function subscribe (socket, message, returnCallback) { + const shouldCallBack = typeof(returnCallback) === 'function'; + + // Native client + if (message && message.accessToken) { + return ctx.authorization.resolveAccessToken(message.accessToken, function resolveFinishForToken (err, auth) { + if (err) { + console.log(`${LOG_ERROR} Authorization failed for accessToken:`, message.accessToken); + + if (shouldCallBack) { + returnCallback({ success: false, message: apiConst.MSG.SOCKET_MISSING_OR_BAD_ACCESS_TOKEN }); + } + return err; + } else { + // Subscribe for acking alarms + socket.on('ack', function onAck (level, group, silenceTime) { + ctx.notifications.ack(level, group, silenceTime, true); + console.info(LOG + 'ack received ' + level + ' ' + group + ' ' + silenceTime); + }); + + var okResponse = { success: true, message: 'Subscribed for alarms' } + if (shouldCallBack) { + returnCallback(okResponse); + } + return okResponse; + } + }); + } + + // Web client (jwt access token or api_hash) + if (message && (message.jwtToken || message.secret)) { + return ctx.authorization.resolve({ api_secret: message.secret, token: message.jwtToken, ip: getRemoteIP(socket.request) }, function resolveFinish (err, auth) { + if (err) { + console.log(`${LOG_ERROR} Authorization failed for jwtToken:`, message.jwtToken); + + if (shouldCallBack) { + returnCallback({ success: false, message: apiConst.MSG.SOCKET_MISSING_OR_BAD_ACCESS_TOKEN }); + } + return err; + } else { + // Subscribe for acking alarms + socket.on('ack', function onAck (level, group, silenceTime) { + ctx.notifications.ack(level, group, silenceTime, true); + console.info(LOG + 'ack received ' + level + ' ' + group + ' ' + silenceTime); + }); + + var okResponse = { success: true, message: 'Subscribed for alarms' } + if (shouldCallBack) { + returnCallback(okResponse); + } + return okResponse; + } + }); + } + + console.log(`${LOG_ERROR} Authorization failed for message:`, message); + if (shouldCallBack) { + returnCallback({ success: false, message: apiConst.MSG.SOCKET_MISSING_OR_BAD_ACCESS_TOKEN}); + } + }; + + + /** + * Emit alarm to subscribed clients + * @param {Object} notofication to emit + */ + + self.emitNotification = function emitNotification (notify) { + if (notify.clear) { + self.namespace.emit('clear_alarm', notify); + console.info(LOG + 'emitted clear_alarm to all clients'); + } else if (notify.level === levels.WARN) { + self.namespace.emit('alarm', notify); + console.info(LOG + 'emitted alarm to all clients'); + } else if (notify.level === levels.URGENT) { + self.namespace.emit('urgent_alarm', notify); + console.info(LOG + 'emitted urgent_alarm to all clients'); + } else if (notify.isAnnouncement) { + self.namespace.emit('announcement', notify); + console.info(LOG + 'emitted announcement to all clients'); + } else { + self.namespace.emit('notification', notify); + console.info(LOG + 'emitted notification to all clients'); + } + }; +} + +module.exports = AlarmSocket; diff --git a/lib/api3/doc/alarmsockets.md b/lib/api3/doc/alarmsockets.md new file mode 100644 index 00000000000..54d86e93a97 --- /dev/null +++ b/lib/api3/doc/alarmsockets.md @@ -0,0 +1,151 @@ +# APIv3: Socket.IO alarm channel + +### Complete sample client code +```html + + + + + + + + APIv3 Socket.IO sample for alarms + + + + + + + + + + +``` + +### Subscription (authorization) +The client must first subscribe to the channel that is exposed at `alarm` namespace, ie the `/alarm` subadress of the base Nightscout's web address (without `/api/v3` subaddress). +```javascript +const socket = io('https://nsapiv3.herokuapp.com/alarm'); +``` + + +Subscription is requested by emitting `subscribe` event to the server, while including document with parameter: +* `accessToken`: required valid accessToken of the security subject, which has been prepared in *Admin Tools* of Nightscout. + +```javascript +socket.on('connect', function () { + socket.emit('subscribe', { + accessToken: 'testadmin-ad3b1f9d7b3f59d5' + }, ... +``` + + +On the server, the subject is identified and authenticated (by the accessToken). Ne special rights are required. + +If the authentication was successful `success` = `true` is set in the response object and the field `message` contains a text response. +In other case `success` = `false` is set in the response object and the field `message` contains an error message. + +```javascript +function (data) { + if (data.success) { + console.log('subscribed for alarms', data.message); + } + else { + console.error(data.message); + } + }); +}); +``` + +### Acking alarms and announcements +If the client is successfully subscribed it can ack alarms and announcements by emitting `ack` message. + +```javascript + socket.emit('ack', level, group, silenceTimeInMilliseconds); +``` + +where `level` and `group` are values from alarm being acked and `silenceTimeInMilliseconds` is duration. During this time alarms of the same type are not emmited. + +### Receiving events +After the successful subscription the client can start listening to `announcement`, `alarm` , `urgent_alarm` and/or `clear_alarm` events of the socket. + + +##### announcement + +The received object contains similiar json: + +```javascript + { + "level":0, + "title":"Announcement", + "message":"test", + "plugin":{"name":"treatmentnotify","label":"Treatment Notifications","pluginType":"notification","enabled":true}, + "group":"Announcement", + "isAnnouncement":true, + "key":"9ac46ad9a1dcda79dd87dae418fce0e7955c68da" + } +``` + + +##### alarm, urgent_alarm + +The received object contains similiar json: + +```javascript + { + "level":1, + "title":"Warning HIGH", + "message":"BG Now: 5 -0.2 → mmol\/L\nRaw BG: 4.8 mmol\/L Čistý\nBG 15m: 4.8 mmol\/L\nIOB: -0.02U\nCOB: 0g", + "eventName":"high", + "plugin":{"name":"simplealarms","label":"Simple Alarms","pluginType":"notification","enabled":true}, + "pushoverSound":"climb", + "debug":{"lastSGV":5,"thresholds":{"bgHigh":180,"bgTargetTop":75,"bgTargetBottom":72,"bgLow":70}}, + "group":"default", + "key":"simplealarms_1" + } +``` + + +##### clear_alarm + +The received object contains similiar json: + +```javascript + { + "clear":true, + "title":"All Clear", + "message":"default - Urgent was ack'd", + "group":"default" + } +``` \ No newline at end of file diff --git a/lib/api3/index.js b/lib/api3/index.js index 83db322a452..2ba0aa762b1 100644 --- a/lib/api3/index.js +++ b/lib/api3/index.js @@ -3,7 +3,8 @@ const express = require('express') , bodyParser = require('body-parser') , renderer = require('./shared/renderer') - , StorageSocket = require('./storageSocket') + , storageSocket = require('./storageSocket') + , alarmSocket = require('./alarmSocket') , apiConst = require('./const.json') , security = require('./security') , genericSetup = require('./generic/setup') @@ -108,7 +109,8 @@ function configure (env, ctx) { opTools.sendJSONStatus(res, apiConst.HTTP.NOT_FOUND, apiConst.MSG.HTTP_404_BAD_OPERATION); }) - ctx.storageSocket = new StorageSocket(app, env, ctx); + ctx.storageSocket = new storageSocket(app, env, ctx); + ctx.alarmSocket = new alarmSocket(app, env, ctx); return app; } diff --git a/lib/client/index.js b/lib/client/index.js index fb1d17d1430..bee6134e947 100644 --- a/lib/client/index.js +++ b/lib/client/index.js @@ -153,6 +153,7 @@ client.load = function load (serverSettings, callback) { var chart , socket + , alarmSocket , isInitialData = false , opacity = { current: 1, DAY: 1, NIGHT: 0.5 } , clientAlarms = {} @@ -811,7 +812,7 @@ client.load = function load (serverSettings, callback) { // only emit ack if client invoke by button press if (isClient && currentNotify) { - socket.emit('ack', currentNotify.level, currentNotify.group, silenceTime); + alarmSocket.emit('ack', currentNotify.level, currentNotify.group, silenceTime); } currentNotify = null; @@ -1041,6 +1042,7 @@ client.load = function load (serverSettings, callback) { //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// /* global io */ client.socket = socket = io.connect({ transports: ["polling"] }); + client.alarmSocket = alarmSocket = io.connect("/alarm", { multiplex: true, transports: ["polling"] }); socket.on('dataUpdate', dataUpdate); @@ -1127,6 +1129,41 @@ client.load = function load (serverSettings, callback) { client.authorizeSocket(); }); + client.subscribeForAlarms = function subscribeForAlarms () { + + var auth_data = { + secret: client.authorized && client.authorized.token ? null : client.hashauth.hash() + , jwtToken: client.authorized && client.authorized.token + }; + + alarmSocket.emit( + 'subscribe' + , auth_data + , function subscribeCallback (data) { + if (!data) { + console.log('Crashed!'); + client.crashed(); + } + + console.log('Subscribed for alarms', data); + if (!data.success) { + client.hashauth.requestAuthentication(function afterRequest () { + client.hashauth.updateSocketAuth(); + if (callback) { + callback(); + } + }); + } else if (callback) { + callback(); + } + } + ); + } + + alarmSocket.on('connect', function() { + client.subscribeForAlarms(); + }); + function hasRequiredPermission () { if (client.requiredPermission) { if (client.hashauth && client.hashauth.isAuthenticated()) { @@ -1151,7 +1188,7 @@ client.load = function load (serverSettings, callback) { return client.latestSGV && client.latestSGV.mgdl <= client.settings.thresholds.bgTargetTop; } - socket.on('notification', function(notify) { + alarmSocket.on('notification', function(notify) { console.log('notification from server:', notify); if (notify.timestamp && previousNotifyTimestamp !== notify.timestamp) { previousNotifyTimestamp = notify.timestamp; @@ -1161,14 +1198,14 @@ client.load = function load (serverSettings, callback) { } }); - socket.on('announcement', function(notify) { + alarmSocket.on('announcement', function(notify) { console.info('announcement received from server'); currentAnnouncement = notify; currentAnnouncement.received = Date.now(); updateTitle(); }); - socket.on('alarm', function(notify) { + alarmSocket.on('alarm', function(notify) { console.info('alarm received from server'); var enabled = (isAlarmForHigh() && client.settings.alarmHigh) || (isAlarmForLow() && client.settings.alarmLow); if (enabled) { @@ -1180,7 +1217,7 @@ client.load = function load (serverSettings, callback) { chart.update(false); }); - socket.on('urgent_alarm', function(notify) { + alarmSocket.on('urgent_alarm', function(notify) { console.info('urgent alarm received from server'); var enabled = (isAlarmForHigh() && client.settings.alarmUrgentHigh) || (isAlarmForLow() && client.settings.alarmUrgentLow); if (enabled) { @@ -1192,7 +1229,7 @@ client.load = function load (serverSettings, callback) { chart.update(false); }); - socket.on('clear_alarm', function(notify) { + alarmSocket.on('clear_alarm', function(notify) { if (alarmInProgress) { console.log('clearing alarm'); stopAlarm(false, null, notify); diff --git a/lib/server/server.js b/lib/server/server.js index ea75d5ac636..49efaeb9394 100644 --- a/lib/server/server.js +++ b/lib/server/server.js @@ -70,14 +70,6 @@ require('./bootevent')(env, language).boot(function booted (ctx) { /////////////////////////////////////////////////// var websocket = require('./websocket')(env, ctx, server); - ctx.bus.on('data-processed', function() { - websocket.update(); - }); - - ctx.bus.on('notification', function(notify) { - websocket.emitNotification(notify); - }); - //after startup if there are no alarms send all clear let sendStartupAllClearTimer = setTimeout(function sendStartupAllClear () { var alarm = ctx.notifications.findHighestAlarm(); diff --git a/lib/server/websocket.js b/lib/server/websocket.js index 08ae6d662c0..2924db0554d 100644 --- a/lib/server/websocket.js +++ b/lib/server/websocket.js @@ -16,8 +16,6 @@ function init (env, ctx, server) { return websocket; } - var levels = ctx.levels; - //var log_yellow = '\x1B[33m'; var log_green = '\x1B[32m'; var log_magenta = '\x1B[35m'; @@ -95,6 +93,11 @@ function init (env, ctx, server) { }); io.close(); }); + + ctx.bus.on('data-processed', function() { + update(); + }); + } function verifyAuthorization (message, ip, callback) { @@ -143,10 +146,6 @@ function init (env, ctx, server) { console.log(LOG_WS + 'Connection from client ID: ', socket.client.id, ' IP: ', remoteIP); io.emit('clients', ++watchers); - socket.on('ack', function onAck (level, group, silenceTime) { - ctx.notifications.ack(level, group, silenceTime, true); - }); - socket.on('disconnect', function onDisconnect () { io.emit('clients', --watchers); console.log(LOG_WS + 'Disconnected client ID: ', socket.client.id); @@ -569,23 +568,10 @@ function init (env, ctx, server) { } }); }); - - // Pind message - // { - // mills: - // } - socket.on('nsping', function ping (message, callback) { - var clientTime = message.mills; - timeDiff = new Date().getTime() - clientTime; - // console.log(LOG_WS + 'Ping from client ID: ',socket.client.id, ' client: ', clientType, ' timeDiff: ', (timeDiff/1000).toFixed(1) + 'sec'); - if (callback) { - callback({ result: 'pong', mills: new Date().getTime(), authorization: socketAuthorization }); - } - }); }); } - websocket.update = function update () { + function update () { // console.log(LOG_WS + 'running websocket.update'); if (lastData.sgvs) { var delta = calcData(lastData, ctx.ddata); @@ -598,25 +584,6 @@ function init (env, ctx, server) { lastData = ctx.ddata.clone(); }; - websocket.emitNotification = function emitNotification (notify) { - if (notify.clear) { - io.emit('clear_alarm', notify); - console.info(LOG_WS + 'emitted clear_alarm to all clients'); - } else if (notify.level === levels.WARN) { - io.emit('alarm', notify); - console.info(LOG_WS + 'emitted alarm to all clients'); - } else if (notify.level === levels.URGENT) { - io.emit('urgent_alarm', notify); - console.info(LOG_WS + 'emitted urgent_alarm to all clients'); - } else if (notify.isAnnouncement) { - io.emit('announcement', notify); - console.info(LOG_WS + 'emitted announcement to all clients'); - } else { - io.emit('notification', notify); - console.info(LOG_WS + 'emitted notification to all clients'); - } - }; - start(); listeners(); @@ -624,6 +591,10 @@ function init (env, ctx, server) { ctx.storageSocket.init(io); } + if (ctx.alarmSocket) { + ctx.alarmSocket.init(io); + } + return websocket(); }