adds support for BLE heart rate monitors

This commit is contained in:
Lars Berning 2021-04-14 22:13:24 +02:00
parent 870fc53fc8
commit 43fa45bf29
18 changed files with 1466 additions and 337 deletions

View File

@ -24,6 +24,7 @@ Open Rowing Monitor implements a physics model to simulate the typical metrics o
* Strokes per Minute
* Calories used (kcal)
* Training Duration
* Heart Rate (requires BLE Heart Rate Monitor)
### Web Interface

158
app/ble/CentralManager.js Normal file
View File

@ -0,0 +1,158 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
This manager creates a Bluetooth Low Energy (BLE) Central that listens
and subscribes to heart rate services
*/
import log from 'loglevel'
import EventEmitter from 'node:events'
import Noble from '@abandonware/noble/lib/noble.js'
import NobleBindings from '@abandonware/noble/lib/hci-socket/bindings.js'
// We are using peripherals and centrals at the same time (with bleno and noble).
// The libraries do not play nice together in this scenario when they see peripherals
// from each other via the HCI-Socket.
// This is a quick patch for two handlers in noble that would otherwise throw warnings
// when they see a peripheral or handle that is managed by bleno
// START of noble patch
Noble.prototype.onRssiUpdate = function (peripheralUuid, rssi) {
const peripheral = this._peripherals[peripheralUuid]
if (peripheral) {
peripheral.rssi = rssi
peripheral.emit('rssiUpdate', rssi)
}
}
NobleBindings.prototype.onDisconnComplete = function (handle, reason) {
const uuid = this._handles[handle]
if (uuid) {
this._aclStreams[handle].push(null, null)
this._gatts[handle].removeAllListeners()
this._signalings[handle].removeAllListeners()
delete this._gatts[uuid]
delete this._gatts[handle]
delete this._signalings[uuid]
delete this._signalings[handle]
delete this._aclStreams[handle]
delete this._handles[uuid]
delete this._handles[handle]
this.emit('disconnect', uuid)
}
}
const noble = new Noble(new NobleBindings())
// END of noble patch
function createCentralManager () {
const emitter = new EventEmitter()
let batteryLevel
noble.on('stateChange', function (state) {
if (state === 'poweredOn') {
// search for heart rate service
noble.startScanning(['180d'], false)
} else {
noble.stopScanning()
}
})
noble.on('discover', (peripheral) => {
noble.stopScanning()
connectHeartratePeripheral(peripheral)
})
function connectHeartratePeripheral (peripheral) {
// connect to the heart rate sensor
peripheral.connect((error) => {
if (error) {
log.error(error)
return
}
log.info(`heart rate peripheral connected, name: '${peripheral.advertisement?.localName}', id: ${peripheral.id}`)
subscribeToHeartrateMeasurement(peripheral)
})
peripheral.once('disconnect', () => {
// todo: figure out if we have to dispose the peripheral somehow to prevent memory leaks
log.info('heart rate peripheral disconnected, searching new one')
batteryLevel = undefined
noble.startScanning(['180d'], false)
})
}
// see https://www.bluetooth.com/specifications/specs/heart-rate-service-1-0/
function subscribeToHeartrateMeasurement (peripheral) {
const heartrateMeasurementUUID = '2a37'
const batteryLevelUUID = '2a19'
peripheral.discoverSomeServicesAndCharacteristics([], [heartrateMeasurementUUID, batteryLevelUUID],
(error, services, characteristics) => {
if (error) {
log.error(error)
return
}
const heartrateMeasurementCharacteristic = characteristics.find(
characteristic => characteristic.uuid === heartrateMeasurementUUID
)
const batteryLevelCharacteristic = characteristics.find(
characteristic => characteristic.uuid === batteryLevelUUID
)
if (heartrateMeasurementCharacteristic !== undefined) {
heartrateMeasurementCharacteristic.notify(true, (error) => {
if (error) {
log.error(error)
return
}
heartrateMeasurementCharacteristic.on('data', (data, isNotification) => {
const buffer = Buffer.from(data)
const flags = buffer.readUInt8(0)
// bits of the feature flag:
// 0: Heart Rate Value Format
// 1 + 2: Sensor Contact Status
// 3: Energy Expended Status
// 4: RR-Interval
const heartrateUint16LE = flags & 0b1
// from the specs:
// While most human applications require support for only 255 bpm or less, special
// applications (e.g. animals) may require support for higher bpm values.
// If the Heart Rate Measurement Value is less than or equal to 255 bpm a UINT8 format
// should be used for power savings.
// If the Heart Rate Measurement Value exceeds 255 bpm a UINT16 format shall be used.
const heartrate = heartrateUint16LE ? buffer.readUInt16LE(1) : buffer.readUInt8(1)
emitter.emit('heartrateMeasurement', { heartrate, batteryLevel })
})
})
}
if (batteryLevelCharacteristic !== undefined) {
batteryLevelCharacteristic.notify(true, (error) => {
if (error) {
log.error(error)
return
}
batteryLevelCharacteristic.on('data', (data, isNotification) => {
const buffer = Buffer.from(data)
batteryLevel = buffer.readUInt8(0)
})
})
}
})
}
return Object.assign(emitter, {
})
}
export { createCentralManager }

