adds config file and additional metrics
This commit is contained in:
parent
37c32829d8
commit
d90fa9ea1f
|
|
@ -2,3 +2,4 @@
|
|||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npm run lint
|
||||
npm test
|
||||
|
|
|
|||
10
README.md
10
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).
|
||||
|
||||
<!-- markdownlint-disable-next-line no-inline-html -->
|
||||
<img src="doc/img/openrowingmonitor_frontend.png" width="700"><br clear="left">
|
||||
|
||||
### 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.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
121
app/server.js
121
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)
|
||||
}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue