diff --git a/app/package.json b/app/package.json index b8e0b37ce..b13a5856a 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "tidepool-uploader", "productName": "tidepool-uploader", - "version": "2.67.0-add-model-rollbar.4", + "version": "2.67.0-one-button-bolus.1", "description": "Tidepool Project Universal Uploader", "main": "./main.prod.js", "author": { diff --git a/app/utils/errors.js b/app/utils/errors.js index b1ce57987..69b84365e 100644 --- a/app/utils/errors.js +++ b/app/utils/errors.js @@ -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' && diff --git a/lib/drivers/medtronic600/NGPHistoryParser.js b/lib/drivers/medtronic600/NGPHistoryParser.js index 0f444f349..97d589249 100644 --- a/lib/drivers/medtronic600/NGPHistoryParser.js +++ b/lib/drivers/medtronic600/NGPHistoryParser.js @@ -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', @@ -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) @@ -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); } diff --git a/lib/drivers/tandem b/lib/drivers/tandem index 670e05c21..04a160513 160000 --- a/lib/drivers/tandem +++ b/lib/drivers/tandem @@ -1 +1 @@ -Subproject commit 670e05c218027077e7fe047b36a23314a0c2af66 +Subproject commit 04a160513257b0f216d51914f4f01801d67252bf diff --git a/package.json b/package.json index 613012226..fd137e7e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tidepool-uploader", - "version": "2.67.0-add-model-rollbar.4", + "version": "2.67.0-one-button-bolus.1", "engines": { "node": "24.15.0" }, diff --git a/test/app/utils/errors.test.js b/test/app/utils/errors.test.js new file mode 100644 index 000000000..ce7cb8055 --- /dev/null +++ b/test/app/utils/errors.test.js @@ -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'); + }); + }); +}); diff --git a/test/lib/medtronic600/testNGPHistoryParser.js b/test/lib/medtronic600/testNGPHistoryParser.js index 72e612c90..6cef11960 100644 --- a/test/lib/medtronic600/testNGPHistoryParser.js +++ b/test/lib/medtronic600/testNGPHistoryParser.js @@ -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'; @@ -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', () => {