20
app/ble/CentralService.js Normal file
View File

@ -0,0 +1,20 @@
'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
todo: check if noble would also work if we move this into a worker thread
(would save some ressources)
*/
import { createCentralManager } from './CentralManager.js'
import process from 'process'
import config from '../config.js'
import log from 'loglevel'
log.setLevel(config.loglevel.default)
const centralManager = createCentralManager()
centralManager.on('heartrateMeasurement', (heartrateMeasurement) => {
process.send(heartrateMeasurement)
})

View File

@ -98,7 +98,7 @@ function createFtmsPeripheral (controlCallback, options) {
// present current rowing metrics to FTMS central
function notifyData (type, data) {
if (type === 'strokeFinished' || type === 'rowingPaused') {
if (type === 'strokeFinished' || type === 'metricsUpdate') {
fitnessMachineService.notifyData(data)
}
}

View File

@ -83,7 +83,7 @@ export default class IndoorBikeDataCharacteristic extends bleno.Characteristic {
// Energy per minute
bufferBuilder.writeUInt8(data.caloriesPerMinute)
// Heart Rate: Beats per minute with a resolution of 1
bufferBuilder.writeUInt8(data.heartRate)
bufferBuilder.writeUInt8(data.heartrate)
// Elapsed Time: Seconds with a resolution of 1
bufferBuilder.writeUInt16LE(data.durationTotal)

View File

@ -82,7 +82,7 @@ export default class RowerDataCharacteristic extends bleno.Characteristic {
// Energy per minute
bufferBuilder.writeUInt8(data.caloriesPerMinute)
// Heart Rate: Beats per minute with a resolution of 1
bufferBuilder.writeUInt8(data.heartRate)
bufferBuilder.writeUInt8(data.heartrate)
// Elapsed Time: Seconds with a resolution of 1
bufferBuilder.writeUInt16LE(data.durationTotal)

View File

@ -76,7 +76,7 @@ export default class PM5RowingService extends bleno.PrimaryService {
}
notifyData (type, data) {
if (type === 'strokeFinished' || type === 'rowingPaused') {
if (type === 'strokeFinished' || type === 'metricsUpdate') {
this.generalStatus.notify(data)
this.additionalStatus.notify(data)
this.additionalStatus2.notify(data)

View File

@ -43,8 +43,8 @@ export default class AdditionalStatus extends bleno.Characteristic {
bufferBuilder.writeUInt16LE(data.speed * 1000 / 3.6)
// strokeRate: UInt8 in strokes/min
bufferBuilder.writeUInt8(data.strokesPerMinute)
// heartRate: UInt8 in bpm, 255 if invalid
bufferBuilder.writeUInt8(255)// data.heartRate
// heartrate: UInt8 in bpm, 255 if invalid
bufferBuilder.writeUInt8(data.heartrate)
// currentPace: UInt16LE in 0.01 sec/500m
// if split is infinite (i.e. while pausing), use the highest possible number
bufferBuilder.writeUInt16LE(data.split !== Infinity ? data.split * 100 : 0xFFFF)

View File

@ -7,7 +7,8 @@
import NoSleep from 'nosleep.js'
export function createApp () {
const fields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', 'power', 'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted', 'peripheralMode']
const fields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', 'power', 'heartrate',
'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted', 'peripheralMode']
const fieldFormatter = {
peripheralMode: (value) => {
if (value === 'PM5') {
@ -18,7 +19,10 @@ export function createApp () {
return 'FTMS Rower'
}
},
distanceTotal: (value) => value >= 10000 ? { value: (value / 1000).toFixed(1), unit: 'km' } : { value, unit: 'm' }
distanceTotal:
(value) => value >= 10000
? { value: (value / 1000).toFixed(1), unit: 'km' }
: { value, unit: 'm' }
}
const standalone = (window.location.hash === '#:standalone:')
@ -61,14 +65,31 @@ export function createApp () {
try {
const data = JSON.parse(event.data)
// show heart rate, if present
if (data.heartrate !== undefined) {
if (data.heartrate !== 0) {
document.getElementById('heartrate-container').style.display = 'inline-block'
document.getElementById('strokes-total-container').style.display = 'none'
} else {
document.getElementById('strokes-total-container').style.display = 'inline-block'
document.getElementById('heartrate-container').style.display = 'none'
}
}
let activeFields = fields
// if we are in reset state only update heart rate and peripheral mode
if (data.strokesTotal === 0) {
activeFields = ['heartrate', 'peripheralMode']
}
for (const [key, value] of Object.entries(data)) {
if (fields.includes(key)) {
if (activeFields.includes(key)) {
const valueFormatted = fieldFormatter[key] ? fieldFormatter[key](value) : value
if (valueFormatted.value && valueFormatted.unit) {
document.getElementById(key).innerHTML = valueFormatted.value
document.getElementById(`${key}Unit`).innerHTML = valueFormatted.unit
if (valueFormatted.value !== undefined && valueFormatted.unit !== undefined) {
if (document.getElementById(key)) document.getElementById(key).innerHTML = valueFormatted.value
if (document.getElementById(`${key}Unit`)) document.getElementById(`${key}Unit`).innerHTML = valueFormatted.unit
} else {
document.getElementById(key).innerHTML = valueFormatted
if (document.getElementById(key)) document.getElementById(key).innerHTML = valueFormatted
}
}
}
@ -95,8 +116,8 @@ export function createApp () {
}
function resetFields () {
for (const key of fields) {
document.getElementById(key).innerHTML = '--'
for (const key of fields.filter((elem) => elem !== 'peripheralMode')) {
if (document.getElementById(key)) document.getElementById(key).innerHTML = '--'
}
}

View File

@ -56,30 +56,34 @@
<span class="metric-unit">/min</span>
</div>
</div>
<div class="col">
<div class="label">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 303.46 303.46">
<path fill="currentColor" d="m254.8 278.23-103.07-103.07-103.07 103.07c-18.349 0.91143-22.401-4.2351-21.213-21.213l103.07-103.07-19.755-19.755c-20.183-1.639-63.38-27.119-70.631-34.37l-30.474-30.474c-5.224-5.224-8.101-12.168-8.101-19.555s2.877-14.332 8.102-19.555l18.365-18.365c5.223-5.224 12.167-8.1 19.554-8.1s14.331 2.876 19.554 8.1l30.475 30.475c6.162 6.163 16.762 25.271 22.383 36.609 7.431 14.991 11.352 25.826 11.979 34.014l19.763 19.763 19.762-19.762c0.627-8.188 4.548-19.023 11.98-34.015 5.621-11.34 16.221-30.447 22.383-36.609l30.475-30.475c5.223-5.224 12.167-8.1 19.554-8.1s14.331 2.876 19.554 8.1l18.366 18.366c10.781 10.781 10.782 28.325 1e-3 39.107l-30.476 30.475c-7.25 7.252-50.443 32.731-70.63 34.37l-19.756 19.756 103.07 103.07c-1 17.227-4.2977 21.56-21.214 21.213z"/>
</svg>
<div id="strokes-total-container">
<div class="label">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill="currentColor" d="m 420.20605,468.95387 33.11371,-33.1137 c 5.22683,-5.22684 8.77492,-17.24658 3.54807,-22.47345 l -2.40714,-2.40718 c -5.22685,-5.22685 -24.05986,3.18856 -30.46144,6.8845 L 208.43041,202.27519 C 197.48359,161.4211 171.36396,110.7189 139.6182,78.964771 L 104.97336,44.31993 C 84.074343,23.420923 78.962098,22.904081 62.197813,39.66838 L 24.014429,77.851751 C 9.0221901,92.843993 7.7868003,99.708469 28.685812,120.60748 l 34.653205,34.65321 c 30.664056,30.66402 82.456343,57.8654 123.310393,68.8122 l 215.56885,215.56886 c -3.69593,6.40155 -12.11135,25.23463 -6.88448,30.46145 l 2.40718,2.40715 c 5.2185,5.21846 17.23824,1.67039 22.46509,-3.55648 z"/>
<path fill="currentColor" d="M 93.111861,469.41843 59.998156,436.30471 c -5.226842,-5.22684 -8.774914,-17.24659 -3.548088,-22.47344 l 2.407166,-2.40717 c 5.226836,-5.22685 24.059868,3.18854 30.46142,6.88449 L 304.88751,202.73974 c 10.94682,-40.85409 37.06645,-91.55629 68.81223,-123.310429 L 408.34455,44.78448 c 20.89903,-20.899007 26.01127,-21.415839 42.77554,-4.65156 l 38.18338,38.183384 c 14.99224,14.992241 16.22764,21.856706 -4.67138,42.755726 l -34.65319,34.6532 c -30.66405,30.66404 -82.45634,57.8654 -123.31038,68.8122 L 111.09965,440.10631 c 3.69593,6.40153 12.11135,25.23461 6.88448,30.46143 l -2.40718,2.40716 c -5.21849,5.21846 -17.23824,1.67038 -22.465089,-3.55647 z"/>
</svg>
</div>
<div class="content">
<span class="metric-value" id="strokesTotal"></span>
<span class="metric-unit">total</span>
</div>
</div>
<div class="content">
<span class="metric-value" id="strokesTotal"></span>
<span class="metric-unit">total</span>
<div id="heartrate-container">
<div class="label">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill="currentColor" d="M320.2 243.8l-49.7 99.4c-6 12.1-23.4 11.7-28.9-.6l-56.9-126.3-30 71.7H60.6l182.5 186.5c7.1 7.3 18.6 7.3 25.7 0L451.4 288H342.3l-22.1-44.2zM473.7 73.9l-2.4-2.5c-51.5-52.6-135.8-52.6-187.4 0L256 100l-27.9-28.5c-51.5-52.7-135.9-52.7-187.4 0l-2.4 2.4C-10.4 123.7-12.5 203 31 256h102.4l35.9-86.2c5.4-12.9 23.6-13.2 29.4-.4l58.2 129.3 49-97.9c5.9-11.8 22.7-11.8 28.6 0l27.6 55.2H481c43.5-53 41.4-132.3-7.3-182.1z"></path>
</svg>
</div>
<div class="content">
<span class="metric-value" id="heartrate"></span>
<span class="metric-unit">bpm</span>
</div>
</div>
</div>
<!--
<div class="col">
<div class="label">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill="currentColor" d="M320.2 243.8l-49.7 99.4c-6 12.1-23.4 11.7-28.9-.6l-56.9-126.3-30 71.7H60.6l182.5 186.5c7.1 7.3 18.6 7.3 25.7 0L451.4 288H342.3l-22.1-44.2zM473.7 73.9l-2.4-2.5c-51.5-52.6-135.8-52.6-187.4 0L256 100l-27.9-28.5c-51.5-52.7-135.9-52.7-187.4 0l-2.4 2.4C-10.4 123.7-12.5 203 31 256h102.4l35.9-86.2c5.4-12.9 23.6-13.2 29.4-.4l58.2 129.3 49-97.9c5.9-11.8 22.7-11.8 28.6 0l27.6 55.2H481c43.5-53 41.4-132.3-7.3-182.1z"></path>
</svg>
</div>
<div class="content">
<span class="metric-value" id="heartRate"></span>
<span class="metric-unit">bpm</span>
</div>
</div>
-->
<div class="col">
<div class="label">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M323.56 51.2c-20.8 19.3-39.58 39.59-56.22 59.97C240.08 73.62 206.28 35.53 168 0 69.74 91.17 0 209.96 0 281.6 0 408.85 100.29 512 224 512s224-103.15 224-230.4c0-53.27-51.98-163.14-124.44-230.4zm-19.47 340.65C282.43 407.01 255.72 416 226.86 416 154.71 416 96 368.26 96 290.75c0-38.61 24.31-72.63 72.79-130.75 6.93 7.98 98.83 125.34 98.83 125.34l58.63-66.88c4.14 6.85 7.91 13.55 11.27 19.97 27.35 52.19 15.81 118.97-33.43 153.42z"></path></svg>

View File

@ -67,7 +67,6 @@ div.label, div.content {
button {
outline:none;
/* background-color: #00468c; */
background-color: #365080;
border: 0;
color: white;
@ -80,6 +79,10 @@ button {
width: 3.5em;
}
#close-button {
#close-button, #heartrate-container {
display:none;
}
#heartrate-container, #strokes-total-container {
width:100%;
}

View File

@ -23,7 +23,11 @@ function createMovingIntervalAverager (movingDuration) {
}
function average () {
return sum / duration * movingDuration
if (duration > 0) {
return sum / duration * movingDuration
} else {
return 0
}
}
function reset () {

View File

@ -8,45 +8,50 @@ import * as assert from 'uvu/assert'
import { createMovingIntervalAverager } from './MovingIntervalAverager.js'
test('average of a datapoint with duration of averager is equal to datapoint', () => {
const minuteAverager = createMovingIntervalAverager(10)
minuteAverager.pushValue(5, 10)
assert.is(minuteAverager.average(), 5)
const movingAverager = createMovingIntervalAverager(10)
movingAverager.pushValue(5, 10)
assert.is(movingAverager.average(), 5)
})
test('average of a datapoint with half duration of averager is double to datapoint', () => {
const minuteAverager = createMovingIntervalAverager(20)
minuteAverager.pushValue(5, 10)
assert.is(minuteAverager.average(), 10)
const movingAverager = createMovingIntervalAverager(20)
movingAverager.pushValue(5, 10)
assert.is(movingAverager.average(), 10)
})
test('average of two identical datapoints with half duration of averager is equal to datapoint sum', () => {
const minuteAverager = createMovingIntervalAverager(20)
minuteAverager.pushValue(5, 10)
minuteAverager.pushValue(5, 10)
assert.is(minuteAverager.average(), 10)
const movingAverager = createMovingIntervalAverager(20)
movingAverager.pushValue(5, 10)
movingAverager.pushValue(5, 10)
assert.is(movingAverager.average(), 10)
})
test('average does not consider datapoints that are outside of duration', () => {
const minuteAverager = createMovingIntervalAverager(20)
minuteAverager.pushValue(10, 10)
minuteAverager.pushValue(5, 10)
minuteAverager.pushValue(5, 10)
assert.is(minuteAverager.average(), 10)
const movingAverager = createMovingIntervalAverager(20)
movingAverager.pushValue(10, 10)
movingAverager.pushValue(5, 10)
movingAverager.pushValue(5, 10)
assert.is(movingAverager.average(), 10)
})
test('average works with lots of values', () => {
// one hour
const minuteAverager = createMovingIntervalAverager(3000)
const movingAverager = createMovingIntervalAverager(3000)
for (let i = 0; i < 1000; i++) {
minuteAverager.pushValue(10, 1)
movingAverager.pushValue(10, 1)
}
for (let i = 0; i < 1000; i++) {
minuteAverager.pushValue(20, 1)
movingAverager.pushValue(20, 1)
}
for (let i = 0; i < 1000; i++) {
minuteAverager.pushValue(30, 2)
movingAverager.pushValue(30, 2)
}
assert.is(minuteAverager.average(), 50000)
assert.is(movingAverager.average(), 50000)
})
test('average should return 0 on empty dataset', () => {
const movingAverager = createMovingIntervalAverager(10)
assert.is(movingAverager.average(), 0)
})
test.run()

View File

@ -23,12 +23,26 @@ function createRowingStatistics () {
let trainingRunning = false
let durationTimer
let rowingPausedTimer
let heartrateResetTimer
let distanceTotal = 0.0
let durationTotal = 0
let strokesTotal = 0
let caloriesTotal = 0.0
let heartrate = 0
let heartrateBatteryLevel = 0
let lastStrokeDuration = 0.0
let lastStrokeState = 'RECOVERY'
let lastMetrics = {}
// send metrics to the clients periodically, if the data has changed
setInterval(emitMetrics, 1000)
function emitMetrics () {
const currentMetrics = getMetrics()
if (Object.entries(currentMetrics).toString() !== Object.entries(lastMetrics).toString()) {
emitter.emit('metricsUpdate', currentMetrics)
lastMetrics = currentMetrics
}
}
function handleStroke (stroke) {
if (!trainingRunning) startTraining()
@ -69,6 +83,18 @@ function createRowingStatistics () {
emitter.emit('strokeStateChanged', getMetrics())
}
// initiated when new heart rate value is received from heart rate sensor
function handleHeartrateMeasurement (value) {
// set the heart rate to zero, if we did not receive a value for some time
if (heartrateResetTimer)clearInterval(heartrateResetTimer)
heartrateResetTimer = setTimeout(() => {
heartrate = 0
heartrateBatteryLevel = 0
}, 2000)
heartrate = value.heartrate
heartrateBatteryLevel = value.batteryLevel
}
function getMetrics () {
const splitTime = speedAverager.weightedAverage() !== 0 ? (500.0 / speedAverager.weightedAverage()) : Infinity
return {
@ -86,7 +112,9 @@ function createRowingStatistics () {
powerRatio: powerRatioAverager.weightedAverage().toFixed(2),
strokesPerMinute: strokeAverager.weightedAverage() !== 0 ? (60.0 / strokeAverager.weightedAverage()).toFixed(1) : 0,
speed: (speedAverager.weightedAverage() * 3.6).toFixed(2), // km/h
strokeState: lastStrokeState
strokeState: lastStrokeState,
heartrate,
heartrateBatteryLevel
}
}
@ -115,18 +143,18 @@ function createRowingStatistics () {
powerRatioAverager.reset()
}
// clear the displayed metrics in case the user pauses rowing
// clear the metrics in case the user pauses rowing
function pauseRowing () {
emitter.emit('rowingPaused', getMetrics())
strokeAverager.reset()
powerAverager.reset()
speedAverager.reset()
powerRatioAverager.reset()
lastStrokeState = 'RECOVERY'
}
function startDurationTimer () {
durationTimer = setInterval(() => {
durationTotal++
emitter.emit('durationUpdate', {
durationTotal,
durationTotalFormatted: secondsToTimeString(durationTotal)
})
}, 1000)
}
@ -149,6 +177,7 @@ function createRowingStatistics () {
return Object.assign(emitter, {
handleStroke,
handlePause,
handleHeartrateMeasurement,
handleStrokeStateChanged,
reset: resetTraining
})

View File

@ -1,6 +1,10 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Measures the time between impulses on the GPIO pin. Started in a
separate thread, since we want the measured time to be as close as
possible to real time.
*/
import process from 'process'
import { Gpio } from 'onoff'

View File

@ -69,62 +69,27 @@ const rowingEngine = createRowingEngine()
const rowingStatistics = createRowingStatistics()
rowingEngine.notify(rowingStatistics)
rowingStatistics.on('strokeFinished', (data) => {
log.info(`stroke: ${data.strokesTotal}, dur: ${data.strokeTime}s, power: ${data.power}w` +
`, split: ${data.splitFormatted}, ratio: ${data.powerRatio}, dist: ${data.distanceTotal}m` +
`, cal: ${data.caloriesTotal}kcal, SPM: ${data.strokesPerMinute}, speed: ${data.speed}km/h` +
`, cal/hour: ${data.caloriesPerHour}kcal, cal/minute: ${data.caloriesPerMinute}kcal`)
const metrics = {
durationTotal: data.durationTotal,
durationTotalFormatted: data.durationTotalFormatted,
strokesTotal: data.strokesTotal,
distanceTotal: data.distanceTotal,
caloriesTotal: data.caloriesTotal,
caloriesPerMinute: data.caloriesPerMinute,
caloriesPerHour: data.caloriesPerHour,
power: data.power,
splitFormatted: data.splitFormatted,
split: data.split,
strokesPerMinute: data.strokesPerMinute,
speed: data.speed,
// todo: no real measurement of heartRate yet
heartRate: Math.max(data.caloriesPerMinute * 7, 50),
strokeState: data.strokeState
}
rowingStatistics.on('strokeFinished', (metrics) => {
log.info(`stroke: ${metrics.strokesTotal}, dur: ${metrics.strokeTime}s, power: ${metrics.power}w` +
`, split: ${metrics.splitFormatted}, ratio: ${metrics.powerRatio}, dist: ${metrics.distanceTotal}m` +
`, cal: ${metrics.caloriesTotal}kcal, SPM: ${metrics.strokesPerMinute}, speed: ${metrics.speed}km/h` +
`, cal/hour: ${metrics.caloriesPerHour}kcal, cal/minute: ${metrics.caloriesPerMinute}kcal`)
webServer.notifyClients(metrics)
peripheralManager.notifyMetrics('strokeFinished', metrics)
})
rowingStatistics.on('rowingPaused', (data) => {
const metrics = {
durationTotal: data.durationTotal,
durationTotalFormatted: data.durationTotalFormatted,
strokesTotal: data.strokesTotal,
distanceTotal: data.distanceTotal,
caloriesTotal: data.caloriesTotal,
caloriesPerMinute: 0,
caloriesPerHour: 0,
strokesPerMinute: 0,
power: 0,
splitFormatted: '∞',
split: Infinity,
speed: 0,
// todo: no real measurement of heartRate yet
heartRate: Math.max(data.caloriesPerMinute * 7, 50),
strokeState: 'RECOVERY'
}
webServer.notifyClients(metrics)
peripheralManager.notifyMetrics('rowingPaused', metrics)
})
rowingStatistics.on('strokeStateChanged', (metrics) => {
peripheralManager.notifyMetrics('strokeStateChanged', metrics)
})
rowingStatistics.on('durationUpdate', (data) => {
webServer.notifyClients({
durationTotalFormatted: data.durationTotalFormatted
})
rowingStatistics.on('metricsUpdate', (metrics) => {
webServer.notifyClients(metrics)
peripheralManager.notifyMetrics('metricsUpdate', metrics)
})
const bleCentralService = fork('./app/ble/CentralService.js')
bleCentralService.on('message', (heartrateMeasurement) => {
rowingStatistics.handleHeartrateMeasurement(heartrateMeasurement)
})
const webServer = createWebServer()
@ -132,10 +97,10 @@ webServer.on('messageReceived', (message) => {
if (message.command === 'reset') {
rowingStatistics.reset()
peripheralManager.notifyStatus({ name: 'reset' })
} if (message.command === 'switchPeripheralMode') {
} else if (message.command === 'switchPeripheralMode') {
peripheralManager.switchPeripheralMode()
} else {
log.warn(`invalid command received: ${message}`)
log.warn('invalid command received:', message)
}
})

1350
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -25,24 +25,25 @@
},
"dependencies": {
"@abandonware/bleno": "^0.5.1-3",
"@abandonware/noble": "^1.9.2-13",
"finalhandler": "^1.1.2",
"http": "0.0.1-security",
"loglevel": "^1.7.1",
"nosleep.js": "^0.12.0",
"onoff": "^6.0.1",
"onoff": "^6.0.2",
"serve-static": "^1.14.1",
"ws": "^7.4.4"
},
"devDependencies": {
"eslint": "^7.23.0",
"eslint": "^7.24.0",
"eslint-config-standard": "^16.0.2",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.3.1",
"eslint-plugin-promise": "^5.1.0",
"husky": "^6.0.0",
"markdownlint-cli": "^0.27.1",
"npm-run-all": "^4.1.5",
"snowpack": "^3.1.2",
"snowpack": "^3.3.0",
"uvu": "^0.5.1",
"uvu-watch": "^1.0.11"
}