From d90fa9ea1fe6dd8f503d40cd2a7b74b954f67789 Mon Sep 17 00:00:00 2001 From: Lars Berning <151194+laberning@users.noreply.github.com> Date: Fri, 19 Mar 2021 19:56:02 +0000 Subject: [PATCH] adds config file and additional metrics --- .husky/pre-commit | 1 + README.md | 10 +- app/WebServer.js | 64 +++++++++ app/ble/FtmsPeripheral.js | 4 +- .../FitnessMachineStatusCharacteristic.js | 4 +- app/ble/ftms/IndoorBikeDataCharacteristic.js | 12 +- app/ble/ftms/RowerDataCharacteristic.js | 14 +- app/ble/pm5/Pm5RowingService.js | 6 +- .../pm5/characteristic/AdditionalStatus.js | 2 +- .../pm5/characteristic/AdditionalStatus2.js | 2 +- .../characteristic/AdditionalStrokeData.js | 2 +- app/ble/pm5/characteristic/ControlTransmit.js | 2 +- app/ble/pm5/characteristic/GeneralStatus.js | 2 +- .../MultiplexedCharacteristic.js | 2 +- app/ble/pm5/characteristic/StrokeData.js | 72 +++++++++++ .../characteristic/ValueReadCharacteristic.js | 2 +- app/config.js | 19 +++ app/engine/MovingIntervalAverager.js | 42 ++++++ app/engine/MovingIntervalAverager.test.js | 48 +++++++ app/engine/MovingTotalizer.js | 33 ----- app/engine/RowingEngine.js | 6 +- app/engine/RowingStatistics.js | 18 ++- app/server.js | 121 ++++++------------ doc/backlog.md | 6 +- 24 files changed, 339 insertions(+), 155 deletions(-) create mode 100644 app/WebServer.js create mode 100644 app/ble/pm5/characteristic/StrokeData.js create mode 100644 app/config.js create mode 100644 app/engine/MovingIntervalAverager.js create mode 100644 app/engine/MovingIntervalAverager.test.js delete mode 100644 app/engine/MovingTotalizer.js diff --git a/.husky/pre-commit b/.husky/pre-commit index 20d0d06..01d4d99 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,3 +2,4 @@ . "$(dirname "$0")/_/husky.sh" npm run lint +npm test diff --git a/README.md b/README.md index 9b747ce..741cded 100644 --- a/README.md +++ b/README.md @@ -27,21 +27,23 @@ Open Rowing Monitor implements a physics model to simulate the typical metrics o ### Web Interface -The web interface visualizes the rowing metrics on any device that can run a browser (i.e. a smartphone that you attach to your rowing machine while training). It uses websockets to show the rowing status in Realtime. Besides that it does not do much (yet). +The web interface visualizes the rowing metrics on any device that can run a browser (i.e. a smartphone that you attach to your rowing machine while training). It uses web sockets to show the rowing status in Realtime. Besides that it does not do much (yet).
-### Bluetooth Low Energy Fitness Machine Service (BLE FTMS) +### Bluetooth Low Energy (BLE) -Open Rowing Monitor also implements the Bluetooth Low Energy (BLE) protocol for Fitness Machine Service (FTMS). This allows using your rowing machine with any Fitness Application that supports FTMS. +Open Rowing Monitor also implements different Bluetooth Low Energy (BLE) protocols so you can use your rowing machine with different fitness applications. -FTMS supports different types of fitness machines. Open Rowing Monitor currently supports the type **FTMS Rower** and simulates the type **FTMS Indoor Bike**. +Fitness Machine Service (FTMS) is a standardized GATT protocol for different types of fitness machines. Open Rowing Monitor currently supports the type **FTMS Rower** and simulates the type **FTMS Indoor Bike**. **FTMS Rower** allows all rower specific metrics (such as stroke rate) to be present, unfortunately not many training applications exist that support this type (the only one I'm aware of is Kinomap but let me know if there are more). **FTMS Indoor Bike** is widely adopted by training applications for bike training. The simulated Indoor Bike offers metrics such as power and distance to the biking application. So why not use your rowing machine to row up a mountain in Zwift, Bkool, Sufferfest or similar :-) +**Concept2 PM** Open Rowing Monitor also implements part of the Concept2 PM Bluetooth Smart Communication Interface Definition. This is still work in progress and may not work with all applications that support C2 rowing machines. + ## How it all started I originally started this project, because my rowing machine (Sportstech WRX700) has a very simple computer and I wanted to build something with a clean and simple interface that calculates more realistic metrics. Also, this was a good reason to learn a bit more about Bluetooth and all its specifics. diff --git a/app/WebServer.js b/app/WebServer.js new file mode 100644 index 0000000..225f8f7 --- /dev/null +++ b/app/WebServer.js @@ -0,0 +1,64 @@ +'use strict' +/* + Open Rowing Monitor, https://github.com/laberning/openrowingmonitor + + Creates the WebServer which serves the static assets and communicates with the clients + via WebSockets +*/ +import WebSocket from 'ws' +import finalhandler from 'finalhandler' +import http from 'http' +import serveStatic from 'serve-static' +import log from 'loglevel' +import EventEmitter from 'events' + +function createWebServer () { + const emitter = new EventEmitter() + const port = process.env.PORT || 80 + const serve = serveStatic('./app/client', { index: ['index.html'] }) + + const server = http.createServer((req, res) => { + serve(req, res, finalhandler(req, res)) + }) + + server.listen(port, (err) => { + if (err) throw err + log.info(`webserver running on port ${port}`) + }) + + const wss = new WebSocket.Server({ server }) + + wss.on('connection', function connection (ws) { + log.debug('websocket client connected') + ws.on('message', function incoming (data) { + try { + const message = JSON.parse(data) + if (message) { + emitter.emit('messageReceived', message) + } else { + log.warn(`invalid message received: ${data}`) + } + } catch (err) { + log.error(err) + } + }) + ws.on('close', function () { + log.debug('websocket client disconnected') + }) + }) + + function notifyClients (message) { + const messageString = JSON.stringify(message) + wss.clients.forEach(function each (client) { + if (client.readyState === WebSocket.OPEN) { + client.send(messageString) + } + }) + } + + return Object.assign(emitter, { + notifyClients + }) +} + +export { createWebServer } diff --git a/app/ble/FtmsPeripheral.js b/app/ble/FtmsPeripheral.js index e792560..434cca2 100644 --- a/app/ble/FtmsPeripheral.js +++ b/app/ble/FtmsPeripheral.js @@ -21,6 +21,7 @@ function createFtmsPeripheral (options) { const emitter = new EventEmitter() const peripheralName = options?.simulateIndoorBike ? 'OpenRowingBike' : 'OpenRowingMonitor' + // const peripheralName = options?.simulateIndoorBike ? 'OpenRowingBike' : 'S1 Comms 1' const fitnessMachineService = new FitnessMachineService(options, controlPointCallback) const deviceInformationService = new DeviceInformationService() @@ -72,9 +73,6 @@ function createFtmsPeripheral (options) { bleno.on('advertisingStop', (event) => { log.debug('advertisingStop', event) }) - bleno.on('servicesSet', () => { - log.debug('servicesSet') - }) bleno.on('servicesSetError', (event) => { log.debug('servicesSetError', event) }) diff --git a/app/ble/ftms/FitnessMachineStatusCharacteristic.js b/app/ble/ftms/FitnessMachineStatusCharacteristic.js index 0512dff..681ce00 100644 --- a/app/ble/ftms/FitnessMachineStatusCharacteristic.js +++ b/app/ble/ftms/FitnessMachineStatusCharacteristic.js @@ -50,7 +50,7 @@ export default class FitnessMachineStatusCharacteristic extends bleno.Characteri } onSubscribe (maxValueSize, updateValueCallback) { - log.debug('FitnessMachineStatusCharacteristic - central subscribed') + log.debug(`FitnessMachineStatusCharacteristic - central subscribed with maxSize: ${maxValueSize}`) this._updateValueCallback = updateValueCallback return this.RESULT_SUCCESS } @@ -82,8 +82,6 @@ export default class FitnessMachineStatusCharacteristic extends bleno.Characteri log.error(`status ${status.name} is not supported`) } this._updateValueCallback(buffer) - } else { - log.debug('can not notify status, no central subscribed') } return this.RESULT_SUCCESS } diff --git a/app/ble/ftms/IndoorBikeDataCharacteristic.js b/app/ble/ftms/IndoorBikeDataCharacteristic.js index 786a6cb..7aaf320 100644 --- a/app/ble/ftms/IndoorBikeDataCharacteristic.js +++ b/app/ble/ftms/IndoorBikeDataCharacteristic.js @@ -34,7 +34,7 @@ export default class IndoorBikeDataCharacteristic extends bleno.Characteristic { } onSubscribe (maxValueSize, updateValueCallback) { - log.debug('IndooBikeDataCharacteristic - central subscribed') + log.debug(`IndooBikeDataCharacteristic - central subscribed with maxSize: ${maxValueSize}`) this._updateValueCallback = updateValueCallback return this.RESULT_SUCCESS } @@ -74,13 +74,11 @@ export default class IndoorBikeDataCharacteristic extends bleno.Characteristic { // Total energy in kcal bufferBuilder.writeUInt16LE(data.caloriesTotal) // Energy per hour - // from specs: if not available the Server shall use the special value 0xFFFF - // which means 'Data Not Available''. - bufferBuilder.writeUInt16LE(0xFFFF) + // The Energy per Hour field represents the average expended energy of a user during a + // period of one hour. + bufferBuilder.writeUInt16LE(data.caloriesPerHour) // Energy per minute - // from specs: if not available the Server shall use the special value 0xFF - // which means 'Data Not Available''. - bufferBuilder.writeUInt16LE(0xFF) + bufferBuilder.writeUInt16LE(data.caloriesPerMinute) this._updateValueCallback(bufferBuilder.getBuffer()) } diff --git a/app/ble/ftms/RowerDataCharacteristic.js b/app/ble/ftms/RowerDataCharacteristic.js index 8c5e7d3..b92e38a 100644 --- a/app/ble/ftms/RowerDataCharacteristic.js +++ b/app/ble/ftms/RowerDataCharacteristic.js @@ -27,7 +27,7 @@ export default class RowerDataCharacteristic extends bleno.Characteristic { } onSubscribe (maxValueSize, updateValueCallback) { - log.debug('RowerDataCharacteristic - central subscribed') + log.debug(`RowerDataCharacteristic - central subscribed with maxSize: ${maxValueSize}`) this._updateValueCallback = updateValueCallback return this.RESULT_SUCCESS } @@ -49,6 +49,8 @@ export default class RowerDataCharacteristic extends bleno.Characteristic { // Field flags as defined in the Bluetooth Documentation // Stroke Rate (default), Stroke Count (default), Total Distance (2), Instantaneous Pace (3), // Instantaneous Power (5), Total / Expended Energy (8) + // todo: might add: Average Stroke Rate (1), Average Pace (4), Average Power (6), Heart Rate (9) + // Elapsed Time (11), Remaining Time (12) // 00101100 bufferBuilder.writeUInt8(0x2c) // 00000001 @@ -70,13 +72,11 @@ export default class RowerDataCharacteristic extends bleno.Characteristic { // Total energy in kcal bufferBuilder.writeUInt16LE(data.caloriesTotal) // Energy per hour - // from specs: if not available the Server shall use the special value 0xFFFF - // which means 'Data Not Available''. - bufferBuilder.writeUInt16LE(0xFFFF) + // The Energy per Hour field represents the average expended energy of a user during a + // period of one hour. + bufferBuilder.writeUInt16LE(data.caloriesPerHour) // Energy per minute - // from specs: if not available the Server shall use the special value 0xFF - // which means 'Data Not Available''. - bufferBuilder.writeUInt16LE(0xFF) + bufferBuilder.writeUInt16LE(data.caloriesPerMinute) this._updateValueCallback(bufferBuilder.getBuffer()) } diff --git a/app/ble/pm5/Pm5RowingService.js b/app/ble/pm5/Pm5RowingService.js index 486b7f8..bda2c86 100644 --- a/app/ble/pm5/Pm5RowingService.js +++ b/app/ble/pm5/Pm5RowingService.js @@ -26,6 +26,7 @@ import GeneralStatus from './characteristic/GeneralStatus.js' import AdditionalStatus from './characteristic/AdditionalStatus.js' import AdditionalStatus2 from './characteristic/AdditionalStatus2.js' import AdditionalStrokeData from './characteristic/AdditionalStrokeData.js' +import StrokeData from './characteristic/StrokeData.js' export default class PM5RowingService extends bleno.PrimaryService { constructor () { @@ -33,6 +34,7 @@ export default class PM5RowingService extends bleno.PrimaryService { const generalStatus = new GeneralStatus(multiplexedCharacteristic) const additionalStatus = new AdditionalStatus(multiplexedCharacteristic) const additionalStatus2 = new AdditionalStatus2(multiplexedCharacteristic) + const strokeData = new StrokeData(multiplexedCharacteristic) const additionalStrokeData = new AdditionalStrokeData(multiplexedCharacteristic) super({ uuid: getFullUUID('0030'), @@ -46,7 +48,7 @@ export default class PM5RowingService extends bleno.PrimaryService { // C2 rowing general status and additional status samplerate new ValueReadCharacteristic(getFullUUID('0034'), 'samplerate', 'samplerate'), // C2 rowing stroke data - new ValueReadCharacteristic(getFullUUID('0035'), 'stroke data', 'stroke data'), + strokeData, // C2 rowing additional stroke data additionalStrokeData, // C2 rowing split/interval data @@ -68,6 +70,7 @@ export default class PM5RowingService extends bleno.PrimaryService { this.generalStatus = generalStatus this.additionalStatus = additionalStatus this.additionalStatus2 = additionalStatus2 + this.strokeData = strokeData this.additionalStrokeData = additionalStrokeData this.multiplexedCharacteristic = multiplexedCharacteristic } @@ -76,6 +79,7 @@ export default class PM5RowingService extends bleno.PrimaryService { this.generalStatus.notify(data) this.additionalStatus.notify(data) this.additionalStatus2.notify(data) + this.strokeData.notify(data) this.additionalStrokeData.notify(data) } } diff --git a/app/ble/pm5/characteristic/AdditionalStatus.js b/app/ble/pm5/characteristic/AdditionalStatus.js index 22f465b..093c5d7 100644 --- a/app/ble/pm5/characteristic/AdditionalStatus.js +++ b/app/ble/pm5/characteristic/AdditionalStatus.js @@ -23,7 +23,7 @@ export default class AdditionalStatus extends bleno.Characteristic { } onSubscribe (maxValueSize, updateValueCallback) { - log.debug('AdditionalStatus - central subscribed') + log.debug(`AdditionalStatus - central subscribed with maxSize: ${maxValueSize}`) this._updateValueCallback = updateValueCallback return this.RESULT_SUCCESS } diff --git a/app/ble/pm5/characteristic/AdditionalStatus2.js b/app/ble/pm5/characteristic/AdditionalStatus2.js index 58e08ad..eb41e93 100644 --- a/app/ble/pm5/characteristic/AdditionalStatus2.js +++ b/app/ble/pm5/characteristic/AdditionalStatus2.js @@ -23,7 +23,7 @@ export default class AdditionalStatus2 extends bleno.Characteristic { } onSubscribe (maxValueSize, updateValueCallback) { - log.debug('AdditionalStatus2 - central subscribed') + log.debug(`AdditionalStatus2 - central subscribed with maxSize: ${maxValueSize}`) this._updateValueCallback = updateValueCallback return this.RESULT_SUCCESS } diff --git a/app/ble/pm5/characteristic/AdditionalStrokeData.js b/app/ble/pm5/characteristic/AdditionalStrokeData.js index 0e25437..3c75397 100644 --- a/app/ble/pm5/characteristic/AdditionalStrokeData.js +++ b/app/ble/pm5/characteristic/AdditionalStrokeData.js @@ -23,7 +23,7 @@ export default class AdditionalStrokeData extends bleno.Characteristic { } onSubscribe (maxValueSize, updateValueCallback) { - log.debug('AdditionalStrokeData - central subscribed') + log.debug(`AdditionalStrokeData - central subscribed with maxSize: ${maxValueSize}`) this._updateValueCallback = updateValueCallback return this.RESULT_SUCCESS } diff --git a/app/ble/pm5/characteristic/ControlTransmit.js b/app/ble/pm5/characteristic/ControlTransmit.js index 8fc6bcc..644ec7a 100644 --- a/app/ble/pm5/characteristic/ControlTransmit.js +++ b/app/ble/pm5/characteristic/ControlTransmit.js @@ -23,7 +23,7 @@ export default class ControlTransmit extends bleno.Characteristic { } onSubscribe (maxValueSize, updateValueCallback) { - log.debug('ControlTransmit - central subscribed') + log.debug(`ControlTransmit - central subscribed with maxSize: ${maxValueSize}`) this._updateValueCallback = updateValueCallback return this.RESULT_SUCCESS } diff --git a/app/ble/pm5/characteristic/GeneralStatus.js b/app/ble/pm5/characteristic/GeneralStatus.js index 452be3a..246b26e 100644 --- a/app/ble/pm5/characteristic/GeneralStatus.js +++ b/app/ble/pm5/characteristic/GeneralStatus.js @@ -23,7 +23,7 @@ export default class GeneralStatus extends bleno.Characteristic { } onSubscribe (maxValueSize, updateValueCallback) { - log.debug('GeneralStatus - central subscribed') + log.debug(`GeneralStatus - central subscribed with maxSize: ${maxValueSize}`) this._updateValueCallback = updateValueCallback return this.RESULT_SUCCESS } diff --git a/app/ble/pm5/characteristic/MultiplexedCharacteristic.js b/app/ble/pm5/characteristic/MultiplexedCharacteristic.js index 975edb7..7f1ee4e 100644 --- a/app/ble/pm5/characteristic/MultiplexedCharacteristic.js +++ b/app/ble/pm5/characteristic/MultiplexedCharacteristic.js @@ -25,7 +25,7 @@ export default class MultiplexedCharacteristic extends bleno.Characteristic { } onSubscribe (maxValueSize, updateValueCallback) { - log.debug('MultiplexedCharacteristic - central subscribed') + log.debug(`MultiplexedCharacteristic - central subscribed with maxSize: ${maxValueSize}`) this._updateValueCallback = updateValueCallback return this.RESULT_SUCCESS } diff --git a/app/ble/pm5/characteristic/StrokeData.js b/app/ble/pm5/characteristic/StrokeData.js new file mode 100644 index 0000000..1e363a1 --- /dev/null +++ b/app/ble/pm5/characteristic/StrokeData.js @@ -0,0 +1,72 @@ +'use strict' +/* + Open Rowing Monitor, https://github.com/laberning/openrowingmonitor + + Implementation of the StrokeData as defined in: + https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf + todo: we could calculate all the missing stroke metrics in the RowerEngine +*/ +import bleno from '@abandonware/bleno' +import { getFullUUID } from '../Pm5Constants.js' +import log from 'loglevel' +import BufferBuilder from '../../BufferBuilder.js' + +export default class StrokeData extends bleno.Characteristic { + constructor (multiplexedCharacteristic) { + super({ + // id for StrokeData as defined in the spec + uuid: getFullUUID('0035'), + value: null, + properties: ['notify'] + }) + this._updateValueCallback = null + this._multiplexedCharacteristic = multiplexedCharacteristic + } + + onSubscribe (maxValueSize, updateValueCallback) { + log.debug(`StrokeData - central subscribed with maxSize: ${maxValueSize}`) + this._updateValueCallback = updateValueCallback + return this.RESULT_SUCCESS + } + + onUnsubscribe () { + log.debug('StrokeData - central unsubscribed') + this._updateValueCallback = null + return this.RESULT_UNLIKELY_ERROR + } + + notify (data) { + if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) { + const bufferBuilder = new BufferBuilder() + // elapsedTime: UInt24LE in 0.01 sec + bufferBuilder.writeUInt24LE(data.durationTotal * 100) + // distance: UInt24LE in 0.1 m + bufferBuilder.writeUInt24LE(data.distanceTotal * 10) + // driveLength: UInt8 in 0.01 m + bufferBuilder.writeUInt8(0 * 100) + // driveTime: UInt8 in 0.01 s + bufferBuilder.writeUInt8(0 * 100) + // strokeRecoveryTime: UInt16LE in 0.01 s + bufferBuilder.writeUInt16LE(0 * 100) + // strokeDistance: UInt16LE in 0.01 s + bufferBuilder.writeUInt16LE(0 * 100) + // peakDriveForce: UInt16LE in 0.1 watts + bufferBuilder.writeUInt16LE(0 * 10) + // averageDriveForce: UInt16LE in 0.1 watts + bufferBuilder.writeUInt16LE(0 * 10) + if (this._updateValueCallback) { + // workPerStroke is only added if data is not send via multiplexer + // workPerStroke: UInt16LE + bufferBuilder.writeUInt16LE(0) + } + // strokeCount: UInt16LE + bufferBuilder.writeUInt16LE(data.strokesTotal) + if (this._updateValueCallback) { + this._updateValueCallback(bufferBuilder.getBuffer()) + } else { + this._multiplexedCharacteristic.notify(0x35, bufferBuilder.getBuffer()) + } + return this.RESULT_SUCCESS + } + } +} diff --git a/app/ble/pm5/characteristic/ValueReadCharacteristic.js b/app/ble/pm5/characteristic/ValueReadCharacteristic.js index a02af38..1d3c2a9 100644 --- a/app/ble/pm5/characteristic/ValueReadCharacteristic.js +++ b/app/ble/pm5/characteristic/ValueReadCharacteristic.js @@ -27,7 +27,7 @@ export default class ValueReadCharacteristic extends bleno.Characteristic { } onSubscribe (maxValueSize, updateValueCallback) { - log.debug(`characteristic ${this._description ? this._description : this.uuid} - central subscribed`) + log.debug(`characteristic ${this._description ? this._description : this.uuid} - central subscribed with maxSize: ${maxValueSize}`) this._updateValueCallback = updateValueCallback return this.RESULT_SUCCESS } diff --git a/app/config.js b/app/config.js new file mode 100644 index 0000000..c5ca020 --- /dev/null +++ b/app/config.js @@ -0,0 +1,19 @@ +'use strict' +/* + Open Rowing Monitor, https://github.com/laberning/openrowingmonitor + + This file contains the app specific configuration. + Modify it to your needs. +*/ +import log from 'loglevel' +export default { + loglevel: { + // the default loglevel + default: log.levels.INFO, + // the log level of some modules can be set individually to filter noise + RowingEngine: log.levels.WARN + }, + // selects the Bluetooth Low Energy Profile + // supported modes: FTMS, FTMSBIKE, PM5 + bluetoothMode: 'FTMS' +} diff --git a/app/engine/MovingIntervalAverager.js b/app/engine/MovingIntervalAverager.js new file mode 100644 index 0000000..dce28c5 --- /dev/null +++ b/app/engine/MovingIntervalAverager.js @@ -0,0 +1,42 @@ +'use strict' +/* + Open Rowing Monitor, https://github.com/laberning/openrowingmonitor + + This averager calculates the average forcast for a moving inteval of a continuous flow + of data points for a certain (time) interval +*/ +function createMovingIntervalAverager (movingDuration) { + let dataPoints = [] + let duration = 0.0 + let sum = 0.0 + + function pushValue (dataValue, dataDuration) { + // add the new dataPoint to the front of the array + dataPoints.unshift({ value: dataValue, duration: dataDuration }) + duration += dataDuration + sum += dataValue + while (duration > movingDuration) { + const removedDataPoint = dataPoints.pop() + duration -= removedDataPoint.duration + sum -= removedDataPoint.value + } + } + + function average () { + return sum / duration * movingDuration + } + + function reset () { + dataPoints = [] + duration = 0.0 + sum = 0.0 + } + + return { + pushValue, + average, + reset + } +} + +export { createMovingIntervalAverager } diff --git a/app/engine/MovingIntervalAverager.test.js b/app/engine/MovingIntervalAverager.test.js new file mode 100644 index 0000000..e71472f --- /dev/null +++ b/app/engine/MovingIntervalAverager.test.js @@ -0,0 +1,48 @@ +'use strict' +/* + Open Rowing Monitor, https://github.com/laberning/openrowingmonitor +*/ +import test from 'ava' +import { createMovingIntervalAverager } from './MovingIntervalAverager.js' + +test('average of a datapoint with duration of averager is equal to datapoint', t => { + const minuteAverager = createMovingIntervalAverager(10) + minuteAverager.pushValue(5, 10) + t.is(minuteAverager.average(), 5) +}) + +test('average of a datapoint with half duration of averager is double to datapoint', t => { + const minuteAverager = createMovingIntervalAverager(20) + minuteAverager.pushValue(5, 10) + t.is(minuteAverager.average(), 10) +}) + +test('average of two identical datapoints with half duration of averager is equal to datapoint sum', t => { + const minuteAverager = createMovingIntervalAverager(20) + minuteAverager.pushValue(5, 10) + minuteAverager.pushValue(5, 10) + t.is(minuteAverager.average(), 10) +}) + +test('average does not consider datapoints that are outside of duration', t => { + const minuteAverager = createMovingIntervalAverager(20) + minuteAverager.pushValue(10, 10) + minuteAverager.pushValue(5, 10) + minuteAverager.pushValue(5, 10) + t.is(minuteAverager.average(), 10) +}) + +test('average works with lots of values', t => { + // one hour + const minuteAverager = createMovingIntervalAverager(3000) + for (let i = 0; i < 1000; i++) { + minuteAverager.pushValue(10, 1) + } + for (let i = 0; i < 1000; i++) { + minuteAverager.pushValue(20, 1) + } + for (let i = 0; i < 1000; i++) { + minuteAverager.pushValue(30, 2) + } + t.is(minuteAverager.average(), 50000) +}) diff --git a/app/engine/MovingTotalizer.js b/app/engine/MovingTotalizer.js deleted file mode 100644 index 404f74f..0000000 --- a/app/engine/MovingTotalizer.js +++ /dev/null @@ -1,33 +0,0 @@ -'use strict' -/* - Open Rowing Monitor, https://github.com/laberning/openrowingmonitor - - This totalizer calculates the moving total of a continuous flow of data points for a - certain (time) interval - - todo: not implemented yet, could be used to calculate Energy per hour and Energy per - minute (for FTMS protocol) -*/ -function createMovingTotalizer (movingTimeInterval) { - let recordedTimeInterval = 0.0 - - function pushValue (dataValue, timeInterval) { - recordedTimeInterval += timeInterval - } - - function total () { - return recordedTimeInterval - } - - function reset () { - recordedTimeInterval = 0.0 - } - - return { - pushValue, - total, - reset - } -} - -export { createMovingTotalizer } diff --git a/app/engine/RowingEngine.js b/app/engine/RowingEngine.js index b304135..379b45f 100644 --- a/app/engine/RowingEngine.js +++ b/app/engine/RowingEngine.js @@ -29,13 +29,13 @@ const numOfImpulsesPerRevolution = 2 // However I still keep it constant here, as I still have to figure out the damping physics of a water rower (see below) // To measure it for your rowing machine, comment in the logging at the end of "startDrivePhase" function. Then do some // strokes on the rower and estimate a value. -const omegaDotDivOmegaSquare = 0.056 +const omegaDotDivOmegaSquare = 0.046 // The moment of inertia of the flywheel kg*m^2 // A way to measure it is outlined here: https://dvernooy.github.io/projects/ergware/, "Flywheel moment of inertia" // You could also roughly estimate it by just doing some strokes and the comparing the calculated power values for // plausibility. Note that the power also depends on omegaDotDivOmegaSquare (see above). -const jMoment = 0.55 +const jMoment = 0.49 // Set this to true if you are using a water rower // The mass of the water starts rotating, when you pull the handle, and therefore acts @@ -150,7 +150,7 @@ function createRowingEngine () { kDampEstimatorAverager.pushValue(kDampEstimator / (strokeElapsed - driveElapsed)) } log.debug(`estimated kDamp: ${jMoment * (-1 * kDampEstimatorAverager.weightedAverage())}`) - log.debug(`estimated omegaDotDivOmegaSquare: ${-1 * kDampEstimatorAverager.weightedAverage()}`) + log.info(`estimated omegaDotDivOmegaSquare: ${-1 * kDampEstimatorAverager.weightedAverage()}`) workoutHandler.handleStrokeStateChange({ strokeState: 'DRIVING' }) diff --git a/app/engine/RowingStatistics.js b/app/engine/RowingStatistics.js index 444950d..de5c8ab 100644 --- a/app/engine/RowingStatistics.js +++ b/app/engine/RowingStatistics.js @@ -5,6 +5,7 @@ This Module calculates the training specific metrics. */ import { EventEmitter } from 'events' +import { createMovingIntervalAverager } from './MovingIntervalAverager.js' import { createWeightedAverager } from './WeightedAverager.js' // The number of strokes that are considered when averaging the calculated metrics @@ -17,6 +18,8 @@ function createRowingStatistics () { const powerAverager = createWeightedAverager(numOfDataPointsForAveraging) const speedAverager = createWeightedAverager(numOfDataPointsForAveraging) const powerRatioAverager = createWeightedAverager(numOfDataPointsForAveraging) + const caloriesAveragerMinute = createMovingIntervalAverager(60) + const caloriesAveragerHour = createMovingIntervalAverager(60 * 60) let trainingRunning = false let durationTimer let rowingPausedTimer @@ -34,14 +37,17 @@ function createRowingStatistics () { if (rowingPausedTimer)clearInterval(rowingPausedTimer) rowingPausedTimer = setTimeout(() => pauseRowing(), 6000) + // based on: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics/ergometer.html#section11 + const calories = (4 * powerAverager.weightedAverage() + 350) * (stroke.duration) / 4200 powerAverager.pushValue(stroke.power) speedAverager.pushValue(stroke.distance / stroke.duration) powerRatioAverager.pushValue(stroke.durationDrivePhase / stroke.duration) strokeAverager.pushValue(stroke.duration) + caloriesAveragerMinute.pushValue(calories, stroke.duration) + caloriesAveragerHour.pushValue(calories, stroke.duration) + caloriesTotal += calories distanceTotal += stroke.distance strokesTotal++ - // based on: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics/ergometer.html#section11 - caloriesTotal += (4 * powerAverager.weightedAverage() + 350) * (stroke.duration) / 4200 lastStrokeDuration = stroke.duration lastStrokeState = stroke.strokeState @@ -51,6 +57,8 @@ function createRowingStatistics () { // initiated by the rowing engine in case an impulse was not considered // because it was too large function handlePause (duration) { + caloriesAveragerMinute.pushValue(0, duration) + caloriesAveragerHour.pushValue(0, duration) } // initiated when the stroke state changes @@ -69,12 +77,14 @@ function createRowingStatistics () { strokesTotal, distanceTotal: Math.round(distanceTotal), // meters caloriesTotal: Math.round(caloriesTotal), // kcal + caloriesPerMinute: Math.round(caloriesAveragerMinute.average()), + caloriesPerHour: Math.round(caloriesAveragerHour.average()), strokeTime: lastStrokeDuration.toFixed(2), // seconds power: Math.round(powerAverager.weightedAverage()), // watts split: splitTime, // seconds/500m splitFormatted: secondsToTimeString(splitTime), powerRatio: powerRatioAverager.weightedAverage().toFixed(2), - strokesPerMinute: strokeAverager.weightedAverage() !== 0 ? Math.round(60.0 / strokeAverager.weightedAverage()) : 0, + strokesPerMinute: strokeAverager.weightedAverage() !== 0 ? (60.0 / strokeAverager.weightedAverage()).toFixed(1) : 0, speed: (speedAverager.weightedAverage() * 3.6).toFixed(2), // km/h strokeState: lastStrokeState } @@ -97,6 +107,8 @@ function createRowingStatistics () { strokesTotal = 0 caloriesTotal = 0.0 durationTotal = 0 + caloriesAveragerMinute.reset() + caloriesAveragerHour.reset() strokeAverager.reset() powerAverager.reset() speedAverager.reset() diff --git a/app/server.js b/app/server.js index 2ac706c..6656c57 100644 --- a/app/server.js +++ b/app/server.js @@ -7,30 +7,41 @@ todo: refactor this as we progress */ import { fork } from 'child_process' -import WebSocket from 'ws' -import finalhandler from 'finalhandler' -import http from 'http' -import serveStatic from 'serve-static' import log from 'loglevel' +import config from './config.js' import { createFtmsPeripheral } from './ble/FtmsPeripheral.js' -// eslint-disable-next-line no-unused-vars import { createPm5Peripheral } from './ble/Pm5Peripheral.js' import { createRowingEngine } from './engine/RowingEngine.js' import { createRowingStatistics } from './engine/RowingStatistics.js' +import { createWebServer } from './WebServer.js' // eslint-disable-next-line no-unused-vars import { recordRowingSession, replayRowingSession } from './tools/RowingRecorder.js' -// sets the global log level -log.setLevel(log.levels.INFO) -// some modules can be set individually to filter noise -log.getLogger('RowingEngine').setLevel(log.levels.INFO) +// set the log levels +log.setLevel(config.loglevel.default) +for (const [loggerName, logLevel] of Object.entries(config.loglevel)) { + if (loggerName !== 'default') { + log.getLogger(loggerName).setLevel(logLevel) + } +} -const peripheral = createFtmsPeripheral({ - simulateIndoorBike: false -}) +log.info(`==== Open Rowing Monitor ${process.env.npm_package_version} ====\n`) -// the simulation of a C2 PM5 is not finished yet -// const peripheral = createPm5Peripheral() +let peripheral +if (config.bluetoothMode === 'PM5') { + log.info('bluetooth profile: Concept2 PM5') + peripheral = createPm5Peripheral() +} else if (config.bluetoothMode === 'FTMSBIKE') { + log.info('bluetooth profile: FTMS Indoor Bike') + peripheral = createFtmsPeripheral({ + simulateIndoorBike: true + }) +} else { + log.info('bluetooth profile: FTMS Rower') + peripheral = createFtmsPeripheral({ + simulateIndoorBike: false + }) +} peripheral.on('controlPoint', (event) => { if (event?.req?.name === 'requestControl') { @@ -70,14 +81,16 @@ 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: ${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, @@ -85,7 +98,7 @@ rowingStatistics.on('strokeFinished', (data) => { speed: data.speed, strokeState: data.strokeState } - notifyWebClients(metrics) + webServer.notifyClients(metrics) peripheral.notifyData(metrics) }) @@ -96,6 +109,8 @@ rowingStatistics.on('rowingPaused', (data) => { strokesTotal: data.strokesTotal, distanceTotal: data.distanceTotal, caloriesTotal: data.caloriesTotal, + caloriesPerMinute: 0, + caloriesPerHour: 0, strokesPerMinute: 0, power: 0, // todo: setting split to 0 might be dangerous, depending on what the client does with this @@ -104,56 +119,26 @@ rowingStatistics.on('rowingPaused', (data) => { speed: 0, strokeState: 'RECOVERY' } - notifyWebClients(metrics) + webServer.notifyClients(metrics) peripheral.notifyData(metrics) }) rowingStatistics.on('durationUpdate', (data) => { - notifyWebClients({ + webServer.notifyClients({ durationTotalFormatted: data.durationTotalFormatted }) }) -const port = process.env.PORT || 80 -const serve = serveStatic('./app/client', { index: ['index.html'] }) - -const server = http.createServer((req, res) => { - serve(req, res, finalhandler(req, res)) +const webServer = createWebServer() +webServer.on('messageReceived', (message) => { + if (message.command === 'reset') { + rowingStatistics.reset() + peripheral.notifyStatus({ name: 'reset' }) + } else { + log.warn(`invalid command received: ${message}`) + } }) -server.listen(port) - -const wss = new WebSocket.Server({ server }) - -wss.on('connection', function connection (ws) { - log.debug('websocket client connected') - ws.on('message', function incoming (data) { - try { - const message = JSON.parse(data) - if (message && message.command === 'reset') { - rowingStatistics.reset() - peripheral.notifyStatus({ name: 'reset' }) - } else { - log.info(`invalid command received: ${data}`) - } - } catch (err) { - log.error(err) - } - }) - ws.on('close', function () { - log.debug('websocket client disconnected') - }) -}) - -function notifyWebClients (message) { - const messageString = JSON.stringify(message) - wss.clients.forEach(function each (client) { - if (client.readyState === WebSocket.OPEN) { - client.send(messageString) - } - }) -} - // recordRowingSession('recordings/wrx700_2magnets.csv') /* replayRowingSession(rowingEngine.handleRotationImpulse, { @@ -162,25 +147,3 @@ replayRowingSession(rowingEngine.handleRotationImpulse, { loop: true }) */ - -// for temporary simulation of usage -/* -setInterval(simulateRowing, 2000) -let simStroke = 0 -let simDistance = 0.0 -let simCalories = 0.0 -function simulateRowing () { - const metrics = { - strokesTotal: simStroke++, - distanceTotal: Math.round(simDistance += 10.1), - caloriesTotal: Math.round(simCalories += 0.3), - power: Math.round(80 + 20 * (Math.random() - 0.5)), - splitFormatted: '02:30', - split: Math.round(80 + 20 * (Math.random() - 0.5)), - strokesPerMinute: Math.round(10 + 20 * (Math.random() - 0.5)), - speed: Math.round((15 + 20 * (Math.random() - 0.5)).toFixed(2)) - } - peripheral.notifyData(metrics) - notifyWebClients(metrics) -} -*/ diff --git a/doc/backlog.md b/doc/backlog.md index 5cae167..57e68dc 100644 --- a/doc/backlog.md +++ b/doc/backlog.md @@ -5,20 +5,16 @@ This is the very minimalistic Backlog for further development of this project. ## Soon * refactor Stroke Phase Handling in RowingStatistics and pm5Peripheral -* calculate the missing averages for FTMS and PM5 -* cleanup of the server.js start file +* calculate the missing energy averages for FTMS * figure out where to set the Service Advertising Data (FTMS.pdf p 15) * Web UI: replace fullscreen button with exit Button when started from home screen * investigate bug: crash, when one unsubscribe to BLE "Generic Attribute", probably a bleno bug "handleAttribute.emit is not a function" -* add photo of wired device to installation instructions * what value should we use for split, if we are in a rowing pause? technically should be infinity... * set up a Raspberry Pi with the installation instructions to see if they are correct ## Later -* implement the proprietary Concept2 PM BLE protocol as described here [Concept2 PM Bluetooth Smart Communication Interface](https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf) * add some attributes to BLE DeviceInformationService -* add a config file * presets for rowing machine specific config parameters * improve the physics model for waterrowers * validate FTMS with more training applications and harden implementation