Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "tidepool-uploader",
"productName": "tidepool-uploader",
"version": "2.67.0",
"version": "2.67.0-one-button-bolus.1",
"description": "Tidepool Project Universal Uploader",
"main": "./main.prod.js",
"author": {
Expand Down
10 changes: 8 additions & 2 deletions app/utils/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,15 @@ export function addInfoToError(err, props) {
if (!_.isEmpty(v) && v !== err.message &&
k !== 'utc' &&
k !== 'code' &&
k !== 'version' &&
k !== 'data'
k !== 'version'
) {
if (k === 'data') {
// the device data is needed for the debug download links on failed
// uploads, but has to stay non-enumerable so it is never serialized
// into the metrics query string or error reports
Object.defineProperty(err, k, { value: v, writable: true, configurable: true });
return;
}
err[k] = v;
if (
k !== 'uuid' &&
Expand Down
25 changes: 18 additions & 7 deletions lib/drivers/medtronic600/NGPHistoryParser.js
Original file line number Diff line number Diff line change
Expand Up @@ -1770,7 +1770,20 @@ class NGPHistoryParser {

buildSuspendResumeRecords(events) {
for (const event of this.eventsOfType(NGPHistoryEvent.EVENT_TYPE.INSULIN_DELIVERY_STOPPED)) {
const resumeEvent = this.findResumeForSuspend(event);
let resumeEvent = this.findResumeForSuspend(event);

let duration = resumeEvent == null ? 0 :
(resumeEvent.timestamp.rtc - event.timestamp.rtc) * 1000;
if (duration < 0) {
duration = resumeEvent.timestamp.toDate().valueOf() - event.timestamp.toDate().valueOf();
}
if (duration < 0) {
// We can't reliably pair this resume with the suspend, so treat the
// suspend as unresumed rather than failing the whole upload.
debug('Resume event precedes suspend event; treating suspend at', sundial.formatDeviceTime(event.timestamp.toDate()), 'as unresumed');
resumeEvent = null;
duration = 0;
}

const reason = {
suspended: 'automatic',
Expand All @@ -1797,12 +1810,6 @@ class NGPHistoryParser {
},
};

const duration = resumeEvent == null ? 0 :
(resumeEvent.timestamp.rtc - event.timestamp.rtc) * 1000;
if (duration < 0) {
throw new Error('Suspend event duration cannot be less than zero');
}

const suspendResumeEvent = this.cfg.builder.makeDeviceEventSuspendResume()
.with_reason(reason)
.with_duration(duration)
Expand Down Expand Up @@ -1845,6 +1852,10 @@ class NGPHistoryParser {
const bolus = this.cfg.builder.makeNormalBolus()
.with_normal(event.deliveredAmount);

if (event.bolusSource === NGPUtil.NGPConstants.BOLUS_SOURCE.EASY_BOLUS) {
bolus.with_deliveryContext('oneButton');
}

if (event.programmedAmount !== event.deliveredAmount) {
bolus.with_expectedNormal(event.programmedAmount);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/drivers/tandem
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tidepool-uploader",
"version": "2.67.0",
"version": "2.67.0-one-button-bolus.1",
"engines": {
"node": "24.15.0"
},
Expand Down
55 changes: 55 additions & 0 deletions test/app/utils/errors.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* == BSD2 LICENSE ==
* Copyright (c) 2026, Tidepool Project
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the associated License, which is identical to the BSD 2-Clause
* License as published by the Open Source Initiative at opensource.org.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the License for more details.
*
* You should have received a copy of the License along with this program; if
* not, you can obtain one from Tidepool Project at tidepool.org.
* == BSD2 LICENSE ==
*/

import { expect } from 'chai';

import { addInfoToError } from '../../../app/utils/errors';

describe('errors', () => {
describe('addInfoToError', () => {
test('should copy device data onto the error for the debug download links', () => {
const err = new Error('Oops!');
const data = { post_records: [], compressed: new Uint8Array([31, 139]) };
addInfoToError(err, { details: 'something broke', data });

expect(err.data).to.equal(data);
});

test('should not expose device data to serialization of the error', () => {
const err = new Error('Oops!');
addInfoToError(err, {
details: 'something broke',
data: { post_records: [{ type: 'bolus' }] },
});

// the metrics middleware serializes the error's enumerable properties
// into a GET query string, which must not include the device data
expect(Object.keys(err)).to.not.contain('data');
expect(JSON.stringify(err)).to.not.contain('bolus');
});

test('should not include device data in the debug details string', () => {
const err = new Error('Oops!');
addInfoToError(err, {
details: 'something broke',
data: { post_records: [{ type: 'bolus' }] },
});

expect(err.debug).to.equal('Details: something broke');
});
});
});
104 changes: 104 additions & 0 deletions test/lib/medtronic600/testNGPHistoryParser.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,42 @@ describe('NGPHistoryParser.js', () => {
});
});

describe('easy bolus', () => {
test('should set delivery context on a one-button (easy) bolus', () => {
// same fixtures as the wizard bolus above, but with bolus source (byte 0x0B)
// set to 2 (EASY_BOLUS) and no wizard record
const bolusProgrammedData = '150016822dff2e9e029f8e02aa0000014dfc000032c8';
const bolusCompleteData = 'dc001a822dff189e029f8e02aa0000014dfc00014dfc000032c8';
const historyParser = new NGPHistoryParser(
cfg, settings,
[bolusProgrammedData + bolusCompleteData],
);
const events = [];

const expected = {
clockDriftOffset: 0,
conversionOffset: 0,
deliveryContext: 'oneButton',
deviceTime: '2017-02-10T15:54:36',
index: 2184052526,
jsDate: new Date('2017-02-10T15:54:36.000Z'),
normal: 8.55,
payload: {
logIndices: [
2184052526,
],
},
subType: 'normal',
time: '2017-02-10T15:54:36.000Z',
timezoneOffset: 0,
type: 'bolus',
};

historyParser.buildNormalBolusRecords(events);
expect(events[0]).to.deep.equal(expected);
});
});

describe('suspend', () => {
test('should calculate the correct suspend duration', () => {
const suspendData = '1e000c81ee52f6a092886601';
Expand Down Expand Up @@ -133,6 +169,74 @@ describe('NGPHistoryParser.js', () => {
historyParser.buildSuspendResumeRecords(events);
expect(events[0]).to.deep.equal(expected);
});

test('should fall back to clock-corrected timestamps when the RTC resets during a suspend', () => {
// suspend at 2019-03-01T12:00:00, then the pump loses its clock
// (TIME_RESET, so the RTC counter restarts lower), then resume at
// 2019-03-01T12:30:00 wall-clock time
const suspendData = '1e000c82000000a20bdb4001';
const resumeData = '1f000c80000100a40be14802';
const historyParser = new NGPHistoryParser(cfg, settings, [suspendData + resumeData]);
const events = [];

const expected = {
time: '2019-03-01T12:00:00.000Z',
timezoneOffset: 0,
clockDriftOffset: 0,
conversionOffset: 0,
deviceTime: '2019-03-01T12:00:00',
type: 'deviceEvent',
subType: 'status',
status: 'suspended',
reason: { suspended: 'automatic', resumed: 'manual' },
duration: 1800000,
payload: {
suspended: { cause: 'Alarm suspend' },
resumed: { cause: 'User cleared alarm' },
logIndices: [2181038080],
},
index: 2181038080,
jsDate: new Date('2019-03-01T12:00:00.000Z'),
};

historyParser.buildSuspendResumeRecords(events);
expect(events[0]).to.deep.equal(expected);
expect(events[1].duration).to.equal(1800000);
});

test('should treat a suspend as unresumed when the resume precedes it even in clock-corrected time', () => {
// resume wall-clock time (11:50:00) is before the suspend (12:00:00)
// and the RTC delta is also negative, so the pair is unusable
const suspendData = '1e000c82000000a20bdb4001';
const resumeData = '1f000c80000100a40bd7e802';
const historyParser = new NGPHistoryParser(cfg, settings, [suspendData + resumeData]);
const events = [];

const expected = {
time: '2019-03-01T12:00:00.000Z',
timezoneOffset: 0,
clockDriftOffset: 0,
conversionOffset: 0,
deviceTime: '2019-03-01T12:00:00',
type: 'deviceEvent',
subType: 'status',
status: 'suspended',
reason: { suspended: 'automatic', resumed: 'automatic' },
duration: 0,
payload: {
suspended: { cause: 'Alarm suspend' },
resumed: { cause: 'not_resumed' },
logIndices: [2181038080],
},
annotations: [{ code: 'status/incomplete-tuple' }],
index: 2181038080,
jsDate: new Date('2019-03-01T12:00:00.000Z'),
};

historyParser.buildSuspendResumeRecords(events);
expect(events[0]).to.deep.equal(expected);
expect(events[1].duration).to.equal(0);
});
});

describe('temp basal', () => {
Expand Down