Skip to content

Commit

Permalink
Implement auto reconnect of ANT+ device
Browse files Browse the repository at this point in the history
Add auto reconnect feature to the ANT+ capabilities. As a result of
this, the WebGui is able to reconnect to already paired devices across
page reloads as well as in case of loss of connection to the sensor or
replugging the ANT+ stick
  • Loading branch information
Abász committed Sep 4, 2023
1 parent db3a075 commit 9a02e6e
Show file tree
Hide file tree
Showing 7 changed files with 163 additions and 54 deletions.
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class AppComponent {
this.isConnected$ = this.webSocketService.connectionStatus();
}

handleAction($event: ButtonClickedTargets): void {
async handleAction($event: ButtonClickedTargets): Promise<void> {
if ($event === "reset") {
this.activityStartTime = Date.now();
this.dataService.reset();
Expand All @@ -68,7 +68,7 @@ export class AppComponent {
}

if ($event === "heartRate") {
this.heartRateService.discover$().subscribe();
await this.heartRateService.discover();
}

if ($event === "bluetooth") {
Expand Down
17 changes: 17 additions & 0 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<void> => {
throw Error("WebUSB API is not available");
},
} as unknown as AntHeartRateService;
},
deps: [MatSnackBar],
},
],
bootstrap: [AppComponent],
})
Expand Down
5 changes: 3 additions & 2 deletions src/common/common.interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Observable } from "rxjs";
import { HeartRateSensor } from "web-ant-plus";

export interface IValidationErrors {
[key: string]: Array<{ message: string; validatorKey: string }>;
Expand Down Expand Up @@ -52,7 +51,8 @@ export interface IHeartRate {

export interface IHeartRateService {
disconnectDevice(): Promise<void> | void;
discover$(): Observable<Array<Observable<never> | BluetoothRemoteGATTCharacteristic> | HeartRateSensor>;
discover(): Promise<void>;
reconnect(): Promise<void>;
streamHRMonitorBatteryLevel$(): Observable<number | undefined>;
streamHeartRate$(): Observable<IHeartRate | undefined>;
}
Expand Down Expand Up @@ -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`;
}
Expand Down
139 changes: 99 additions & 40 deletions src/common/services/ant-heart-rate.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,21 @@ import { Injectable } from "@angular/core";
import { MatSnackBar } from "@angular/material/snack-bar";
import {
BehaviorSubject,
catchError,
EMPTY,
filter,
from,
fromEvent,
map,
merge,
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";

Expand All @@ -31,51 +32,61 @@ export class AntHeartRateService implements IHeartRateService {
HeartRateSensor | undefined
>(undefined);

private onConnect: Subscription | undefined;

private onConnect$: Observable<void> = (
fromEvent(navigator.usb, "connect") as Observable<USBConnectionEvent>
).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<void> => from(this.reconnect())),
);
private stick: USBDriver | undefined = undefined;

constructor(private snackBar: MatSnackBar) {}

async disconnectDevice(): Promise<void> {
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<HeartRateSensor> {
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<HeartRateSensor> => {
this.stick = stick;
const hrSensor = new HeartRateSensor(this.stick);

return merge(
fromEvent(this.stick, "startup").pipe(
switchMap((): Observable<void> => from(hrSensor.attachSensor(0, 0))),
),
fromEvent(hrSensor, "detached").pipe(
switchMap((): Observable<void> => {
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<never> => EMPTY),
);
async discover(): Promise<void> {
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<void> {
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<number | undefined> {
Expand All @@ -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,
Expand All @@ -108,6 +118,55 @@ export class AntHeartRateService implements IHeartRateService {
);
}

private async connect(stick: USBDriver): Promise<void> {
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<void> => {
this.snackBar.open("ANT+ Stick is ready", "Dismiss");

return from(hrSensor.attachSensor(0, 0));
}),
switchMap(
(): Observable<void> =>
merge(
(fromEvent(hrSensor, "detached") as Observable<void>).pipe(
switchMap((): Observable<void> => {
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<void>).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 {
Expand Down
18 changes: 18 additions & 0 deletions src/common/services/ble-heart-rate.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<Observable<never> | BluetoothRemoteGATTCharacteristic>> {
return this.ble
.discover$({
Expand Down
28 changes: 22 additions & 6 deletions src/common/services/heart-rate.service.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -18,32 +18,48 @@ export class HeartRateService {
private configManager: ConfigManagerService,
private ble: BLEHeartRateService,
private ant: AntHeartRateService,
private snack: MatSnackBar,
) {}

discover$(): Observable<Array<Observable<never> | BluetoothRemoteGATTCharacteristic> | HeartRateSensor> {
async discover(): Promise<void> {
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<IHeartRate | undefined> {
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<IHeartRate | undefined> => {
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);
}
}),
Expand Down

0 comments on commit 9a02e6e

Please sign in to comment.