From 17d6a74332175265fd4d90b7788b58797d9def1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=A1sz?= <> Date: Sun, 18 Dec 2022 15:19:47 +0100 Subject: [PATCH] Enable rotation of the heart rate monitor modes Add button to the GUI so the user can switch between heart rate monitor modes (BLE, ANT, OFF). Update peripheralManager to handle switching and implement necessary changes to the structure of the peripheralManager. --- app/client/components/DashboardActions.js | 14 +++-- app/client/lib/app.js | 4 ++ app/client/store/appState.js | 6 +- app/peripherals/PeripheralManager.js | 76 ++++++++++++++++++----- app/peripherals/ant/AntManager.js | 32 ++++++++-- app/peripherals/ant/HrmPeripheral.js | 27 ++++---- app/peripherals/ble/HrmPeripheral.js | 33 +++++++--- app/peripherals/ble/hrm/HrmService.js | 17 +++++ app/server.js | 28 ++++----- config/default.config.js | 14 ++--- 10 files changed, 178 insertions(+), 73 deletions(-) create mode 100644 app/peripherals/ble/hrm/HrmService.js diff --git a/app/client/components/DashboardActions.js b/app/client/components/DashboardActions.js index cde18ec..d6564df 100644 --- a/app/client/components/DashboardActions.js +++ b/app/client/components/DashboardActions.js @@ -7,7 +7,7 @@ import { AppElement, html, css } from './AppElement.js' import { customElement, state } from 'lit/decorators.js' -import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload } from '../lib/icons.js' +import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload, icon_heartbeat } from '../lib/icons.js' import './AppDialog.js' @customElement('dashboard-actions') @@ -66,7 +66,9 @@ export class DashboardActions extends AppElement { ${this.renderOptionalButtons()} -
${this.peripheralMode()}
+
${this.blePeripheralMode()}
+ +
${this.appState?.config?.hrmPeripheralMode}
${this.dialog ? this.dialog : ''} ` } @@ -101,8 +103,8 @@ export class DashboardActions extends AppElement { return buttons } - peripheralMode () { - const value = this.appState?.config?.peripheralMode + blePeripheralMode () { + const value = this.appState?.config?.blePeripheralMode switch (value) { case 'PM5': @@ -139,6 +141,10 @@ export class DashboardActions extends AppElement { this.sendEvent('triggerAction', { command: 'switchBlePeripheralMode' }) } + switchHrmPeripheralMode () { + this.sendEvent('triggerAction', { command: 'switchHrmMode' }) + } + uploadTraining () { this.dialog = html` diff --git a/app/client/lib/app.js b/app/client/lib/app.js index 5961202..e680715 100644 --- a/app/client/lib/app.js +++ b/app/client/lib/app.js @@ -134,6 +134,10 @@ export function createApp (app) { if (socket)socket.send(JSON.stringify({ command: 'switchBlePeripheralMode' })) break } + case 'switchHrmMode': { + if (socket)socket.send(JSON.stringify({ command: 'switchHrmMode' })) + break + } case 'reset': { resetFields() if (socket)socket.send(JSON.stringify({ command: 'reset' })) diff --git a/app/client/store/appState.js b/app/client/store/appState.js index 12666d7..ef27e12 100644 --- a/app/client/store/appState.js +++ b/app/client/store/appState.js @@ -11,8 +11,10 @@ export const APP_STATE = { // contains all the rowing metrics that are delivered from the backend metrics: {}, config: { - // currently can be FTMS, FTMSBIKE, PM5, CSC, CPS - peripheralMode: '', + // currently can be FTMS, FTMSBIKE, PM5, CSC, CPS, OFF + blePeripheralMode: '', + // currently can be ANT, BLE, OFF + hrmPeripheralMode: '', // true if upload to strava is enabled stravaUploadEnabled: false, // true if remote device shutdown is enabled diff --git a/app/peripherals/PeripheralManager.js b/app/peripherals/PeripheralManager.js index efa8ed2..8039854 100644 --- a/app/peripherals/PeripheralManager.js +++ b/app/peripherals/PeripheralManager.js @@ -12,17 +12,23 @@ import log from 'loglevel' import EventEmitter from 'node:events' import { createCpsPeripheral } from './ble/CpsPeripheral.js' import { createCscPeripheral } from './ble/CscPeripheral.js' -import child_process from 'child_process' import AntManager from './ant/AntManager.js' import { createAntHrmPeripheral } from './ant/HrmPeripheral.js' +import { createBleHrmPeripheral } from './ble/HrmPeripheral.js' const bleModes = ['FTMS', 'FTMSBIKE', 'PM5', 'CSC', 'CPS', 'OFF'] +const hrmModes = ['ANT', 'BLE', 'OFF'] function createPeripheralManager () { const emitter = new EventEmitter() + let _antManager let blePeripheral let bleMode + let hrmPeripheral + let hrmMode + createBlePeripheral(config.bluetoothMode) + createHrmPeripheral(config.heartRateMode) function getBlePeripheral () { return blePeripheral @@ -32,6 +38,14 @@ function createPeripheralManager () { return bleMode } + function getHrmPeripheral () { + return hrmPeripheral + } + + function getHrmPeripheralMode () { + return hrmMode + } + function switchBlePeripheralMode (newMode) { // if now mode was passed, select the next one from the list if (newMode === undefined) { @@ -102,22 +116,55 @@ function createPeripheralManager () { }) } - function startBleHeartRateService () { - const hrmPeripheral = child_process.fork('./app/peripherals/ble/HrmPeripheral.js') - hrmPeripheral.on('message', (heartRateMeasurement) => { - emitter.emit('heartRateBleMeasurement', heartRateMeasurement) - }) + function switchHrmMode (newMode) { + if (newMode === undefined) { + newMode = hrmModes[(hrmModes.indexOf(hrmMode) + 1) % hrmModes.length] + } + createHrmPeripheral(newMode) } - function startAntHeartRateService () { - if (!this._antManager) { - this._antManager = new AntManager() + async function createHrmPeripheral (newMode) { + if (hrmPeripheral) { + await hrmPeripheral.destroy() + hrmPeripheral.removeAllListeners() + + if (_antManager && newMode !== 'ANT') { await _antManager.closeAntStick() } } - const antHrm = createAntHrmPeripheral(this._antManager) + switch (newMode) { + case 'ANT': + log.info('heart rate profile: ANT') + if (!_antManager) { + _antManager = new AntManager() + } - antHrm.on('heartRateMeasurement', (heartRateMeasurement) => { - emitter.emit('heartRateAntMeasurement', heartRateMeasurement) + hrmPeripheral = createAntHrmPeripheral(_antManager) + hrmMode = 'ANT' + await hrmPeripheral.attach() + break + + case 'BLE': + log.info('heart rate profile: BLE') + hrmPeripheral = createBleHrmPeripheral() + hrmMode = 'BLE' + break + + default: + log.info('heart rate profile: Off') + hrmMode = 'OFF' + } + + if (hrmMode.toLocaleLowerCase() !== 'OFF'.toLocaleLowerCase()) { + hrmPeripheral.on('heartRateMeasurement', (heartRateMeasurement) => { + emitter.emit('heartRateMeasurement', heartRateMeasurement) + }) + } + + emitter.emit('control', { + req: { + name: 'hrmPeripheralMode', + peripheralMode: hrmMode + } }) } @@ -126,10 +173,11 @@ function createPeripheralManager () { } return Object.assign(emitter, { - startAntHeartRateService, - startBleHeartRateService, getBlePeripheral, getBlePeripheralMode, + getHrmPeripheral, + getHrmPeripheralMode, + switchHrmMode, switchBlePeripheralMode, notifyMetrics, notifyStatus diff --git a/app/peripherals/ant/AntManager.js b/app/peripherals/ant/AntManager.js index 3961477..d268c54 100644 --- a/app/peripherals/ant/AntManager.js +++ b/app/peripherals/ant/AntManager.js @@ -9,9 +9,12 @@ - Garmin USB or USB2 ANT+ or an off-brand clone of it (ID 0x1008) - Garmin mini ANT+ (ID 0x1009) */ +import log from 'loglevel' import Ant from 'ant-plus' export default class AntManager { + _isStickOpen = false + constructor () { // it seems that we have to use two separate heart rate sensors to support both old and new // ant sticks, since the library requires them to be bound before open is called @@ -22,10 +25,31 @@ export default class AntManager { } openAntStick () { - if (!this._stick.open()) { - return false - } - return this._stick + return new Promise((resolve, reject) => { + if (!this._stick.open()) { + reject(new Error('Error opening Ant Stick')) + } + this._stick.once('startup', () => { + log.info('ANT+ stick found') + this._isStickOpen = true + resolve(this._stick) + }) + }) + } + + closeAntStick () { + return new Promise(resolve => { + this._stick.once('shutdown', () => { + log.info('ANT+ stick is closed') + this._isStickOpen = false + resolve() + }) + this._stick.close() + }) + } + + isStickOpen () { + return this._isStickOpen } getAntStick () { diff --git a/app/peripherals/ant/HrmPeripheral.js b/app/peripherals/ant/HrmPeripheral.js index 4f6883d..e85ea66 100644 --- a/app/peripherals/ant/HrmPeripheral.js +++ b/app/peripherals/ant/HrmPeripheral.js @@ -7,7 +7,6 @@ */ import EventEmitter from 'node:events' import Ant from 'ant-plus' -import log from 'loglevel' function createAntHrmPeripheral (antManager) { const emitter = new EventEmitter() @@ -19,32 +18,28 @@ function createAntHrmPeripheral (antManager) { emitter.emit('heartRateMeasurement', { heartrate: data.ComputedHeartRate, batteryLevel: data.BatteryLevel }) }) - antStick.on('startup', () => { - log.info('ANT+ stick found') - heartRateSensor.attach(0, 0) - }) - - antStick.on('shutdown', () => { - log.info('classic ANT+ stick lost') - }) - - if (!antManager.openAntStick()) { - throw new Error('Error opening Ant Stick') + function attach () { + return new Promise(resolve => { + heartRateSensor.once('attached', () => { + resolve() + }) + heartRateSensor.attach(0, 0) + }) } function destroy () { return new Promise((resolve) => { - heartRateSensor.detach() - heartRateSensor.on('detached', () => { - antStick.removeAllListeners() + heartRateSensor.once('detached', () => { heartRateSensor.removeAllListeners() resolve() }) + heartRateSensor.detach() }) } return Object.assign(emitter, { - destroy + destroy, + attach }) } diff --git a/app/peripherals/ble/HrmPeripheral.js b/app/peripherals/ble/HrmPeripheral.js index 1916c70..cd3de27 100644 --- a/app/peripherals/ble/HrmPeripheral.js +++ b/app/peripherals/ble/HrmPeripheral.js @@ -5,14 +5,29 @@ Starts the central manager in a forked thread since noble does not like to run in the same thread as bleno */ -import process from 'process' -import config from '../../tools/ConfigManager.js' -import log from 'loglevel' -import { createHeartRateManager } from './hrm/HeartRateManager.js' +import EventEmitter from 'node:events' +import child_process from 'child_process' -log.setLevel(config.loglevel.default) -const heartRateManager = createHeartRateManager() +function createBleHrmPeripheral () { + const emitter = new EventEmitter() -heartRateManager.on('heartRateMeasurement', (heartRateMeasurement) => { - process.send(heartRateMeasurement) -}) + const bleHrmProcess = child_process.fork('./app/peripherals/ble/hrm/HrmService.js') + + bleHrmProcess.on('message', (heartRateMeasurement) => { + emitter.emit('heartRateMeasurement', heartRateMeasurement) + }) + + function destroy () { + return new Promise(resolve => { + bleHrmProcess.kill() + bleHrmProcess.removeAllListeners() + resolve() + }) + } + + return Object.assign(emitter, { + destroy + }) +} + +export { createBleHrmPeripheral } diff --git a/app/peripherals/ble/hrm/HrmService.js b/app/peripherals/ble/hrm/HrmService.js new file mode 100644 index 0000000..518af51 --- /dev/null +++ b/app/peripherals/ble/hrm/HrmService.js @@ -0,0 +1,17 @@ +'use strict' +/* + Open Rowing Monitor, https://github.com/laberning/openrowingmonitor + + Starts the central manager in a forked thread since noble does not like + to run in the same thread as bleno +*/ +import process from 'process' +import log from 'loglevel' +import config from '../../../tools/ConfigManager.js' +import { createHeartRateManager } from './HeartRateManager.js' + +log.setLevel(config.loglevel.default) +const heartRateManager = createHeartRateManager() +heartRateManager.on('heartRateMeasurement', (heartRateMeasurement) => { + process.send(heartRateMeasurement) +}) diff --git a/app/server.js b/app/server.js index d287559..7ecef86 100644 --- a/app/server.js +++ b/app/server.js @@ -102,11 +102,19 @@ peripheralManager.on('control', (event) => { webServer.notifyClients('config', getConfig()) event.res = true break + case 'hrmPeripheralMode': + webServer.notifyClients('config', getConfig()) + event.res = true + break default: log.info('unhandled Command', event.req) } }) +peripheralManager.on('heartRateMeasurement', (heartRateMeasurement) => { + rowingStatistics.handleHeartRateMeasurement(heartRateMeasurement) +}) + function pauseWorkout () { rowingStatistics.pause() } @@ -191,20 +199,6 @@ rowingStatistics.on('rowingStopped', (metrics) => { workoutRecorder.writeRecordings() }) -if (config.heartRateMonitorBLE) { - peripheralManager.startBleHeartRateService() - peripheralManager.on('heartRateBleMeasurement', (heartRateMeasurement) => { - rowingStatistics.handleHeartRateMeasurement(heartRateMeasurement) - }) -} - -if (config.heartRateMonitorANT) { - peripheralManager.startAntHeartRateService() - peripheralManager.on('heartRateAntMeasurement', (heartRateMeasurement) => { - rowingStatistics.handleHeartRateMeasurement(heartRateMeasurement) - }) -} - workoutUploader.on('authorizeStrava', (data, client) => { webServer.notifyClient(client, 'authorizeStrava', data) }) @@ -219,6 +213,9 @@ webServer.on('messageReceived', async (message, client) => { case 'switchBlePeripheralMode': peripheralManager.switchBlePeripheralMode() break + case 'switchHrmMode': + peripheralManager.switchHrmMode() + break case 'reset': resetWorkout() break @@ -243,7 +240,8 @@ webServer.on('clientConnected', (client) => { // todo: extract this into some kind of state manager function getConfig () { return { - peripheralMode: peripheralManager.getBlePeripheralMode(), + blePeripheralMode: peripheralManager.getBlePeripheralMode(), + hrmPeripheralMode: peripheralManager.getHrmPeripheralMode(), stravaUploadEnabled: !!config.stravaClientId && !!config.stravaClientSecret, shutdownEnabled: !!config.shutdownCommand } diff --git a/config/default.config.js b/config/default.config.js index e44371d..94efe86 100644 --- a/config/default.config.js +++ b/config/default.config.js @@ -81,15 +81,11 @@ export default { // - OFF: Turns Bluetooth advertisement off bluetoothMode: 'FTMS', - // Turn this on if you want support for Bluetooth Low Energy heart rate monitors - // Will currenty connect to the first device found - heartrateMonitorBLE: true, - - // Turn this on if you want support for ANT+ heart rate monitors - // You will need an ANT+ USB stick for this to work, the following models might work: - // - Garmin USB or USB2 ANT+ or an off-brand clone of it (ID 0x1008) - // - Garmin mini ANT+ (ID 0x1009) - heartrateMonitorANT: false, + // Selects the heart rate monitor mode. Supported modes: + // - BLE: Use Bluetooth Low Energy to connect Heart Rate Monitor (Will currently connect to the first device found) + // - ANT: Use Ant+ to connect Heart Rate Monitor + // - OFF: turns of Heart Rate Monitor discovery + heartRateMode: 'BLE', // Defines the name that is used to announce the FTMS Rower via Bluetooth Low Energy (BLE) // Some rowing training applications expect that the rowing device is announced with a certain name