adds support for BLE heart rate monitors
This commit is contained in:
parent
870fc53fc8
commit
43fa45bf29
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = '--'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue