nightscout
Version:
Nightscout acts as a web-based CGM (Continuous Glucose Monitor) to allow multiple caregivers to remotely view a patients glucose data in realtime.
638 lines (529 loc) • 18.6 kB
JavaScript
'use strict';
var _ = require('lodash');
var moment = require('moment');
var times = require('../times');
// var ALL_STATUS_FIELDS = ['status-symbol', 'status-label', 'iob', 'freq', 'rssi']; Unused variable
function init (ctx) {
var utils = require('../utils')(ctx);
var translate = ctx.language.translate;
var levels = ctx.levels;
var loop = {
name: 'loop'
, label: 'Loop'
, pluginType: 'pill-status'
};
var firstPrefs = true;
loop.getPrefs = function getPrefs (sbx) {
var prefs = {
warn: sbx.extendedSettings.warn ? sbx.extendedSettings.warn : 30
, urgent: sbx.extendedSettings.urgent ? sbx.extendedSettings.urgent : 60
, enableAlerts: sbx.extendedSettings.enableAlerts
};
if (firstPrefs) {
firstPrefs = false;
console.info(' Prefs:', prefs);
}
return prefs;
};
loop.setProperties = function setProperties (sbx) {
sbx.offerProperty('loop', function setLoop () {
return loop.analyzeData(sbx);
});
};
loop.analyzeData = function analyzeData (sbx) {
var recentHours = 6;
var recentMills = sbx.time - times.hours(recentHours).msecs;
var recentData = _.chain(sbx.data.devicestatus)
.filter(function(status) {
return ('loop' in status) && sbx.entryMills(status) <= sbx.time && sbx.entryMills(status) >= recentMills;
}).value();
var prefs = loop.getPrefs(sbx);
var recent = moment(sbx.time).subtract(prefs.warn / 2, 'minutes');
function getDisplayForStatus (status) {
var desc = {
symbol: '⚠'
, code: 'warning'
, label: 'Warning'
};
if (!status) {
return desc;
}
if (status.failureReason || (status.enacted && !status.enacted.received)) {
desc.symbol = 'x';
desc.code = 'error';
desc.label = 'Error';
} else if (status.enacted && moment(status.timestamp).isAfter(recent)) {
desc.symbol = '⌁';
desc.code = 'enacted';
desc.label = 'Enacted';
} else if (status.recommendedTempBasal && moment(status.recommendedTempBasal.timestamp).isAfter(recent)) {
desc.symbol = '⏀';
desc.code = 'recommendation';
desc.label = 'Recomendation';
} else if (status.moment && status.moment.isAfter(recent)) {
desc.symbol = '↻';
desc.code = 'looping';
desc.label = 'Looping';
}
return desc;
}
var result = {
lastLoop: null
, lastEnacted: null
, lastPredicted: null
, lastOkMoment: null
};
function assignLastEnacted (loopStatus) {
var enacted = loopStatus.enacted;
if (enacted && enacted.timestamp) {
enacted.moment = moment(enacted.timestamp);
if (!result.lastEnacted || enacted.moment.isAfter(result.lastEnacted.moment)) {
result.lastEnacted = enacted;
}
}
}
function assignLastPredicted (loopStatus) {
if (loopStatus.predicted && loopStatus.predicted.startDate) {
result.lastPredicted = loopStatus.predicted;
}
}
function assignLastLoop (loopStatus) {
if (!result.lastLoop || loopStatus.moment.isAfter(result.lastLoop.moment)) {
result.lastLoop = loopStatus;
}
}
function assignLastOverride (status) {
var override = status.override;
if (override && override.timestamp) {
override.moment = moment(override.timestamp);
if (!result.lastOverride || override.moment.isAfter(result.lastOverride.moment)) {
result.lastOverride = override;
}
}
}
function assignLastOkMoment (loopStatus) {
if (!loopStatus.failureReason && (!result.lastOkMoment || loopStatus.moment.isAfter(result.lastOkMoment))) {
result.lastOkMoment = loopStatus.moment;
}
}
_.forEach(recentData, function eachStatus (status) {
if (status && status.loop && status.loop.timestamp) {
var loopStatus = status.loop;
loopStatus.moment = moment(loopStatus.timestamp);
assignLastEnacted(loopStatus);
assignLastLoop(loopStatus);
assignLastPredicted(loopStatus);
assignLastOverride(status);
assignLastOkMoment(loopStatus);
}
});
result.display = getDisplayForStatus(result.lastLoop);
return result;
};
loop.checkNotifications = function checkNotifications (sbx) {
var prefs = loop.getPrefs(sbx);
if (!prefs.enableAlerts) { return; }
var prop = sbx.properties.loop;
if (!prop.lastLoop) {
console.info('Loop hasn\'t reported a loop yet');
return;
}
var now = moment();
var level = statusLevel(prop, prefs, sbx);
if (level >= levels.WARN) {
sbx.notifications.requestNotify({
level: level
, title: 'Loop isn\'t looping'
, message: 'Last Loop: ' + utils.formatAgo(prop.lastOkMoment, now.valueOf())
, pushoverSound: 'echo'
, group: 'Loop'
, plugin: loop
, debug: prop
});
}
};
loop.getEventTypes = function getEventTypes (sbx) {
var units = sbx.settings.units;
console.log('units', units);
var reasonconf = [];
if (sbx.data === undefined || sbx.data.profile === undefined || sbx.data.profile.data.length == 0) {
return [];
}
let profile = sbx.data.profile.data[0];
if (profile.loopSettings === undefined || profile.loopSettings.overridePresets == undefined) {
return [];
}
let presets = profile.loopSettings.overridePresets;
for (var i = 0; i < presets.length; i++) {
let preset = presets[i]
reasonconf.push({ name: preset.name, displayName: preset.symbol + " " + preset.name, duration: preset.duration / 60});
}
var postLoopNotification = function (client, data, callback) {
$.ajax({
method: "POST"
, headers: client.headers()
, url: '/api/v2/notifications/loop'
, data: data
})
.done(function () {
callback();
})
.fail(function (jqXHR) {
callback(jqXHR.responseText);
});
}
// TODO: add OTP entry
return [
{
val: 'Temporary Override'
, name: 'Temporary Override'
, bg: false
, insulin: false
, carbs: false
, prebolus: false
, duration: true
, percent: false
, absolute: false
, profile: false
, split: false
, targets: false
, reasons: reasonconf
, otp: true
, submitHook: postLoopNotification
},
{
val: 'Temporary Override Cancel'
, name: 'Temporary Override Cancel'
, bg: false
, insulin: false
, carbs: false
, prebolus: false
, duration: false
, percent: false
, absolute: false
, profile: false
, split: false
, targets: false
, submitHook: postLoopNotification
},
{
val: 'Remote Carbs Entry'
, name: 'Remote Carbs Entry'
, remoteCarbs: true
, remoteAbsorption: true
, otp: true
, submitHook: postLoopNotification
},
{
val: 'Remote Bolus Entry'
, name: 'Remote Bolus Entry'
, remoteBolus: true
, otp: true
, submitHook: postLoopNotification
}
];
};
// TODO: Add event listener to customize labels
loop.updateVisualisation = function updateVisualisation (sbx) {
var prop = sbx.properties.loop;
var prefs = loop.getPrefs(sbx);
function valueString (prefix, value) {
return (value != null) ? prefix + value : '';
}
var events = [];
function addRecommendedTempBasal () {
if (prop.lastLoop && prop.lastLoop.recommendedTempBasal) {
var recommendedTempBasal = prop.lastLoop.recommendedTempBasal;
var valueParts = [
'Suggested Temp: ' + recommendedTempBasal.rate + 'U/hour for ' +
recommendedTempBasal.duration + 'm'
];
valueParts = concatIOB(valueParts);
valueParts = concatCOB(valueParts);
valueParts = concatEventualBG(valueParts);
valueParts = concatRecommendedBolus(valueParts);
events.push({
time: moment(recommendedTempBasal.timestamp)
, value: valueParts.join('')
});
}
}
function addRSSI () {
var mostRecent = "";
var pumpRSSI = "";
var bleRSSI = "";
var reportRSSI = "";
_.forEach(sbx.data.devicestatus, function(entry) {
if (entry.radioAdapter) {
var entryMoment = moment(entry.created_at);
if (mostRecent == "") {
mostRecent = entryMoment;
if (entry.radioAdapter.pumpRSSI) {
pumpRSSI = entry.radioAdapter.pumpRSSI;
}
if (entry.radioAdapter.RSSI) {
bleRSSI = entry.radioAdapter.RSSI;
}
}
if (mostRecent < entryMoment) {
mostRecent = entryMoment;
if (entry.radioAdapter.pumpRSSI) {
pumpRSSI = entry.radioAdapter.pumpRSSI;
}
if (entry.radioAdapter.RSSI) {
bleRSSI = entry.radioAdapter.RSSI;
}
}
}
});
if (bleRSSI != "") {
reportRSSI = "BLE RSSI: " + bleRSSI + " ";
}
if (pumpRSSI != "") {
reportRSSI = reportRSSI + "Pump RSSI: " + pumpRSSI;
}
if (reportRSSI != "") {
events.push({
time: mostRecent
, value: reportRSSI
});
}
}
function addLastEnacted () {
if (prop.lastEnacted) {
var canceled = prop.lastEnacted.rate === 0 && prop.lastEnacted.duration === 0;
var valueParts = [
'<b>Temp Basal' + (canceled ? ' Canceled' : ' Started') + '</b>'
, canceled ? '' : ' ' + prop.lastEnacted.rate.toFixed(2) + 'U/hour for ' + prop.lastEnacted.duration + 'm'
, valueString(', ', prop.lastEnacted.reason)
];
valueParts = concatIOB(valueParts);
valueParts = concatCOB(valueParts);
valueParts = concatEventualBG(valueParts);
valueParts = concatRecommendedBolus(valueParts);
events.push({
time: prop.lastEnacted.moment
, value: valueParts.join('')
});
}
}
function concatIOB (valueParts) {
if (prop.lastLoop && prop.lastLoop.iob) {
var iob = prop.lastLoop.iob;
valueParts = valueParts.concat([
', IOB: '
, sbx.roundInsulinForDisplayFormat(iob.iob) + 'U'
, iob.basaliob ? ', Basal IOB ' + sbx.roundInsulinForDisplayFormat(iob.basaliob) + 'U' : ''
]);
}
return valueParts;
}
function concatCOB (valueParts) {
if (prop.lastLoop && prop.lastLoop.cob) {
var cob = prop.lastLoop.cob.cob;
cob = Math.round(cob);
valueParts = valueParts.concat([
', COB: '
, cob + 'g'
]);
}
return valueParts;
}
function concatEventualBG (valueParts) {
if (prop.lastLoop && prop.lastLoop.predicted) {
var predictedBGvalues = prop.lastLoop.predicted.values;
var eventualBG = predictedBGvalues[predictedBGvalues.length - 1];
var maxBG = Math.max.apply(null, predictedBGvalues);
var minBG = Math.min.apply(null, predictedBGvalues);
var eventualBGscaled = sbx.settings.units === 'mmol' ?
sbx.roundBGToDisplayFormat(sbx.scaleMgdl(eventualBG)) : eventualBG;
var maxBGscaled = sbx.settings.units === 'mmol' ?
sbx.roundBGToDisplayFormat(sbx.scaleMgdl(maxBG)) : maxBG;
var minBGscaled = sbx.settings.units === 'mmol' ?
sbx.roundBGToDisplayFormat(sbx.scaleMgdl(minBG)) : minBG;
valueParts = valueParts.concat([
', Predicted Min-Max BG: '
, minBGscaled
, '-'
, maxBGscaled
, ', Eventual BG: '
, eventualBGscaled
]);
}
return valueParts;
}
function concatRecommendedBolus (valueParts) {
if (prop.lastLoop && prop.lastLoop.recommendedBolus) {
var recommendedBolus = prop.lastLoop.recommendedBolus;
valueParts = valueParts.concat([
', Recommended Bolus: '
, recommendedBolus + 'U'
]);
}
return valueParts;
}
function getForecastPoints () {
var points = [];
function toPoints (startTime, offset) {
return function toPoint (value, index) {
return {
mgdl: value
, color: '#ff00ff'
, mills: startTime.valueOf() + times.mins(5 * index).msecs + offset
, noFade: true
};
};
}
if (prop.lastPredicted) {
var predicted = prop.lastPredicted;
var startTime = moment(predicted.startDate);
if (predicted.values) {
points = points.concat(_.map(predicted.values, toPoints(startTime, 0)));
}
}
return points;
}
if ('error' === prop.display.code) {
events.push({
time: prop.lastLoop.moment
, value: valueString('Error: ', prop.lastLoop.failureReason)
});
addRecommendedTempBasal();
} else if ('enacted' === prop.display.code) {
addLastEnacted();
} else if ('looping' === prop.display.code) {
addLastEnacted();
} else {
addRecommendedTempBasal();
}
addRSSI();
var sorted = _.sortBy(events, function toMill (event) {
return event.time.valueOf();
}).reverse();
var info = _.map(sorted, function eventToInfo (event) {
return {
label: utils.timeAt(false, sbx) + utils.timeFormat(event.time, sbx)
, value: event.value
};
});
var loopName = 'Loop';
if (prop.lastLoop && prop.lastLoop.name) {
loopName = prop.lastLoop.name;
}
var eventualBGValue = '';
if (prop.lastLoop && prop.lastLoop.predicted) {
var predictedBGvalues = prop.lastLoop.predicted.values;
var eventualBG = predictedBGvalues[predictedBGvalues.length - 1];
if (sbx.settings.units === 'mmol') {
eventualBG = sbx.roundBGToDisplayFormat(sbx.scaleMgdl(eventualBG));
}
eventualBGValue = ' ↝ ' + eventualBG;
}
var label = loopName + ' ' + prop.display.symbol;
var lastLoopValue = prop.lastLoop ?
utils.timeFormat(prop.lastLoop.moment, sbx) + eventualBGValue : null;
sbx.pluginBase.updatePillText(loop, {
value: lastLoopValue
, label: label
, info: info
, pillClass: statusClass(prop, prefs, sbx)
});
var forecastPoints = getForecastPoints();
if (forecastPoints && forecastPoints.length > 0) {
sbx.pluginBase.addForecastPoints(forecastPoints, { type: 'loop', label: 'Loop Forecasts' });
}
};
function virtAsstForecastHandler (next, slots, sbx) {
var predicted = _.get(sbx, 'properties.loop.lastLoop.predicted');
if (predicted) {
var forecast = predicted.values;
var max = forecast[0];
var min = forecast[0];
var maxForecastIndex = Math.min(6, forecast.length);
var startPrediction = moment(predicted.startDate);
var endPrediction = startPrediction.clone().add(maxForecastIndex * 5, 'minutes');
if (endPrediction.valueOf() < sbx.time) {
next(translate('virtAsstTitleLoopForecast'), translate('virtAsstForecastUnavailable'));
} else {
for (var i = 1, len = forecast.slice(0, maxForecastIndex).length; i < len; i++) {
if (forecast[i] > max) {
max = forecast[i];
}
if (forecast[i] < min) {
min = forecast[i];
}
}
var response = '';
if (min === max) {
response = translate('virtAsstLoopForecastAround', {
params: [
max
, moment(endPrediction).from(moment(sbx.time))
]
});
} else {
response = translate('virtAsstLoopForecastBetween', {
params: [
min
, max
, moment(endPrediction).from(moment(sbx.time))
]
});
}
next(translate('virtAsstTitleLoopForecast'), response);
}
} else {
next(translate('virtAsstTitleLoopForecast'), translate('virtAsstUnknown'));
}
}
function virtAsstLastLoopHandler (next, slots, sbx) {
var lastLoop = _.get(sbx, 'properties.loop.lastLoop')
if (lastLoop) {
console.log(JSON.stringify(lastLoop));
var response = translate('virtAsstLastLoop', {
params: [
moment(sbx.properties.loop.lastOkMoment).from(moment(sbx.time))
]
});
next(translate('virtAsstTitleLastLoop'), response);
} else {
next(translate('virtAsstTitleLastLoop'), translate('virtAsstUnknown'));
}
}
loop.virtAsst = {
intentHandlers: [{
intent: 'MetricNow'
, metrics: ['loop forecast', 'forecast']
, intentHandler: virtAsstForecastHandler
}, {
intent: 'LastLoop'
, intentHandler: virtAsstLastLoopHandler
}]
};
function statusClass (prop, prefs, sbx) {
var level = statusLevel(prop, prefs, sbx);
var cls = 'current';
if (level === levels.WARN) {
cls = 'warn';
} else if (level === levels.URGENT) {
cls = 'urgent';
}
return cls;
}
function statusLevel (prop, prefs, sbx) {
var level = levels.NONE;
var now = moment(sbx.time);
if (prop.lastOkMoment) {
var urgentTime = prop.lastOkMoment.clone().add(prefs.urgent, 'minutes');
var warningTime = prop.lastOkMoment.clone().add(prefs.warn, 'minutes');
if (urgentTime.isBefore(now)) {
level = levels.URGENT;
} else if (warningTime.isBefore(now)) {
level = levels.WARN;
}
}
return level;
}
return loop;
}
module.exports = init;