diff --git a/README.md b/README.md index 8a89f8d..ff36e0a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Build Status](https://travis-ci.org/urish/muse-js.png?branch=master)](https://travis-ci.org/urish/muse-js) -Muse 2016 EEG Headset JavaScript Library (using Web Bluetooth) +Muse 1, Muse 2, and Muse S EEG Headset JavaScript Library (using Web Bluetooth). ## Running the demo app @@ -76,6 +76,28 @@ async function main() { } ``` +## PPG (Photoplethysmography) / Optical Sensor + +The Muse 2 and Muse S contain PPG/optical blood sensors, which this library supports. There are three signal streams, ppg1, ppg2, and ppg3. These are ambient, infrared, and red (respectively) on the Muse 2, and (we think, unconfirmed) infrared, green, and unknown (respectively) on the Muse S. To use PPG, ensure you enable it before connecting to a Muse. PPG is not present and thus will not work on Muse 1/1.5, and enabling it may have unexpected consequences. + +To enable PPG: + +```javascript +async function main() { + let client = new MuseClient(); + client.enablePpg = true; + await client.connect(); +} +``` + +To subscribe and receive values from PPG, it's just like subscribing to EEG (see **Usage Example**): + +```javascript +client.ppgReadings.subscribe((ppgreading) => { + console.log(ppgreading); +}); +``` + ## Event Markers For convenience, there is an `eventMarkers` stream included in `MuseClient` that you can use in order to introduce timestamped event markers into your project. Just subscribe to `eventMarkers` and use the `injectMarker` method with the value and optional timestamp of an event to send it through the stream. diff --git a/package.json b/package.json index 3b3e98e..cb87009 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "muse-js", - "version": "3.2.0", + "version": "3.3.0", "description": "Muse 2016 EEG Headset JavaScript Library", "main": "dist/muse.js", "typings": "dist/muse.d.ts", diff --git a/src/lib/muse-interfaces.ts b/src/lib/muse-interfaces.ts index e5c166a..61bab6f 100644 --- a/src/lib/muse-interfaces.ts +++ b/src/lib/muse-interfaces.ts @@ -5,6 +5,13 @@ export interface EEGReading { samples: number[]; // 12 samples each time } +export interface PPGReading { + index: number; + ppgChannel: number; // 0 to 2 + timestamp: number; // milliseconds since epoch + samples: number[]; // 6 samples each time +} + export interface TelemetryData { sequenceId: number; batteryLevel: number; diff --git a/src/lib/muse-parse.spec.ts b/src/lib/muse-parse.spec.ts index 7ab0da4..98c8278 100644 --- a/src/lib/muse-parse.spec.ts +++ b/src/lib/muse-parse.spec.ts @@ -3,6 +3,7 @@ import { toArray } from 'rxjs/operators'; import { decodeUnsigned12BitData, + decodeUnsigned24BitData, parseAccelerometer, parseControl, parseGyroscope, @@ -88,6 +89,13 @@ describe('decodeUnsigned12BitData', () => { }); }); +describe('decodeUnsigned24BitData', () => { + it('should correctly decode 24-bit PPG samples received from muse', () => { + const input = new Uint8Array([87, 33, 192, 82, 73, 6, 106, 242, 49, 64, 88, 153, 128, 66, 254, 44, 119, 157]); + expect(decodeUnsigned24BitData(input)).toEqual([5710272, 5392646, 7008817, 4216985, 8405758, 2914205]); + }); +}); + describe('parseTelemtry', () => { it('should correctly parse Muse telemetry data', () => { const input = new DataView( diff --git a/src/lib/muse-parse.ts b/src/lib/muse-parse.ts index 221d677..fa52664 100644 --- a/src/lib/muse-parse.ts +++ b/src/lib/muse-parse.ts @@ -1,7 +1,7 @@ import { Observable } from 'rxjs'; import { concatMap, filter, map, scan } from 'rxjs/operators'; -import { AccelerometerData, EEGReading, GyroscopeData, TelemetryData } from './muse-interfaces'; +import { AccelerometerData, EEGReading, GyroscopeData, PPGReading, TelemetryData } from './muse-interfaces'; export function parseControl(controlData: Observable) { return controlData.pipe( @@ -23,9 +23,9 @@ export function decodeUnsigned12BitData(samples: Uint8Array) { // tslint:disable:no-bitwise for (let i = 0; i < samples.length; i++) { if (i % 3 === 0) { - samples12Bit.push(samples[i] << 4 | samples[i + 1] >> 4); + samples12Bit.push((samples[i] << 4) | (samples[i + 1] >> 4)); } else { - samples12Bit.push((samples[i] & 0xf) << 8 | samples[i + 1]); + samples12Bit.push(((samples[i] & 0xf) << 8) | samples[i + 1]); i++; } } @@ -33,16 +33,32 @@ export function decodeUnsigned12BitData(samples: Uint8Array) { return samples12Bit; } +export function decodeUnsigned24BitData(samples: Uint8Array) { + const samples24Bit = []; + // tslint:disable:no-bitwise + for (let i = 0; i < samples.length; i = i + 3) { + samples24Bit.push((samples[i] << 16) | (samples[i + 1] << 8) | samples[i + 2]); + } + // tslint:enable:no-bitwise + return samples24Bit; +} + export function decodeEEGSamples(samples: Uint8Array) { - return decodeUnsigned12BitData(samples) - .map((n) => 0.48828125 * (n - 0x800)); + return decodeUnsigned12BitData(samples).map((n) => 0.48828125 * (n - 0x800)); +} + +export function decodePPGSamples(samples: Uint8Array) { + // Decode data packet of one PPG channel. + // Each packet is encoded with a 16bit timestamp followed by 6 + // samples with a 24 bit resolution. + return decodeUnsigned24BitData(samples); } export function parseTelemetry(data: DataView): TelemetryData { // tslint:disable:object-literal-sort-keys return { sequenceId: data.getUint16(0), - batteryLevel: data.getUint16(2) / 512., + batteryLevel: data.getUint16(2) / 512, fuelGaugeVoltage: data.getUint16(4) * 2.2, // Next 2 bytes are probably ADC millivolt level, not sure temperature: data.getUint16(8), diff --git a/src/lib/zip-samplesPpg.spec.ts b/src/lib/zip-samplesPpg.spec.ts new file mode 100644 index 0000000..f11e16c --- /dev/null +++ b/src/lib/zip-samplesPpg.spec.ts @@ -0,0 +1,80 @@ +import { Observable, of } from 'rxjs'; +import { toArray } from 'rxjs/operators'; + +import { zipSamplesPpg } from './zip-samplesPpg'; + +// tslint:disable:object-literal-sort-keys + +describe('zipSamplesPpg', () => { + it('should zip all ppg channels into one array', async () => { + const input = of( + { + ppgChannel: 0, + index: 100, + timestamp: 1000, + samples: [0.01, 0.02, 0.03, 0.04, 0.05, 0.06], + }, + { + ppgChannel: 1, + index: 100, + timestamp: 1000, + samples: [1.01, 1.02, 1.03, 1.04, 1.05, 1.06], + }, + { + ppgChannel: 2, + index: 100, + timestamp: 1000, + samples: [2.01, 2.02, 2.03, 2.04, 2.05, 2.06], + }, + { + ppgChannel: 0, + index: 101, + timestamp: 1046.875, + samples: [10.01, 10.02, 10.03, 10.04, 10.05, 10.06], + }, + { + ppgChannel: 1, + index: 101, + timestamp: 1046.875, + samples: [11.01, 11.02, 11.03, 11.04, 11.05, 11.06], + }, + { + ppgChannel: 2, + index: 101, + timestamp: 1046.875, + samples: [12.01, 12.02, 12.03, 12.04, 12.05, 12.06], + }, + ); + const zipped = zipSamplesPpg(input); + const result = await zipped.pipe(toArray()).toPromise(); + expect(result).toEqual([ + { index: 100, timestamp: 1000.0, data: [0.01, 1.01, 2.01] }, + { index: 100, timestamp: 1015.625, data: [0.02, 1.02, 2.02] }, + { index: 100, timestamp: 1031.25, data: [0.03, 1.03, 2.03] }, + { index: 100, timestamp: 1046.875, data: [0.04, 1.04, 2.04] }, + { index: 100, timestamp: 1062.5, data: [0.05, 1.05, 2.05] }, + { index: 100, timestamp: 1078.125, data: [0.06, 1.06, 2.06] }, + { index: 101, timestamp: 1046.875, data: [10.01, 11.01, 12.01] }, + { index: 101, timestamp: 1062.5, data: [10.02, 11.02, 12.02] }, + { index: 101, timestamp: 1078.125, data: [10.03, 11.03, 12.03] }, + { index: 101, timestamp: 1093.75, data: [10.04, 11.04, 12.04] }, + { index: 101, timestamp: 1109.375, data: [10.05, 11.05, 12.05] }, + { index: 101, timestamp: 1125, data: [10.06, 11.06, 12.06] }, + ]); + }); + + it('should indicate missing samples with NaN', async () => { + const input = of( + { index: 50, timestamp: 5000, ppgChannel: 0, samples: [0.01, 0.02, 0.03, 0.04] }, + { index: 50, timestamp: 5000, ppgChannel: 2, samples: [2.01, 2.02, 2.03, 2.04] }, + ); + const zipped = zipSamplesPpg(input); + const result = await zipped.pipe(toArray()).toPromise(); + expect(result).toEqual([ + { index: 50, timestamp: 5000.0, data: [0.01, NaN, 2.01] }, + { index: 50, timestamp: 5015.625, data: [0.02, NaN, 2.02] }, + { index: 50, timestamp: 5031.25, data: [0.03, NaN, 2.03] }, + { index: 50, timestamp: 5046.875, data: [0.04, NaN, 2.04] }, + ]); + }); +}); diff --git a/src/lib/zip-samplesPpg.ts b/src/lib/zip-samplesPpg.ts new file mode 100644 index 0000000..10026b8 --- /dev/null +++ b/src/lib/zip-samplesPpg.ts @@ -0,0 +1,44 @@ +import { from, Observable } from 'rxjs'; +import { concat, mergeMap } from 'rxjs/operators'; +import { PPG_FREQUENCY } from './../muse'; +import { PPGReading } from './muse-interfaces'; + +export interface PPGSample { + index: number; + timestamp: number; // milliseconds since epoch + data: number[]; +} + +export function zipSamplesPpg(ppgReadings: Observable): Observable { + const buffer: PPGReading[] = []; + let lastTimestamp: number | null = null; + return ppgReadings.pipe( + mergeMap((reading) => { + if (reading.timestamp !== lastTimestamp) { + lastTimestamp = reading.timestamp; + if (buffer.length) { + const result = from([[...buffer]]); + buffer.splice(0, buffer.length, reading); + return result; + } + } + buffer.push(reading); + return from([]); + }), + concat(from([buffer])), + mergeMap((readings: PPGReading[]) => { + const result = readings[0].samples.map((x, index) => { + const data = [NaN, NaN, NaN]; + for (const reading of readings) { + data[reading.ppgChannel] = reading.samples[index]; + } + return { + data, + index: readings[0].index, + timestamp: readings[0].timestamp + (index * 1000) / PPG_FREQUENCY, + }; + }); + return from(result); + }), + ); +} diff --git a/src/muse.spec.ts b/src/muse.spec.ts index ec9cb26..3ad04a4 100644 --- a/src/muse.spec.ts +++ b/src/muse.spec.ts @@ -1,6 +1,6 @@ import { TextDecoder, TextEncoder } from 'text-encoding'; import { DeviceMock, WebBluetoothMock } from 'web-bluetooth-mock'; -import { EEGReading } from './../dist/lib/muse-interfaces.d'; +import { EEGReading, PPGReading } from './../dist/lib/muse-interfaces.d'; import { MuseClient } from './muse'; declare var global; @@ -36,6 +36,18 @@ describe('MuseClient', () => { expect(eeg1Char.startNotifications).toHaveBeenCalled(); }); + it('should call startNotifications() on the PPG channel characteritics', async () => { + const service = museDevice.getServiceMock(0xfe8d); + const ppg1Char = service.getCharacteristicMock('273e000f-4c4d-454d-96be-f03bac821358'); + ppg1Char.startNotifications = jest.fn(); + + const client = new MuseClient(); + client.enablePpg = true; + await client.connect(); + + expect(ppg1Char.startNotifications).toHaveBeenCalled(); + }); + it('should not call startNotifications() on the Aux EEG electrode by default', async () => { const service = museDevice.getServiceMock(0xfe8d); const eegAuxChar = service.getCharacteristicMock('273e0007-4c4d-454d-96be-f03bac821358'); @@ -93,6 +105,24 @@ describe('MuseClient', () => { new Uint8Array([4, ...charCodes('p21'), 10]), ); }); + + it('choose preset number 50 instead of 20/21 if ppg is enabled', async () => { + const client = new MuseClient(); + const controlCharacteristic = museDevice + .getServiceMock(0xfe8d) + .getCharacteristicMock('273e0001-4c4d-454d-96be-f03bac821358'); + controlCharacteristic.writeValue = jest.fn(); + + client.enableAux = true; + client.enablePpg = true; + await client.connect(); + await client.start(); + + expect(controlCharacteristic.writeValue).toHaveBeenCalledWith(new Uint8Array([4, ...charCodes('p50'), 10])); + expect(controlCharacteristic.writeValue).not.toHaveBeenCalledWith( + new Uint8Array([4, ...charCodes('p21'), 10]), + ); + }); }); describe('eegReadings', () => { @@ -136,8 +166,8 @@ describe('MuseClient', () => { }); // Timestamp should be about (1000/256.0*12) milliseconds behind the event dispatch time - expect(lastReading.timestamp).toBeGreaterThanOrEqual(beforeDispatchTime - 1000 / 256.0 * 12); - expect(lastReading.timestamp).toBeLessThanOrEqual(afterDispatchTime - 1000 / 256.0 * 12); + expect(lastReading.timestamp).toBeGreaterThanOrEqual(beforeDispatchTime - (1000 / 256.0) * 12); + expect(lastReading.timestamp).toBeLessThanOrEqual(afterDispatchTime - (1000 / 256.0) * 12); }); it('should report the same timestamp for eeg events with the same sequence', async () => { @@ -201,7 +231,7 @@ describe('MuseClient', () => { eeg1Char.value = new DataView(new Uint8Array([0, 16]).buffer); eeg1Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); - expect(readings[1].timestamp - readings[0].timestamp).toEqual(-4 * 1000 / (256.0 / 12.0)); + expect(readings[1].timestamp - readings[0].timestamp).toEqual((-4 * 1000) / (256.0 / 12.0)); }); it('should handle timestamp wraparound', async () => { @@ -225,6 +255,77 @@ describe('MuseClient', () => { }); }); + describe('ppgReadings', () => { + it('should report the same timestamp for ppg events with the same sequence', async () => { + const service = museDevice.getServiceMock(0xfe8d); + const ppg0Char = service.getCharacteristicMock('273e000f-4c4d-454d-96be-f03bac821358'); + const ppg1Char = service.getCharacteristicMock('273e0010-4c4d-454d-96be-f03bac821358'); + + const client = new MuseClient(); + client.enablePpg = true; + await client.connect(); + + const readings: PPGReading[] = []; + client.ppgReadings.subscribe((reading) => { + readings.push(reading); + }); + + ppg0Char.value = new DataView(new Uint8Array([0, 15]).buffer); + ppg0Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); + ppg1Char.value = new DataView(new Uint8Array([0, 15]).buffer); + ppg1Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); + + expect(readings.length).toBe(2); + expect(readings[0].ppgChannel).toBe(0); + expect(readings[1].ppgChannel).toBe(1); + expect(readings[0].timestamp).toEqual(readings[1].timestamp); + }); + + it('should bump the timestamp for subsequent PPG events', async () => { + const service = museDevice.getServiceMock(0xfe8d); + const ppg0Char = service.getCharacteristicMock('273e000f-4c4d-454d-96be-f03bac821358'); + + const client = new MuseClient(); + client.enableAux = true; + client.enablePpg = true; + await client.connect(); + + const readings: PPGReading[] = []; + client.ppgReadings.subscribe((reading) => { + readings.push(reading); + }); + + ppg0Char.value = new DataView(new Uint8Array([0, 15]).buffer); + ppg0Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); + ppg0Char.value = new DataView(new Uint8Array([0, 16]).buffer); + ppg0Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); + + expect(readings[1].timestamp - readings[0].timestamp).toEqual(1000 / (64.0 / 6.0)); + }); + + it('should correctly handle out-of-order PPG events', async () => { + const service = museDevice.getServiceMock(0xfe8d); + const ppg1Char = service.getCharacteristicMock('273e0010-4c4d-454d-96be-f03bac821358'); + + const client = new MuseClient(); + client.enableAux = true; + client.enablePpg = true; + await client.connect(); + + const readings: PPGReading[] = []; + client.ppgReadings.subscribe((reading) => { + readings.push(reading); + }); + + ppg1Char.value = new DataView(new Uint8Array([0, 20]).buffer); + ppg1Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); + ppg1Char.value = new DataView(new Uint8Array([0, 16]).buffer); + ppg1Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); + + expect(readings[1].timestamp - readings[0].timestamp).toEqual((-4 * 1000) / (64.0 / 6.0)); + }); + }); + describe('deviceInfo', () => { it('should return information about the headset', async () => { const service = museDevice.getServiceMock(0xfe8d); diff --git a/src/muse.ts b/src/muse.ts index 3de6b54..85f2e3e 100644 --- a/src/muse.ts +++ b/src/muse.ts @@ -8,20 +8,45 @@ import { GyroscopeData, MuseControlResponse, MuseDeviceInfo, + PPGReading, TelemetryData, XYZ, } from './lib/muse-interfaces'; -import { decodeEEGSamples, parseAccelerometer, parseControl, parseGyroscope, parseTelemetry } from './lib/muse-parse'; +import { + decodeEEGSamples, + decodePPGSamples, + parseAccelerometer, + parseControl, + parseGyroscope, + parseTelemetry, +} from './lib/muse-parse'; import { decodeResponse, encodeCommand, observableCharacteristic } from './lib/muse-utils'; export { zipSamples, EEGSample } from './lib/zip-samples'; -export { EEGReading, TelemetryData, AccelerometerData, GyroscopeData, XYZ, MuseControlResponse, MuseDeviceInfo }; +export { zipSamplesPpg, PPGSample } from './lib/zip-samplesPpg'; +export { + EEGReading, + PPGReading, + TelemetryData, + AccelerometerData, + GyroscopeData, + XYZ, + MuseControlResponse, + MuseDeviceInfo, +}; export const MUSE_SERVICE = 0xfe8d; const CONTROL_CHARACTERISTIC = '273e0001-4c4d-454d-96be-f03bac821358'; const TELEMETRY_CHARACTERISTIC = '273e000b-4c4d-454d-96be-f03bac821358'; const GYROSCOPE_CHARACTERISTIC = '273e0009-4c4d-454d-96be-f03bac821358'; const ACCELEROMETER_CHARACTERISTIC = '273e000a-4c4d-454d-96be-f03bac821358'; +const PPG_CHARACTERISTICS = [ + '273e000f-4c4d-454d-96be-f03bac821358', // ambient 0x37-0x39 + '273e0010-4c4d-454d-96be-f03bac821358', // infrared 0x3a-0x3c + '273e0011-4c4d-454d-96be-f03bac821358', // red 0x3d-0x3f +]; +export const PPG_FREQUENCY = 64; +export const PPG_SAMPLES_PER_READING = 6; const EEG_CHARACTERISTICS = [ '273e0003-4c4d-454d-96be-f03bac821358', '273e0004-4c4d-454d-96be-f03bac821358', @@ -30,12 +55,17 @@ const EEG_CHARACTERISTICS = [ '273e0007-4c4d-454d-96be-f03bac821358', ]; export const EEG_FREQUENCY = 256; +export const EEG_SAMPLES_PER_READING = 12; + +// These names match the characteristics defined in PPG_CHARACTERISTICS above +export const ppgChannelNames = ['ambient', 'infrared', 'red']; // These names match the characteristics defined in EEG_CHARACTERISTICS above export const channelNames = ['TP9', 'AF7', 'AF8', 'TP10', 'AUX']; export class MuseClient { enableAux = false; + enablePpg = false; deviceName: string | null = ''; connectionStatus = new BehaviorSubject(false); rawControlData: Observable; @@ -44,11 +74,13 @@ export class MuseClient { gyroscopeData: Observable; accelerometerData: Observable; eegReadings: Observable; + ppgReadings: Observable; eventMarkers: Subject; private gatt: BluetoothRemoteGATTServer | null = null; private controlChar: BluetoothRemoteGATTCharacteristic; private eegCharacteristics: BluetoothRemoteGATTCharacteristic[]; + private ppgCharacteristics: BluetoothRemoteGATTCharacteristic[]; private lastIndex: number | null = null; private lastTimestamp: number | null = null; @@ -96,6 +128,32 @@ export class MuseClient { this.eventMarkers = new Subject(); + // PPG + if (this.enablePpg) { + this.ppgCharacteristics = []; + const ppgObservables = []; + const ppgChannelCount = PPG_CHARACTERISTICS.length; + for (let ppgChannelIndex = 0; ppgChannelIndex < ppgChannelCount; ppgChannelIndex++) { + const characteristicId = PPG_CHARACTERISTICS[ppgChannelIndex]; + const ppgChar = await service.getCharacteristic(characteristicId); + ppgObservables.push( + (await observableCharacteristic(ppgChar)).pipe( + map((data) => { + const eventIndex = data.getUint16(0); + return { + index: eventIndex, + ppgChannel: ppgChannelIndex, + samples: decodePPGSamples(new Uint8Array(data.buffer).subarray(2)), + timestamp: this.getTimestamp(eventIndex, PPG_SAMPLES_PER_READING, PPG_FREQUENCY), + }; + }), + ), + ); + this.ppgCharacteristics.push(ppgChar); + } + this.ppgReadings = merge(...ppgObservables); + } + // EEG this.eegCharacteristics = []; const eegObservables = []; @@ -111,7 +169,7 @@ export class MuseClient { electrode: channelIndex, index: eventIndex, samples: decodeEEGSamples(new Uint8Array(data.buffer).subarray(2)), - timestamp: this.getTimestamp(eventIndex), + timestamp: this.getTimestamp(eventIndex, EEG_SAMPLES_PER_READING, EEG_FREQUENCY), }; }), ), @@ -128,7 +186,13 @@ export class MuseClient { async start() { await this.pause(); - const preset = this.enableAux ? 'p20' : 'p21'; + let preset = 'p21'; + if (this.enablePpg) { + preset = 'p50'; + } else if (this.enableAux) { + preset = 'p20'; + } + await this.controlChar.writeValue(encodeCommand(preset)); await this.controlChar.writeValue(encodeCommand('s')); await this.resume(); @@ -143,7 +207,12 @@ export class MuseClient { } async deviceInfo() { - const resultListener = this.controlResponses.pipe(filter((r) => !!r.fw), take(1)).toPromise(); + const resultListener = this.controlResponses + .pipe( + filter((r) => !!r.fw), + take(1), + ) + .toPromise(); await this.sendCommand('v1'); return resultListener as Promise; } @@ -161,9 +230,8 @@ export class MuseClient { } } - private getTimestamp(eventIndex: number) { - const SAMPLES_PER_READING = 12; - const READING_DELTA = 1000 * (1.0 / EEG_FREQUENCY) * SAMPLES_PER_READING; + private getTimestamp(eventIndex: number, samplesPerReading: number, frequency: number) { + const READING_DELTA = 1000 * (1.0 / frequency) * samplesPerReading; if (this.lastIndex === null || this.lastTimestamp === null) { this.lastIndex = eventIndex; this.lastTimestamp = new Date().getTime() - READING_DELTA; diff --git a/yarn.lock b/yarn.lock index 4bdd451..6a7d3ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1730,9 +1730,9 @@ growly@^1.3.0: integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= handlebars@^4.0.3: - version "4.7.6" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.6.tgz#d4c05c1baf90e9945f77aa68a7a219aa4a7df74e" - integrity sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA== + version "4.7.7" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" + integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== dependencies: minimist "^1.2.5" neo-async "^2.6.0" @@ -4497,9 +4497,9 @@ tmp@^0.0.33: os-tmpdir "~1.0.2" tmpl@1.0.x: - version "1.0.4" - resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" - integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== to-fast-properties@^1.0.3: version "1.0.3" @@ -4637,9 +4637,9 @@ typescript@^2.8.3: integrity sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w== uglify-js@^3.1.4: - version "3.11.3" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.11.3.tgz#b2f8c87826344f091ba48c417c499d6cba5d5786" - integrity sha512-wDRziHG94mNj2n3R864CvYw/+pc9y/RNImiTyrrf8BzgWn75JgFSwYvXrtZQMnMnOp/4UTrf3iCSQxSStPiByA== + version "3.13.5" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.13.5.tgz#5d71d6dbba64cf441f32929b1efce7365bb4f113" + integrity sha512-xtB8yEqIkn7zmOyS2zUNBsYCBRhDkvlNxMMY2smuJ/qA8NCHeQvKCF3i9Z4k8FJH4+PJvZRtMrPynfZ75+CSZw== ultron@1.0.x: version "1.0.2"