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