Refactor heart rate peripherals

Refactor ANT manager to be a central class managing the ANT stick state.
This state could be used for future implementation of other ANT profiles
and the peripheralManager should be responsible for creating once needed
This commit is contained in:
Abász 2022-12-12 23:20:39 +01:00
parent 3edfe22434
commit 7ef338d856
7 changed files with 118 additions and 73 deletions

View File

@ -417,7 +417,7 @@ function createRowingStatistics (config) {
} }
return Object.assign(emitter, { return Object.assign(emitter, {
handleHeartrateMeasurement, handleHeartRateMeasurement: handleHeartrateMeasurement,
handleRotationImpulse, handleRotationImpulse,
setIntervalParameters, setIntervalParameters,
pause: pauseTraining, pause: pauseTraining,

View File

@ -12,6 +12,9 @@ import log from 'loglevel'
import EventEmitter from 'node:events' import EventEmitter from 'node:events'
import { createCpsPeripheral } from './ble/CpsPeripheral.js' import { createCpsPeripheral } from './ble/CpsPeripheral.js'
import { createCscPeripheral } from './ble/CscPeripheral.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'
const modes = ['FTMS', 'FTMSBIKE', 'PM5', 'CSC', 'CPS'] const modes = ['FTMS', 'FTMSBIKE', 'PM5', 'CSC', 'CPS']
function createPeripheralManager () { function createPeripheralManager () {
@ -94,11 +97,32 @@ function createPeripheralManager () {
}) })
} }
function startBleHeartRateService () {
const hrmPeripheral = child_process.fork('./app/peripherals/ble/HrmPeripheral.js')
hrmPeripheral.on('message', (heartRateMeasurement) => {
emitter.emit('heartRateBleMeasurement', heartRateMeasurement)
})
}
function startAntHeartRateService () {
if (!this._antManager) {
this._antManager = new AntManager()
}
const antHrm = createAntHrmPeripheral(this._antManager)
antHrm.on('heartRateMeasurement', (heartRateMeasurement) => {
emitter.emit('heartRateAntMeasurement', heartRateMeasurement)
})
}
function controlCallback (event) { function controlCallback (event) {
emitter.emit('control', event) emitter.emit('control', event)
} }
return Object.assign(emitter, { return Object.assign(emitter, {
startAntHeartRateService,
startBleHeartRateService,
getPeripheral, getPeripheral,
getPeripheralMode, getPeripheralMode,
switchPeripheralMode, switchPeripheralMode,

View File

@ -9,55 +9,26 @@
- Garmin USB or USB2 ANT+ or an off-brand clone of it (ID 0x1008) - Garmin USB or USB2 ANT+ or an off-brand clone of it (ID 0x1008)
- Garmin mini ANT+ (ID 0x1009) - Garmin mini ANT+ (ID 0x1009)
*/ */
import log from 'loglevel'
import Ant from 'ant-plus' import Ant from 'ant-plus'
import EventEmitter from 'node:events'
function createAntManager () { export default class AntManager {
const emitter = new EventEmitter() constructor () {
const antStick = new Ant.GarminStick2()
const antStick3 = new Ant.GarminStick3()
// it seems that we have to use two separate heart rate sensors to support both old and new // 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 // ant sticks, since the library requires them to be bound before open is called
const heartrateSensor = new Ant.HeartRateSensor(antStick) this._stick = new Ant.GarminStick3() // 0fcf:1009
const heartrateSensor3 = new Ant.HeartRateSensor(antStick3) if (!this._stick.is_present()) {
this._stick = new Ant.GarminStick2() // 0fcf:1008
heartrateSensor.on('hbData', (data) => { }
emitter.emit('heartrateMeasurement', { heartrate: data.ComputedHeartRate, batteryLevel: data.BatteryLevel })
})
heartrateSensor3.on('hbData', (data) => {
emitter.emit('heartrateMeasurement', { heartrate: data.ComputedHeartRate, batteryLevel: data.BatteryLevel })
})
antStick.on('startup', () => {
log.info('classic ANT+ stick found')
heartrateSensor.attach(0, 0)
})
antStick3.on('startup', () => {
log.info('mini ANT+ stick found')
heartrateSensor3.attach(0, 0)
})
antStick.on('shutdown', () => {
log.info('classic ANT+ stick lost')
})
antStick3.on('shutdown', () => {
log.info('mini ANT+ stick lost')
})
if (!antStick.open()) {
log.debug('classic ANT+ stick NOT found')
} }
if (!antStick3.open()) { openAntStick () {
log.debug('mini ANT+ stick NOT found') if (!this._stick.open()) {
return false
}
return this._stick
} }
return Object.assign(emitter, { getAntStick () {
}) return this._stick
}
} }
export { createAntManager }

View File

@ -0,0 +1,51 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are required for
a Cycling Speed and Cadence Profile
*/
import EventEmitter from 'node:events'
import Ant from 'ant-plus'
import log from 'loglevel'
function createAntHrmPeripheral (antManager) {
const emitter = new EventEmitter()
const antStick = antManager.getAntStick()
const heartRateSensor = new Ant.HeartRateSensor(antStick)
heartRateSensor.on('hbData', (data) => {
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 destroy () {
return new Promise((resolve) => {
heartRateSensor.detach()
heartRateSensor.on('detached', () => {
antStick.removeAllListeners()
heartRateSensor.removeAllListeners()
resolve()
})
})
}
return Object.assign(emitter, {
destroy
})
}
export { createAntHrmPeripheral }

View File

@ -5,14 +5,14 @@
Starts the central manager in a forked thread since noble does not like Starts the central manager in a forked thread since noble does not like
to run in the same thread as bleno to run in the same thread as bleno
*/ */
import { createCentralManager } from './CentralManager.js'
import process from 'process' import process from 'process'
import config from '../../tools/ConfigManager.js' import config from '../../tools/ConfigManager.js'
import log from 'loglevel' import log from 'loglevel'
import { createHeartRateManager } from './hrm/HeartRateManager.js'
log.setLevel(config.loglevel.default) log.setLevel(config.loglevel.default)
const centralManager = createCentralManager() const heartRateManager = createHeartRateManager()
centralManager.on('heartrateMeasurement', (heartrateMeasurement) => { heartRateManager.on('heartRateMeasurement', (heartRateMeasurement) => {
process.send(heartrateMeasurement) process.send(heartRateMeasurement)
}) })

View File

@ -49,7 +49,7 @@ NobleBindings.prototype.onDisconnComplete = function (handle, reason) {
const noble = new Noble(new NobleBindings()) const noble = new Noble(new NobleBindings())
// END of noble patch // END of noble patch
function createCentralManager () { function createHeartRateManager () {
const emitter = new EventEmitter() const emitter = new EventEmitter()
let batteryLevel let batteryLevel
@ -64,10 +64,10 @@ function createCentralManager () {
noble.on('discover', (peripheral) => { noble.on('discover', (peripheral) => {
noble.stopScanning() noble.stopScanning()
connectHeartratePeripheral(peripheral) connectHeartRatePeripheral(peripheral)
}) })
function connectHeartratePeripheral (peripheral) { function connectHeartRatePeripheral (peripheral) {
// connect to the heart rate sensor // connect to the heart rate sensor
peripheral.connect((error) => { peripheral.connect((error) => {
if (error) { if (error) {
@ -75,7 +75,7 @@ function createCentralManager () {
return return
} }
log.info(`heart rate peripheral connected, name: '${peripheral.advertisement?.localName}', id: ${peripheral.id}`) log.info(`heart rate peripheral connected, name: '${peripheral.advertisement?.localName}', id: ${peripheral.id}`)
subscribeToHeartrateMeasurement(peripheral) subscribeToHeartRateMeasurement(peripheral)
}) })
peripheral.once('disconnect', () => { peripheral.once('disconnect', () => {
@ -87,33 +87,33 @@ function createCentralManager () {
} }
// see https://www.bluetooth.com/specifications/specs/heart-rate-service-1-0/ // see https://www.bluetooth.com/specifications/specs/heart-rate-service-1-0/
function subscribeToHeartrateMeasurement (peripheral) { function subscribeToHeartRateMeasurement (peripheral) {
const heartrateMeasurementUUID = '2a37' const heartRateMeasurementUUID = '2a37'
const batteryLevelUUID = '2a19' const batteryLevelUUID = '2a19'
peripheral.discoverSomeServicesAndCharacteristics([], [heartrateMeasurementUUID, batteryLevelUUID], peripheral.discoverSomeServicesAndCharacteristics([], [heartRateMeasurementUUID, batteryLevelUUID],
(error, services, characteristics) => { (error, services, characteristics) => {
if (error) { if (error) {
log.error(error) log.error(error)
return return
} }
const heartrateMeasurementCharacteristic = characteristics.find( const heartRateMeasurementCharacteristic = characteristics.find(
characteristic => characteristic.uuid === heartrateMeasurementUUID characteristic => characteristic.uuid === heartRateMeasurementUUID
) )
const batteryLevelCharacteristic = characteristics.find( const batteryLevelCharacteristic = characteristics.find(
characteristic => characteristic.uuid === batteryLevelUUID characteristic => characteristic.uuid === batteryLevelUUID
) )
if (heartrateMeasurementCharacteristic !== undefined) { if (heartRateMeasurementCharacteristic !== undefined) {
heartrateMeasurementCharacteristic.notify(true, (error) => { heartRateMeasurementCharacteristic.notify(true, (error) => {
if (error) { if (error) {
log.error(error) log.error(error)
return return
} }
heartrateMeasurementCharacteristic.on('data', (data, isNotification) => { heartRateMeasurementCharacteristic.on('data', (data, isNotification) => {
const buffer = Buffer.from(data) const buffer = Buffer.from(data)
const flags = buffer.readUInt8(0) const flags = buffer.readUInt8(0)
// bits of the feature flag: // bits of the feature flag:
@ -121,7 +121,7 @@ function createCentralManager () {
// 1 + 2: Sensor Contact Status // 1 + 2: Sensor Contact Status
// 3: Energy Expended Status // 3: Energy Expended Status
// 4: RR-Interval // 4: RR-Interval
const heartrateUint16LE = flags & 0b1 const heartRateUint16LE = flags & 0b1
// from the specs: // from the specs:
// While most human applications require support for only 255 bpm or less, special // While most human applications require support for only 255 bpm or less, special
@ -129,8 +129,8 @@ function createCentralManager () {
// If the Heart Rate Measurement Value is less than or equal to 255 bpm a UINT8 format // If the Heart Rate Measurement Value is less than or equal to 255 bpm a UINT8 format
// should be used for power savings. // should be used for power savings.
// If the Heart Rate Measurement Value exceeds 255 bpm a UINT16 format shall be used. // If the Heart Rate Measurement Value exceeds 255 bpm a UINT16 format shall be used.
const heartrate = heartrateUint16LE ? buffer.readUInt16LE(1) : buffer.readUInt8(1) const heartrate = heartRateUint16LE ? buffer.readUInt16LE(1) : buffer.readUInt8(1)
emitter.emit('heartrateMeasurement', { heartrate, batteryLevel }) emitter.emit('heartRateMeasurement', { heartrate, batteryLevel })
}) })
}) })
} }
@ -155,4 +155,4 @@ function createCentralManager () {
}) })
} }
export { createCentralManager } export { createHeartRateManager }

View File

@ -18,7 +18,6 @@ import { createPeripheralManager } from './peripherals/PeripheralManager.js'
import { replayRowingSession } from './tools/RowingRecorder.js' import { replayRowingSession } from './tools/RowingRecorder.js'
import { createWorkoutRecorder } from './engine/WorkoutRecorder.js' import { createWorkoutRecorder } from './engine/WorkoutRecorder.js'
import { createWorkoutUploader } from './engine/WorkoutUploader.js' import { createWorkoutUploader } from './engine/WorkoutUploader.js'
import { createAntManager } from './peripherals/ant/AntManager.js'
const exec = promisify(child_process.exec) const exec = promisify(child_process.exec)
// set the log levels // set the log levels
@ -192,17 +191,17 @@ rowingStatistics.on('rowingStopped', (metrics) => {
workoutRecorder.writeRecordings() workoutRecorder.writeRecordings()
}) })
if (config.heartrateMonitorBLE) { if (config.heartRateMonitorBLE) {
const bleCentralService = child_process.fork('./app/peripherals/ble/CentralService.js') peripheralManager.startBleHeartRateService()
bleCentralService.on('message', (heartrateMeasurement) => { peripheralManager.on('heartRateBleMeasurement', (heartRateMeasurement) => {
rowingStatistics.handleHeartrateMeasurement(heartrateMeasurement) rowingStatistics.handleHeartRateMeasurement(heartRateMeasurement)
}) })
} }
if (config.heartrateMonitorANT) { if (config.heartRateMonitorANT) {
const antManager = createAntManager() peripheralManager.startAntHeartRateService()
antManager.on('heartrateMeasurement', (heartrateMeasurement) => { peripheralManager.on('heartRateAntMeasurement', (heartRateMeasurement) => {
rowingStatistics.handleHeartrateMeasurement(heartrateMeasurement) rowingStatistics.handleHeartRateMeasurement(heartRateMeasurement)
}) })
} }