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