Skip to content

Commit

Permalink
Alarm sockets for api v3 (nightscout#7858)
Browse files Browse the repository at this point in the history
* Alarm sockets for api v3

* Migrate to alarm websockets

* Fix unit tests

---------

Co-authored-by: Sulka Haro <sulka@sulka.net>
  • Loading branch information
MilosKozak and sulkaharo committed Feb 18, 2023
1 parent 4e1f364 commit 89d7eb6
Show file tree
Hide file tree
Showing 6 changed files with 360 additions and 55 deletions.
152 changes: 152 additions & 0 deletions 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;
151 changes: 151 additions & 0 deletions lib/api3/doc/alarmsockets.md
@@ -0,0 +1,151 @@
# APIv3: Socket.IO alarm channel

### Complete sample client code
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

<title>APIv3 Socket.IO sample for alarms</title>

<link rel="icon" href="images/favicon.png" />
</head>

<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.2.0/socket.io.js"></script>

<script>
const socket = io('https://nsapiv3.herokuapp.com/alarm');
socket.on('connect', function () {
socket.emit('subscribe', {
accessToken: 'testadmin-ad3b1f9d7b3f59d5'
}, function (data) {
if (data.success) {
console.log('subscribed for alarms', data.message);
}
else {
console.error(data.message);
}
});
});
socket.on('announcement', function (data) {
console.log(data);
});
socket.on('alarm', function (data) {
console.log(data);
});
socket.on('urgent_alarm', function (data) {
console.log(data);
});
socket.on('clear_alarm', function (data) {
console.log(data);
});
</script>
</body>
</html>
```

### 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"
}
```
6 changes: 4 additions & 2 deletions lib/api3/index.js
Expand Up @@ -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')
Expand Down Expand Up @@ -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;
}
Expand Down

0 comments on commit 89d7eb6

Please sign in to comment.