@neurosity/sdk
Version:
Neurosity SDK
410 lines (409 loc) • 20.4 kB
JavaScript
;
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebBluetoothTransport = void 0;
const ipk_1 = require("@neurosity/ipk");
const ipk_2 = require("@neurosity/ipk");
const ipk_3 = require("@neurosity/ipk");
const ipk_4 = require("@neurosity/ipk");
const rxjs_1 = require("rxjs");
const rxjs_2 = require("rxjs");
const operators_1 = require("rxjs/operators");
const operators_2 = require("rxjs/operators");
const operators_3 = require("rxjs/operators");
const isWebBluetoothSupported_1 = require("./isWebBluetoothSupported");
const create6DigitPin_1 = require("../utils/create6DigitPin");
const textCodec_1 = require("../utils/textCodec");
const types_1 = require("../types");
const constants_1 = require("../constants");
const constants_2 = require("../constants");
const decodeJSONChunks_1 = require("../utils/decodeJSONChunks");
const defaultOptions = {
autoConnect: true
};
class WebBluetoothTransport {
constructor(options = {}) {
this.type = types_1.TRANSPORT_TYPE.WEB;
this.textCodec = new textCodec_1.TextCodec(this.type);
this.characteristicsByName = {};
this.connection$ = new rxjs_1.BehaviorSubject(types_1.BLUETOOTH_CONNECTION.DISCONNECTED);
this.pendingActions$ = new rxjs_1.BehaviorSubject([]);
this.logs$ = new rxjs_1.ReplaySubject(10);
this.onDisconnected$ = this._onDisconnected().pipe((0, operators_3.share)());
this.connectionStream$ = this.connection$
.asObservable()
.pipe((0, operators_1.filter)((connection) => !!connection), (0, operators_2.distinctUntilChanged)(), (0, operators_2.shareReplay)(1));
this._isAutoConnectEnabled$ = new rxjs_1.ReplaySubject(1);
this.options = Object.assign(Object.assign({}, defaultOptions), options);
if (!(0, isWebBluetoothSupported_1.isWebBluetoothSupported)()) {
const errorMessage = "Web Bluetooth is not supported";
this.addLog(errorMessage);
throw new Error(errorMessage);
}
this._isAutoConnectEnabled$.subscribe((autoConnect) => {
this.addLog(`Auto connect: ${autoConnect ? "enabled" : "disabled"}`);
});
this._isAutoConnectEnabled$.next(this.options.autoConnect);
this.connection$.asObservable().subscribe((connection) => {
this.addLog(`connection status is ${connection}`);
});
this.onDisconnected$.subscribe(() => {
this.connection$.next(types_1.BLUETOOTH_CONNECTION.DISCONNECTED);
});
}
_getPairedDevices() {
return __awaiter(this, void 0, void 0, function* () {
return yield navigator.bluetooth.getDevices();
});
}
_autoConnect(selectedDevice$) {
return this._isAutoConnectEnabled$.pipe((0, operators_1.switchMap)((isAutoConnectEnabled) => isAutoConnectEnabled
? (0, rxjs_2.merge)(selectedDevice$, this.onDisconnected$.pipe((0, operators_1.switchMap)(() => selectedDevice$)))
: rxjs_2.NEVER), (0, operators_1.switchMap)((selectedDevice) => __awaiter(this, void 0, void 0, function* () {
var _a;
const { deviceNickname } = selectedDevice;
if (this.isConnected()) {
this.addLog(`Auto connect: ${deviceNickname} is already connected. Skipping auto connect.`);
return;
}
const [devicesError, devices] = yield this._getPairedDevices()
.then((devices) => [null, devices])
.catch((error) => [error, null]);
if (devicesError) {
throw new Error(`failed to get devices: ${(_a = devicesError === null || devicesError === void 0 ? void 0 : devicesError.message) !== null && _a !== void 0 ? _a : devicesError}`);
}
this.addLog(`Auto connect: found ${devices.length} devices ${devices
.map(({ name }) => name)
.join(", ")}`);
// @important - Using `findLast` instead of `find` because somehow the browser
// is finding multiple peripherals with the same name
const device = devices.findLast((device) => device.name === deviceNickname);
if (!device) {
throw new Error(`couldn't find selected device in the list of paired devices.`);
}
this.addLog(`Auto connect: ${deviceNickname} was detected and previously paired`);
return device;
})), (0, operators_1.tap)(() => {
this.connection$.next(types_1.BLUETOOTH_CONNECTION.SCANNING);
}), (0, operators_1.switchMap)((device) => onAdvertisementReceived(device)), (0, operators_1.switchMap)((advertisement) => __awaiter(this, void 0, void 0, function* () {
this.addLog(`Advertisement received for ${advertisement.device.name}`);
return yield this.getServerServiceAndCharacteristics(advertisement.device);
})));
}
enableAutoConnect(autoConnect) {
this._isAutoConnectEnabled$.next(autoConnect);
}
addLog(log) {
this.logs$.next(log);
}
isConnected() {
const connection = this.connection$.getValue();
return connection === types_1.BLUETOOTH_CONNECTION.CONNECTED;
}
connection() {
return this.connectionStream$;
}
connect(deviceNickname) {
return __awaiter(this, void 0, void 0, function* () {
try {
// requires user gesture
const device = yield this.requestDevice(deviceNickname);
yield this.getServerServiceAndCharacteristics(device);
}
catch (error) {
return Promise.reject(error);
}
});
}
requestDevice(deviceNickname) {
return __awaiter(this, void 0, void 0, function* () {
try {
this.addLog("Requesting Bluetooth Device...");
const prefixes = ipk_3.BLUETOOTH_DEVICE_NAME_PREFIXES.map((namePrefix) => ({
namePrefix
}));
// Ability to only show selectedDevice if provided
const filters = deviceNickname
? [
{
name: deviceNickname
}
]
: prefixes;
const device = yield window.navigator.bluetooth.requestDevice({
filters: [
...filters,
{
manufacturerData: [
{
companyIdentifier: ipk_4.BLUETOOTH_COMPANY_IDENTIFIER_HEX
}
]
}
],
optionalServices: [ipk_1.BLUETOOTH_PRIMARY_SERVICE_UUID_HEX]
});
return device;
}
catch (error) {
return Promise.reject(error);
}
});
}
getServerServiceAndCharacteristics(device) {
return __awaiter(this, void 0, void 0, function* () {
try {
this.device = device;
const isConnecting = this.connection$.getValue() === types_1.BLUETOOTH_CONNECTION.CONNECTING;
if (!isConnecting) {
this.connection$.next(types_1.BLUETOOTH_CONNECTION.CONNECTING);
}
this.server = yield device.gatt.connect();
this.addLog(`Getting service...`);
this.service = yield this.server.getPrimaryService(ipk_1.BLUETOOTH_PRIMARY_SERVICE_UUID_HEX);
this.addLog(`Got service ${this.service.uuid}, getting characteristics...`);
const characteristicsList = yield this.service.getCharacteristics();
this.addLog(`Got characteristics`);
this.characteristicsByName = Object.fromEntries(characteristicsList.map((characteristic) => [
constants_2.CHARACTERISTIC_UUIDS_TO_NAMES[characteristic.uuid],
characteristic
]));
this.connection$.next(types_1.BLUETOOTH_CONNECTION.CONNECTED);
}
catch (error) {
return Promise.reject(error);
}
});
}
_onDisconnected() {
return this.connection$
.asObservable()
.pipe((0, operators_1.switchMap)((connection) => connection === types_1.BLUETOOTH_CONNECTION.CONNECTED
? fromDOMEvent(this.device, "gattserverdisconnected")
: rxjs_2.NEVER));
}
disconnect() {
var _a, _b;
return __awaiter(this, void 0, void 0, function* () {
const isDeviceConnected = (_b = (_a = this === null || this === void 0 ? void 0 : this.device) === null || _a === void 0 ? void 0 : _a.gatt) === null || _b === void 0 ? void 0 : _b.connected;
if (isDeviceConnected) {
this.device.gatt.disconnect();
}
});
}
/**
*
* Bluetooth GATT attributes, services, characteristics, etc. are invalidated
* when a device disconnects. This means your code should always retrieve
* (through getPrimaryService(s), getCharacteristic(s), etc.) these attributes
* after reconnecting.
*/
getCharacteristicByName(characteristicName) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
return (_a = this.characteristicsByName) === null || _a === void 0 ? void 0 : _a[characteristicName];
});
}
subscribeToCharacteristic({ characteristicName, manageNotifications = true, skipJSONDecoding = false }) {
const data$ = (0, rxjs_2.defer)(() => this.getCharacteristicByName(characteristicName)).pipe((0, operators_1.switchMap)((characteristic) => __awaiter(this, void 0, void 0, function* () {
var _a;
if (this.isConnected() && manageNotifications) {
try {
yield characteristic.startNotifications();
this.addLog(`Started notifications for ${characteristicName} characteristic`);
}
catch (error) {
this.addLog(`Attemped to stop notifications for ${characteristicName} characteristic: ${(_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : error}`);
}
}
return characteristic;
})), (0, operators_1.switchMap)((characteristic) => {
return fromDOMEvent(characteristic, "characteristicvaluechanged", () => __awaiter(this, void 0, void 0, function* () {
var _a;
if (this.isConnected() && manageNotifications) {
try {
yield characteristic.stopNotifications();
this.addLog(`Stopped notifications for ${characteristicName} characteristic`);
}
catch (error) {
this.addLog(`Attemped to stop notifications for ${characteristicName} characteristic: ${(_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : error}`);
}
}
}));
}), (0, operators_1.map)((event) => event.target.value.buffer));
return this.connection$.pipe((0, operators_1.switchMap)((connection) => connection === types_1.BLUETOOTH_CONNECTION.CONNECTED
? data$.pipe(skipJSONDecoding
? rxjs_1.identity // noop
: (0, decodeJSONChunks_1.decodeJSONChunks)({
textCodec: this.textCodec,
characteristicName,
delimiter: ipk_2.BLUETOOTH_CHUNK_DELIMITER,
addLog: (message) => this.addLog(message)
}))
: rxjs_2.NEVER));
}
readCharacteristic(characteristicName, parse = false) {
return __awaiter(this, void 0, void 0, function* () {
try {
this.addLog(`Reading characteristic: ${characteristicName}`);
const characteristic = yield this.getCharacteristicByName(characteristicName);
if (!characteristic) {
this.addLog(`Did not fund ${characteristicName} characteristic`);
return Promise.reject(`Did not find characteristic by the name: ${characteristicName}`);
}
const dataview = yield characteristic.readValue();
const arrayBuffer = dataview.buffer;
const decodedValue = this.textCodec.decode(arrayBuffer);
const data = parse ? JSON.parse(decodedValue) : decodedValue;
this.addLog(`Received read data from ${characteristicName} characteristic: \n${data}`);
return data;
}
catch (error) {
return Promise.reject(`Error reading characteristic: ${error.message}`);
}
});
}
writeCharacteristic(characteristicName, data) {
return __awaiter(this, void 0, void 0, function* () {
this.addLog(`Writing characteristic: ${characteristicName}`);
const characteristic = yield this.getCharacteristicByName(characteristicName);
if (!characteristic) {
this.addLog(`Did not fund ${characteristicName} characteristic`);
return Promise.reject(`Did not find characteristic by the name: ${characteristicName}`);
}
const encoded = this.textCodec.encode(data);
yield characteristic.writeValueWithResponse(encoded);
});
}
_addPendingAction(actionId) {
const actions = this.pendingActions$.getValue();
this.pendingActions$.next([...actions, actionId]);
}
_removePendingAction(actionId) {
const actions = this.pendingActions$.getValue();
this.pendingActions$.next(actions.filter((id) => id !== actionId));
}
_autoToggleActionNotifications() {
let actionsCharacteristic;
let started = false;
return this.connection$.asObservable().pipe((0, operators_1.switchMap)((connection) => connection === types_1.BLUETOOTH_CONNECTION.CONNECTED
? (0, rxjs_2.defer)(() => this.getCharacteristicByName("actions")).pipe((0, operators_1.switchMap)((characteristic) => {
actionsCharacteristic = characteristic;
return this.pendingActions$;
}))
: rxjs_2.NEVER), (0, operators_1.tap)((pendingActions) => __awaiter(this, void 0, void 0, function* () {
var _a, _b;
const hasPendingActions = !!pendingActions.length;
if (hasPendingActions && !started) {
started = true;
try {
yield actionsCharacteristic.startNotifications();
this.addLog(`Started notifications for [actions] characteristic`);
}
catch (error) {
this.addLog(`Attemped to start notifications for [actions] characteristic: ${(_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : error}`);
}
}
if (!hasPendingActions && started) {
started = false;
try {
yield actionsCharacteristic.stopNotifications();
this.addLog(`Stopped notifications for actions characteristic`);
}
catch (error) {
this.addLog(`Attemped to stop notifications for [actions] characteristic: ${(_b = error === null || error === void 0 ? void 0 : error.message) !== null && _b !== void 0 ? _b : error}`);
}
}
})));
}
dispatchAction({ characteristicName, action }) {
return __awaiter(this, void 0, void 0, function* () {
const { responseRequired = false, responseTimeout = constants_1.DEFAULT_ACTION_RESPONSE_TIMEOUT } = action;
return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () {
const characteristic = yield this.getCharacteristicByName(characteristicName).catch(() => {
reject(`Did not find characteristic by the name: ${characteristicName}`);
});
if (!characteristic) {
return;
}
const actionId = (0, create6DigitPin_1.create6DigitPin)(); // use to later identify and filter response
const payload = JSON.stringify(Object.assign({ actionId }, action)); // add the response id to the action
this.addLog(`Dispatched action with id ${actionId}`);
if (responseRequired && responseTimeout) {
this._addPendingAction(actionId);
const timeout = (0, rxjs_2.timer)(responseTimeout).subscribe(() => {
this._removePendingAction(actionId);
reject(`Action with id ${actionId} timed out after ${responseTimeout}ms`);
});
// listen for a response before writing
this.subscribeToCharacteristic({
characteristicName,
manageNotifications: false
})
.pipe((0, operators_1.filter)((response) => (response === null || response === void 0 ? void 0 : response.actionId) === actionId), (0, operators_3.take)(1))
.subscribe((response) => {
timeout.unsubscribe();
this._removePendingAction(actionId);
resolve(response);
});
// register action by writing
this.writeCharacteristic(characteristicName, payload).catch((error) => {
this._removePendingAction(actionId);
reject(error.message);
});
}
else {
this.writeCharacteristic(characteristicName, payload)
.then(() => {
resolve(null);
})
.catch((error) => {
reject(error.message);
});
}
}));
});
}
}
exports.WebBluetoothTransport = WebBluetoothTransport;
function fromDOMEvent(target, eventName, beforeRemove) {
return (0, rxjs_2.fromEventPattern)((addHandler) => {
target.addEventListener(eventName, addHandler);
}, (removeHandler) => __awaiter(this, void 0, void 0, function* () {
if (beforeRemove) {
yield beforeRemove();
}
target.removeEventListener(eventName, removeHandler);
}));
}
function onAdvertisementReceived(device) {
return new rxjs_1.Observable((subscriber) => {
const abortController = new AbortController();
const { signal } = abortController;
const listener = device.addEventListener("advertisementreceived", (advertisement) => {
abortController.abort();
subscriber.next(advertisement);
subscriber.complete();
}, {
once: true
});
try {
device.watchAdvertisements({ signal });
}
catch (error) {
subscriber.error(error);
}
return () => {
abortController.abort();
device.removeEventListener("advertisementreceived", listener);
};
});
}