adds config file and additional metrics

This commit is contained in:
Lars Berning 2021-03-19 19:56:02 +00:00
parent 37c32829d8
commit d90fa9ea1f
24 changed files with 339 additions and 155 deletions

View File

@ -2,3 +2,4 @@
. "$(dirname "$0")/_/husky.sh"
npm run lint
npm test

View File

@ -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.

64
app/WebServer.js Normal file
View File

@ -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 }

View File

@ -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)
})

View File

@ -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
}

View File

@ -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())
}

View File

@ -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())
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}
}
}

View File

@ -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
}

19
app/config.js Normal file
View File

@ -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'
}

View File

@ -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 }

View File

@ -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)
})

View File

@ -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 }

View File

@ -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'
})

View File

@ -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()

View File

@ -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)
}
*/

View File

@ -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