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.
631 lines (535 loc) • 21.1 kB
JavaScript
'use strict';
var _ = require('lodash');
var moment = require('moment');
var times = require('../times');
var consts = require('../constants');
// var ALL_STATUS_FIELDS = ['status-symbol', 'status-label', 'iob', 'meal-assist', 'freq', 'rssi']; Unused variable
function init (ctx) {
var utils = require('../utils')(ctx);
var openaps = {
name: 'openaps'
, label: 'OpenAPS'
, pluginType: 'pill-status'
};
var translate = ctx.language.translate;
var firstPrefs = true;
var levels = ctx.levels;
openaps.getClientPrefs = function getClientPrefs() {
return ([{
label: "Color prediction lines",
id: "colorPredictionLines",
type: "boolean"
}]);
}
openaps.getPrefs = function getPrefs (sbx) {
function cleanList (value) {
return decodeURIComponent(value || '').toLowerCase().split(' ');
}
function isEmpty (list) {
return _.isEmpty(list) || _.isEmpty(list[0]);
}
const settings = sbx.extendedSettings || {};
var fields = cleanList(settings.fields);
fields = isEmpty(fields) ? ['status-symbol', 'status-label', 'iob', 'meal-assist', 'rssi'] : fields;
var retroFields = cleanList(settings.retroFields);
retroFields = isEmpty(retroFields) ? ['status-symbol', 'status-label', 'iob', 'meal-assist', 'rssi'] : retroFields;
if (typeof settings.colorPredictionLines == 'undefined') {
settings.colorPredictionLines = true;
}
var prefs = {
fields: fields
, retroFields: retroFields
, warn: settings.warn ? settings.warn : 30
, urgent: settings.urgent ? settings.urgent : 60
, enableAlerts: settings.enableAlerts
, predIOBColor: settings.predIobColor ? settings.predIobColor : '#1e88e5'
, predCOBColor: settings.predCobColor ? settings.predCobColor : '#FB8C00'
, predACOBColor: settings.predAcobColor ? settings.predAcobColor : '#FB8C00'
, predZTColor: settings.predZtColor ? settings.predZtColor : '#00d2d2'
, predUAMColor: settings.predUamColor ? settings.predUamColor : '#c9bd60'
, colorPredictionLines: settings.colorPredictionLines
};
if (firstPrefs) {
firstPrefs = false;
}
return prefs;
};
openaps.setProperties = function setProperties (sbx) {
sbx.offerProperty('openaps', function setOpenAPS () {
return openaps.analyzeData(sbx);
});
};
openaps.analyzeData = function analyzeData (sbx) {
var recentHours = 6; //TODO dia*2
var recentMills = sbx.time - times.hours(recentHours).msecs;
var recentData = _.chain(sbx.data.devicestatus)
.filter(function(status) {
return ('openaps' in status) && sbx.entryMills(status) <= sbx.time && sbx.entryMills(status) >= recentMills;
})
.map(function(status) {
if (status.openaps && _.isArray(status.openaps.iob) && status.openaps.iob.length > 0) {
status.openaps.iob = status.openaps.iob[0];
if (status.openaps.iob.time) {
status.openaps.iob.timestamp = status.openaps.iob.time;
}
}
return status;
})
.value();
var prefs = openaps.getPrefs(sbx);
var recent = moment(sbx.time).subtract(prefs.warn / 2, 'minutes');
var result = {
seenDevices: {}
, lastEnacted: null
, lastNotEnacted: null
, lastSuggested: null
, lastIOB: null
, lastMMTune: null
, lastPredBGs: null
};
function getDevice (status) {
var uri = status.device || 'device';
var device = result.seenDevices[uri];
if (!device) {
device = {
name: utils.deviceName(uri)
, uri: uri
};
result.seenDevices[uri] = device;
}
return device;
}
function toMoments (status) {
var enacted = false;
var notEnacted = false;
if (status.openaps.enacted && status.openaps.enacted.timestamp && (status.openaps.enacted.recieved || status.openaps.enacted.received)) {
if (status.openaps.enacted.mills) {
enacted = moment(status.openaps.enacted.mills);
} else {
enacted = moment(status.openaps.enacted.timestamp);
}
} else if (status.openaps.enacted && status.openaps.enacted.timestamp && !(status.openaps.enacted.recieved || status.openaps.enacted.received)) {
if (status.openaps.enacted.mills) {
notEnacted = moment(status.openaps.enacted.mills)
} else {
notEnacted = moment(status.openaps.enacted.timestamp)
}
}
var suggested = false;
if (status.openaps.suggested && status.openaps.suggested.mills) {
suggested = moment(status.openaps.suggested.mills);
} else if (status.openaps.suggested && status.openaps.suggested.timestamp) {
suggested = moment(status.openaps.suggested.timestamp);
}
var iob = false;
if (status.openaps.iob && status.openaps.iob.mills) {
iob = moment(status.openaps.iob.mills);
} else if (status.openaps.iob && status.openaps.iob.timestamp) {
iob = moment(status.openaps.iob.timestamp);
}
return {
when: moment(status.mills)
, enacted
, notEnacted
, suggested
, iob
};
}
function momentsToLoopStatus (moments, noWarning) {
var status = {
symbol: '⚠'
, code: 'warning'
, label: 'Warning'
};
if (moments.notEnacted && (
(moments.enacted && moments.notEnacted.isAfter(moments.enacted)) || (!moments.enacted && moments.notEnacted.isAfter(recent)))) {
status.symbol = 'x';
status.code = 'notenacted';
status.label = 'Not Enacted';
} else if (moments.enacted && moments.enacted.isAfter(recent)) {
status.symbol = '⌁';
status.code = 'enacted';
status.label = 'Enacted';
} else if (moments.suggested && moments.suggested.isAfter(recent)) {
status.symbol = '↻';
status.code = 'looping';
status.label = 'Looping';
} else if (moments.when && (noWarning || moments.when.isAfter(recent))) {
status.symbol = '◉';
status.code = 'waiting';
status.label = 'Waiting';
}
return status;
}
_.forEach(recentData, function eachStatus (status) {
var device = getDevice(status);
var moments = toMoments(status);
var loopStatus = momentsToLoopStatus(moments, true);
if (!device.status || moments.when.isAfter(device.status.when)) {
device.status = loopStatus;
device.status.when = moments.when;
}
var enacted = status.openaps && status.openaps.enacted;
if (enacted && moments.enacted && (!result.lastEnacted || moments.enacted.isAfter(result.lastEnacted.moment))) {
if (enacted.mills) {
enacted.moment = moment(enacted.mills);
} else {
enacted.moment = moment(enacted.timestamp);
}
result.lastEnacted = enacted;
if (enacted.predBGs && (!result.lastPredBGs || enacted.moment.isAfter(result.lastPredBGs.moment))) {
result.lastPredBGs = _.isArray(enacted.predBGs) ? { values: enacted.predBGs } : enacted.predBGs;
result.lastPredBGs.moment = enacted.moment;
}
}
if (enacted && moments.notEnacted && (!result.lastNotEnacted || moments.notEnacted.isAfter(result.lastNotEnacted.moment))) {
if (enacted.mills) {
enacted.moment = moment(enacted.mills);
} else {
enacted.moment = moment(enacted.timestamp);
}
result.lastNotEnacted = enacted;
}
var suggested = status.openaps && status.openaps.suggested;
if (suggested && moments.suggested && (!result.lastSuggested || moments.suggested.isAfter(result.lastSuggested.moment))) {
if (suggested.mills) {
suggested.moment = moment(suggested.mills);
} else {
suggested.moment = moment(suggested.timestamp);
}
result.lastSuggested = suggested;
if (suggested.predBGs && (!result.lastPredBGs || suggested.moment.isAfter(result.lastPredBGs.moment))) {
result.lastPredBGs = _.isArray(suggested.predBGs) ? { values: suggested.predBGs } : suggested.predBGs;
result.lastPredBGs.moment = suggested.moment;
}
}
var iob = status.openaps && status.openaps.iob;
if (moments.iob && (!result.lastIOB || moment(iob.timestamp).isAfter(result.lastIOB.moment))) {
iob.moment = moments.iob;
result.lastIOB = iob;
}
if (status.mmtune && status.mmtune.timestamp) {
status.mmtune.moment = moment(status.mmtune.timestamp);
if (!device.mmtune || moments.when.isAfter(device.mmtune.moment)) {
device.mmtune = status.mmtune;
}
}
});
if (result.lastEnacted && result.lastSuggested) {
if (result.lastEnacted.moment.isAfter(result.lastSuggested.moment)) {
result.lastLoopMoment = result.lastEnacted.moment;
result.lastEventualBG = result.lastEnacted.eventualBG;
} else {
result.lastLoopMoment = result.lastSuggested.moment;
result.lastEventualBG = result.lastSuggested.eventualBG;
}
} else if (result.lastEnacted && result.lastEnacted.moment) {
result.lastLoopMoment = result.lastEnacted.moment;
result.lastEventualBG = result.lastEnacted.eventualBG;
} else if (result.lastSuggested && result.lastSuggested.moment) {
result.lastLoopMoment = result.lastSuggested.moment;
result.lastEventualBG = result.lastSuggested.eventualBG;
}
result.status = momentsToLoopStatus({
enacted: result.lastEnacted && result.lastEnacted.moment
, notEnacted: result.lastNotEnacted && result.lastNotEnacted.moment
, suggested: result.lastSuggested && result.lastSuggested.moment
}, false, recent);
return result;
};
openaps.getEventTypes = function getEventTypes (sbx) {
var units = sbx.settings.units;
console.log('units', units);
var reasonconf = [];
if (units == 'mmol') {
reasonconf.push({ name: translate('Eating Soon'), targetTop: 4.5, targetBottom: 4.5, duration: 60 });
reasonconf.push({ name: translate('Activity'), targetTop: 8, targetBottom: 6.5, duration: 120 });
} else {
reasonconf.push({ name: translate('Eating Soon'), targetTop: 80, targetBottom: 80, duration: 60 });
reasonconf.push({ name: translate('Activity'), targetTop: 140, targetBottom: 120, duration: 120 });
}
reasonconf.push({ name: 'Manual' });
return [
{
val: 'Temporary Target'
, name: 'Temporary Target'
, bg: false
, insulin: false
, carbs: false
, prebolus: false
, duration: true
, percent: false
, absolute: false
, profile: false
, split: false
, targets: true
, reasons: reasonconf
}
, {
val: 'Temporary Target Cancel'
, name: 'Temporary Target Cancel'
, bg: false
, insulin: false
, carbs: false
, prebolus: false
, duration: false
, percent: false
, absolute: false
, profile: false
, split: false
}
, {
val: 'OpenAPS Offline'
, name: 'OpenAPS Offline'
, bg: false
, insulin: false
, carbs: false
, prebolus: false
, duration: true
, percent: false
, absolute: false
, profile: false
, split: false
}
];
};
openaps.checkNotifications = function checkNotifications (sbx) {
var prefs = openaps.getPrefs(sbx);
if (!prefs.enableAlerts) { return; }
var prop = sbx.properties.openaps;
if (!prop.lastLoopMoment) {
console.info('OpenAPS 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: 'OpenAPS isn\'t looping'
, message: 'Last Loop: ' + utils.formatAgo(prop.lastLoopMoment, now.valueOf())
, pushoverSound: 'echo'
, group: 'OpenAPS'
, plugin: openaps
, debug: prop
});
}
};
openaps.findOfflineMarker = function findOfflineMarker (sbx) {
return _.findLast(sbx.data.treatments, function match (treatment) {
var eventTime = sbx.entryMills(treatment);
var eventEnd = treatment.duration ? eventTime + times.mins(treatment.duration).msecs : eventTime;
return eventTime <= sbx.time && treatment.eventType === 'OpenAPS Offline' && eventEnd >= sbx.time;
});
};
openaps.updateVisualisation = function updateVisualisation (sbx) {
var prop = sbx.properties.openaps;
var prefs = openaps.getPrefs(sbx);
var selectedFields = sbx.data.inRetroMode ? prefs.retroFields : prefs.fields;
function valueString (prefix, value) {
return value ? prefix + value : '';
}
var events = [];
function addSuggestion () {
if (prop.lastSuggested) {
var bg = prop.lastSuggested.bg;
var units = sbx.data.profile.getUnits();
if (units === 'mmol') {
bg = Math.round(bg / consts.MMOL_TO_MGDL * 10) / 10;
}
var valueParts = [
valueString('BG: ', bg)
, valueString(', ', prop.lastSuggested.reason)
, prop.lastSuggested.sensitivityRatio ? ', <b>Sensitivity Ratio:</b> ' + prop.lastSuggested.sensitivityRatio : ''
];
if (_.includes(selectedFields, 'iob')) {
valueParts = concatIOB(valueParts);
}
events.push({
time: prop.lastSuggested.moment
, value: valueParts.join('')
});
}
}
function concatIOB (valueParts) {
if (prop.lastIOB) {
valueParts = valueParts.concat([
', IOB: '
, sbx.roundInsulinForDisplayFormat(prop.lastIOB.iob) + 'U'
, prop.lastIOB.basaliob ? ', Basal IOB ' + sbx.roundInsulinForDisplayFormat(prop.lastIOB.basaliob) + 'U' : ''
, prop.lastIOB.bolusiob ? ', Bolus IOB ' + sbx.roundInsulinForDisplayFormat(prop.lastIOB.bolusiob) + 'U' : ''
]);
}
return valueParts;
}
function getForecastPoints () {
var points = [];
function toPoints (offset, forecastType) {
return function toPoint (value, index) {
var colors = {
'Values': '#ff00ff'
, 'IOB': prefs.predIOBColor
, 'Zero-Temp': prefs.predZTColor
, 'COB': prefs.predCOBColor
, 'Accel-COB': prefs.predACOBColor
, 'UAM': prefs.predUAMColor
}
return {
mgdl: value
, color: prefs.colorPredictionLines ? colors[forecastType] : '#ff00ff'
, mills: prop.lastPredBGs.moment.valueOf() + times.mins(5 * index).msecs + offset
, noFade: true
, forecastType: forecastType
};
};
}
if (prop.lastPredBGs) {
if (prop.lastPredBGs.values) {
points = points.concat(_.map(prop.lastPredBGs.values, toPoints(0, "Values")));
}
if (prop.lastPredBGs.IOB) {
points = points.concat(_.map(prop.lastPredBGs.IOB, toPoints(3333, "IOB")));
}
if (prop.lastPredBGs.ZT) {
points = points.concat(_.map(prop.lastPredBGs.ZT, toPoints(4444, "Zero-Temp")));
}
if (prop.lastPredBGs.aCOB) {
points = points.concat(_.map(prop.lastPredBGs.aCOB, toPoints(5555, "Accel-COB")));
}
if (prop.lastPredBGs.COB) {
points = points.concat(_.map(prop.lastPredBGs.COB, toPoints(7777, "COB")));
}
if (prop.lastPredBGs.UAM) {
points = points.concat(_.map(prop.lastPredBGs.UAM, toPoints(9999, "UAM")));
}
}
return points;
}
if ('enacted' === prop.status.code) {
var canceled = prop.lastEnacted.rate === 0 && prop.lastEnacted.duration === 0;
var valueParts = [
valueString('BG: ', prop.lastEnacted.bg)
, ', <b>Temp Basal' + (canceled ? ' Canceled' : ' Started') + '</b>'
, canceled ? '' : ' ' + prop.lastEnacted.rate.toFixed(2) + ' for ' + prop.lastEnacted.duration + 'm'
, valueString(', ', prop.lastEnacted.reason)
, prop.lastEnacted.mealAssist && _.includes(selectedFields, 'meal-assist') ? ' <b>Meal Assist:</b> ' + prop.lastEnacted.mealAssist : ''
];
if (prop.lastSuggested && prop.lastSuggested.moment.isAfter(prop.lastEnacted.moment)) {
addSuggestion();
} else {
valueParts = concatIOB(valueParts);
}
events.push({
time: prop.lastEnacted.moment
, value: valueParts.join('')
});
} else {
addSuggestion();
}
_.forIn(prop.seenDevices, function seenDevice (device) {
var deviceInfo = [device.name];
if (_.includes(selectedFields, 'status-symbol')) {
deviceInfo.push(device.status.symbol);
}
if (_.includes(selectedFields, 'status-label')) {
deviceInfo.push(device.status.label);
}
if (device.mmtune) {
var best = _.maxBy(device.mmtune.scanDetails, function(d) {
return d[2];
});
if (_.includes(selectedFields, 'freq')) {
deviceInfo.push(device.mmtune.setFreq + 'MHz');
}
if (best && best.length > 2 && _.includes(selectedFields, 'rssi')) {
deviceInfo.push('@ ' + best[2] + 'dB');
}
}
events.push({
time: device.status.when
, value: deviceInfo.join(' ')
});
});
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 label = 'OpenAPS';
if (_.includes(selectedFields, 'status-symbol')) {
label += ' ' + prop.status.symbol;
}
sbx.pluginBase.updatePillText(openaps, {
value: utils.timeFormat(prop.lastLoopMoment, sbx)
, label: label
, info: info
, pillClass: statusClass(prop, prefs, sbx)
});
var forecastPoints = getForecastPoints();
if (forecastPoints && forecastPoints.length > 0) {
sbx.pluginBase.addForecastPoints(forecastPoints, { type: 'openaps', label: 'OpenAPS Forecasts' });
}
};
function virtAsstForecastHandler (next, slots, sbx) {
var lastEventualBG = _.get(sbx, 'properties.openaps.lastEventualBG');
if (lastEventualBG) {
var response = translate('virtAsstOpenAPSForecast', {
params: [
lastEventualBG
]
});
next(translate('virtAsstTitleOpenAPSForecast'), response);
} else {
next(translate('virtAsstTitleOpenAPSForecast'), translate('virtAsstUnknown'));
}
}
function virtAsstLastLoopHandler (next, slots, sbx) {
var lastLoopMoment = _.get(sbx, 'properties.openaps.lastLoopMoment');
if (lastLoopMoment) {
var response = translate('virtAsstLastLoop', {
params: [
moment(lastLoopMoment).from(moment(sbx.time))
]
});
next(translate('virtAsstTitleLastLoop'), response);
} else {
next(translate('virtAsstTitleLastLoop'), translate('virtAsstUnknown'));
}
}
openaps.virtAsst = {
intentHandlers: [{
intent: 'MetricNow'
, metrics: ['openaps forecast', 'forecast']
, intentHandler: virtAsstForecastHandler
}, {
intent: 'LastLoop'
, intentHandler: virtAsstLastLoopHandler
}]
};
function statusClass (prop, prefs, sbx) {
var level = statusLevel(prop, prefs, sbx);
return levels.toStatusClass(level);
}
function statusLevel (prop, prefs, sbx) {
var level = levels.NONE;
var now = moment(sbx.time);
if (openaps.findOfflineMarker(sbx)) {
console.info('OpenAPS known offline, not checking for alerts');
} else if (prop.lastLoopMoment) {
var urgentTime = prop.lastLoopMoment.clone().add(prefs.urgent, 'minutes');
var warningTime = prop.lastLoopMoment.clone().add(prefs.warn, 'minutes');
if (urgentTime.isBefore(now)) {
level = levels.URGENT;
} else if (warningTime.isBefore(now)) {
level = levels.WARN;
}
}
return level;
}
return openaps;
}
module.exports = init;