UNPKG

@neurosity/sdk

Version:
410 lines (409 loc) 20.4 kB
"use strict"; 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); }; }); }