diff --git a/README.md b/README.md index 52a4ccc..8a7875e 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,10 @@ A more robust and permanent solution to this is to implement/move to a web serve ## Experimental ANT+ Heart Rate Monitor Support -Added experimental ANT+ HR support that can be enabled in the setting. Once that is done a heart icon will show up on the toolbar that enables connecting to the HR monitor (user needs to click and then select the device from the popup window). The implementation uses Web USB API. However currently there are several limitations: +Added experimental ANT+ HR support that can be enabled in the setting. Once that is done a heart icon will show up on the toolbar that enables connecting to the HR monitor (user needs to click and then select the device from the popup window). The implementation uses Web USB API. However currently there are a few limitations: -- Automatic connection to previously pared devices after page reload (or stick reinsert) is currently not implemented so the usb stick needs to be selected on every connect (this is WIP) - Same issue with the secure context as for [bluetooth](#experimental-ble-heart-rate-monitor-support) - -Also, the ANT+ stick needs a WinUSB driver (instead of generic libusb) otherwise itt will not work. This can be installed with [Zadig](https://zadig.akeo.ie/). +- The ANT+ stick needs a WinUSB driver (instead of generic libusb) otherwise itt will not work. This can be installed with [Zadig](https://zadig.akeo.ie/). ## Backlog diff --git a/src/app/app.component.ts b/src/app/app.component.ts index d524be0..624e999 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -50,7 +50,7 @@ export class AppComponent { this.isConnected$ = this.webSocketService.connectionStatus(); } - handleAction($event: ButtonClickedTargets): void { + async handleAction($event: ButtonClickedTargets): Promise { if ($event === "reset") { this.activityStartTime = Date.now(); this.dataService.reset(); @@ -68,7 +68,7 @@ export class AppComponent { } if ($event === "heartRate") { - this.heartRateService.discover$().subscribe(); + await this.heartRateService.discover(); } if ($event === "bluetooth") { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 1157a1e..135bf0b 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,10 +1,12 @@ import { HTTP_INTERCEPTORS } from "@angular/common/http"; import { NgModule } from "@angular/core"; +import { MatSnackBar } from "@angular/material/snack-bar"; import { BrowserModule } from "@angular/platform-browser"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { BrowserWebBluetooth, WebBluetoothModule } from "@manekinekko/angular-web-bluetooth"; import { CoreModule } from "../common/core.module"; +import { AntHeartRateService } from "../common/services/ant-heart-rate.service"; import { ErrorInterceptor } from "../common/services/error.interceptor.service"; import { SnackBarConfirmComponent } from "../common/snack-bar-confirm/snack-bar-confirm.component"; @@ -50,6 +52,21 @@ if (isSecureContext) { } as unknown as BrowserWebBluetooth; }, }, + { + provide: AntHeartRateService, + useFactory: (snack: MatSnackBar): AntHeartRateService => { + if (isSecureContext) { + return new AntHeartRateService(snack); + } + + return { + discover: (): Promise => { + throw Error("WebUSB API is not available"); + }, + } as unknown as AntHeartRateService; + }, + deps: [MatSnackBar], + }, ], bootstrap: [AppComponent], }) diff --git a/src/common/common.interfaces.ts b/src/common/common.interfaces.ts index b7a6728..e80724a 100644 --- a/src/common/common.interfaces.ts +++ b/src/common/common.interfaces.ts @@ -1,5 +1,4 @@ import { Observable } from "rxjs"; -import { HeartRateSensor } from "web-ant-plus"; export interface IValidationErrors { [key: string]: Array<{ message: string; validatorKey: string }>; @@ -52,7 +51,8 @@ export interface IHeartRate { export interface IHeartRateService { disconnectDevice(): Promise | void; - discover$(): Observable | BluetoothRemoteGATTCharacteristic> | HeartRateSensor>; + discover(): Promise; + reconnect(): Promise; streamHRMonitorBatteryLevel$(): Observable; streamHeartRate$(): Observable; } @@ -81,6 +81,7 @@ export enum BleServiceFlag { export type HeartRateMonitorMode = "ant" | "ble" | "off"; export class Config { + bleDeviceId: string = ""; heartRateMonitor: HeartRateMonitorMode = "off"; webSocketAddress: string = `ws://${window.location.host}/ws`; } diff --git a/src/common/services/ant-heart-rate.service.ts b/src/common/services/ant-heart-rate.service.ts index a19b132..17da739 100644 --- a/src/common/services/ant-heart-rate.service.ts +++ b/src/common/services/ant-heart-rate.service.ts @@ -2,8 +2,7 @@ import { Injectable } from "@angular/core"; import { MatSnackBar } from "@angular/material/snack-bar"; import { BehaviorSubject, - catchError, - EMPTY, + filter, from, fromEvent, map, @@ -11,11 +10,13 @@ import { Observable, of, startWith, + Subscription, switchMap, + take, + takeWhile, tap, } from "rxjs"; -import { HeartRateSensor, HeartRateSensorState } from "web-ant-plus"; -import { USBDriver } from "web-ant-plus/dist/USBDriver"; +import { HeartRateSensor, HeartRateSensorState, USBDriver } from "web-ant-plus"; import { IHeartRate, IHeartRateService } from "../common.interfaces"; @@ -31,51 +32,61 @@ export class AntHeartRateService implements IHeartRateService { HeartRateSensor | undefined >(undefined); + private onConnect: Subscription | undefined; + + private onConnect$: Observable = ( + fromEvent(navigator.usb, "connect") as Observable + ).pipe( + filter( + (event: USBConnectionEvent): boolean => + ((event.device.vendorId === USBDriver.supportedDevices[0].vendor || + event.device.vendorId === USBDriver.supportedDevices[1].vendor) && + event.device.productId === USBDriver.supportedDevices[0].product) || + event.device.productId === USBDriver.supportedDevices[1].product, + ), + switchMap((): Observable => from(this.reconnect())), + ); private stick: USBDriver | undefined = undefined; constructor(private snackBar: MatSnackBar) {} async disconnectDevice(): Promise { - await this.heartRateSensorSubject.value?.detach(); - await this.stick?.close(); + if (this.heartRateSensorSubject.value !== undefined) { + await this.stick?.close(); + } + this.onConnect?.unsubscribe(); this.batteryLevelSubject.next(undefined); this.heartRateSensorSubject.next(undefined); this.stick = undefined; + this.onConnect = undefined; } - discover$(): Observable { - return from(USBDriver.requestDevice()).pipe( - tap({ - error: (error: unknown): void => { - if (error) { - this.snackBar.open("No USB device was selected", "Dismiss"); - } - }, - }), - switchMap((stick: USBDriver): Observable => { - this.stick = stick; - const hrSensor = new HeartRateSensor(this.stick); - - return merge( - fromEvent(this.stick, "startup").pipe( - switchMap((): Observable => from(hrSensor.attachSensor(0, 0))), - ), - fromEvent(hrSensor, "detached").pipe( - switchMap((): Observable => { - this.batteryLevelSubject.next(undefined); - this.heartRateSensorSubject.next(undefined); - - return from(hrSensor.attachSensor(0, 0)); - }), - ), - from(this.stick.open()), - ).pipe( - map((): HeartRateSensor => hrSensor), - tap((hrSensor: HeartRateSensor): void => this.heartRateSensorSubject.next(hrSensor)), - ); - }), - catchError((): Observable => EMPTY), - ); + async discover(): Promise { + let newStick: USBDriver | undefined; + try { + newStick = await USBDriver.createFromNewDevice(); + } catch (error) { + if (error) { + this.snackBar.open("No USB device was selected", "Dismiss"); + } + } + if (newStick !== undefined) { + await this.disconnectDevice(); + await this.connect(newStick); + } + } + + async reconnect(): Promise { + await this.disconnectDevice(); + const stick = await USBDriver.createFromPairedDevice(); + + if (this.onConnect === undefined) { + this.onConnect = this.onConnect$.subscribe(); + } + + if (stick !== undefined) { + await this.connect(stick); + } } streamHRMonitorBatteryLevel$(): Observable { @@ -91,7 +102,6 @@ export class AntHeartRateService implements IHeartRateService { const batteryLevel = data.BatteryLevel ?? this.parseBatteryStatus(data.BatteryStatus) ?? 0; this.batteryLevelSubject.next(batteryLevel); - console.log(data); return { contactDetected: true, @@ -108,6 +118,55 @@ export class AntHeartRateService implements IHeartRateService { ); } + private async connect(stick: USBDriver): Promise { + if (this.onConnect === undefined) { + this.onConnect = this.onConnect$.subscribe(); + } + this.stick = stick; + const hrSensor = new HeartRateSensor(this.stick); + + fromEvent(this.stick, "startup") + .pipe( + take(1), + switchMap((): Observable => { + this.snackBar.open("ANT+ Stick is ready", "Dismiss"); + + return from(hrSensor.attachSensor(0, 0)); + }), + switchMap( + (): Observable => + merge( + (fromEvent(hrSensor, "detached") as Observable).pipe( + switchMap((): Observable => { + this.batteryLevelSubject.next(undefined); + this.heartRateSensorSubject.next(undefined); + this.snackBar.open("Heart Rate Monitor connection lost", "Dismiss"); + + return from(hrSensor.attachSensor(0, 0)); + }), + ), + (fromEvent(hrSensor, "attached") as Observable).pipe( + tap((): void => { + this.heartRateSensorSubject.next(hrSensor); + }), + ), + ), + ), + ) + .pipe(takeWhile((): boolean => this.onConnect !== undefined)) + .subscribe(); + + try { + await this.stick.open(); + } catch (error) { + if (error instanceof Error) { + console.error(error); + this.heartRateSensorSubject.next(undefined); + this.snackBar.open("An error occurred while communicating with ANT+ Stick", "Dismiss"); + } + } + } + private parseBatteryStatus( batteryStatus: "New" | "Good" | "Ok" | "Low" | "Critical" | "Invalid" | undefined, ): number { diff --git a/src/common/services/ble-heart-rate.service.ts b/src/common/services/ble-heart-rate.service.ts index e8fb345..4cbee5b 100644 --- a/src/common/services/ble-heart-rate.service.ts +++ b/src/common/services/ble-heart-rate.service.ts @@ -56,6 +56,24 @@ export class BLEHeartRateService implements IHeartRateService { } } + // TODO: Reconnect feature: + // a new function in the heart rate service is needed that routes to either ant+ or ble depending on the setting so the right reconnect is done + // need to handle the change of setting which should trigger a reconnect of the other + // 1) this has been only tested in chrome + // 2) need to enable the chrome://flags/#enable-web-bluetooth-new-permissions-backend in chrome + + // need to check if getDevices API is available + // it needs to save the last connected device id in local storage and then search getDevvices(). + // const devices = (await navigator.bluetooth.getDevices()) + // register the onadverisementreceived event (only once) + // devices[0].onadvertisementreceived = (event) =>{console.log(event) + // event.device.gatt.connect() + // } + + // switch on advertisement watch + // await devices[0].watchAdvertisements() + // get the heartratecharacteristic and do next. everything should be a go then. + discover$(): Observable | BluetoothRemoteGATTCharacteristic>> { return this.ble .discover$({ diff --git a/src/common/services/heart-rate.service.ts b/src/common/services/heart-rate.service.ts index 32f9935..8b15f59 100644 --- a/src/common/services/heart-rate.service.ts +++ b/src/common/services/heart-rate.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@angular/core"; +import { MatSnackBar } from "@angular/material/snack-bar"; import { EMPTY, filter, Observable, of, shareReplay, startWith, switchMap } from "rxjs"; -import { HeartRateSensor } from "web-ant-plus"; import { Config, HeartRateMonitorMode, IHeartRate } from "../common.interfaces"; @@ -18,32 +18,48 @@ export class HeartRateService { private configManager: ConfigManagerService, private ble: BLEHeartRateService, private ant: AntHeartRateService, + private snack: MatSnackBar, ) {} - discover$(): Observable | BluetoothRemoteGATTCharacteristic> | HeartRateSensor> { + async discover(): Promise { + this.ble.disconnectDevice(); + switch (this.configManager.getConfig().heartRateMonitor) { case "ble": - return this.ble.discover$(); + this.ble.discover$().subscribe(); + break; case "ant": - return this.ant.discover$(); + return await this.ant.discover(); default: - return EMPTY; + return; } } streamHeartRate(): Observable { + if (!isSecureContext) { + this.snack.open("Heart Rate features are not available, refer to documentation", "Dismiss"); + + return EMPTY; + } + return this.configManager.config$.pipe( filter((config: Config): boolean => config.heartRateMonitor !== this.heartRateMonitor), switchMap((config: Config): Observable => { this.heartRateMonitor = config.heartRateMonitor; this.ble.disconnectDevice(); - this.ant.disconnectDevice(); + switch (config.heartRateMonitor) { case "ble": + this.ant.disconnectDevice(); + return this.ble.streamHeartRate$(); case "ant": + this.ant.reconnect(); + return this.ant.streamHeartRate$(); default: + this.ant.disconnectDevice(); + return of(undefined); } }),