adds logging framework, cleans documentation

This commit is contained in:
Lars Berning 2021-03-09 19:39:42 +00:00
parent 1b0cc5d9da
commit 361e1c65f8
16 changed files with 86 additions and 69 deletions

View File

@ -1,14 +1,15 @@
<img width="150" height="150" src="doc/img/openrowingmonitor.png">
# Open Rowing Monitor
<!-- markdownlint-disable-next-line no-inline-html -->
<img width="200" height="200" align="left" src="doc/img/openrowingmonitor.png">
An open source rowing monitor for rowing exercise machines.
The Open Rowing Monitor runs on a Raspberry Pi and measures the rotation of the rower's flywheel to calculate rowing specific metrics such as power, split time, speed, stroke rate, distance and calories.
Open Rowing Monitor runs on a Raspberry Pi and measures the rotation of the rower's flywheel to calculate rowing specific metrics such as power, split time, speed, stroke rate, distance and calories.
The web interface can be used to view those metrics on any device that runs a browser (i.e. a smartphone that you attach to your rowing machine while training).
A web interface visualizes those metrics on any device that can run a browser (i.e. a smartphone that you attach to your rowing machine while training).
The Open Rowing Monitor also implements the Bluetooth Low Energy (BLE) protocol for Fitness Machine Service (FTMS). This allows using your rowing machine with Fitness Applications that support FTMS.
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.
FTMS supports different types of fitness machines. Open Rowing Monitor currently supports the type **FTMS Rower** and simulates the type **FTMS Indoor Bike**.
@ -16,9 +17,9 @@ FTMS supports different types of fitness machines. Open Rowing Monitor currently
**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 :-)
I basically started this project, because my rowing machine (WRX700) has a very crappy computer and I wanted to build something with more realistic metrics and more features. But there is not much that is specific to that rowing machine. It should run fine with any rowing machine that uses an air or water resistance mechanism.
I originally started this project, because my rowing machine (WRX700) has a very simple computer and I wanted to build something with a clean interface that calculates more realistic metrics. But there is not much that is specific to that rowing machine. It should run fine with any rowing machine that uses an air or water resistance mechanism.
Feel free to contact me if you have any questions to this project. Let me know if you run this with a different rowing machine setup so I can expand the documentation.
Feel free to contact me if you have any questions about this project. Let me know if you run Open Rowing Machine with a different setup so I can expand the documentation.
This project is already in a very usable stage, but some things are still a bit rough on the edges.

View File

@ -9,6 +9,7 @@
- Zwift: uses startOrResume and setIndoorBikeSimulationParameters
*/
import bleno from '@abandonware/bleno'
import log from 'loglevel'
// see https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0 for details
const ControlPointOpCode = {
@ -67,7 +68,7 @@ export default class FitnessMachineControlPointCharacteristic extends bleno.Char
case ControlPointOpCode.requestControl:
if (!this.controlled) {
if (this.controlPointCallback({ name: 'requestControl' })) {
console.log('requestControl sucessful')
log.debug('requestControl sucessful')
this.controlled = true
callback(this.buildResponse(opCode, ResultCode.success))
} else {
@ -95,7 +96,7 @@ export default class FitnessMachineControlPointCharacteristic extends bleno.Char
} else if (controlParameter === 2) {
this.handleSimpleCommand(ControlPointOpCode.stopOrPause, 'pause', callback)
} else {
console.log(`stopOrPause with invalid controlParameter: ${controlParameter}`)
log.error(`stopOrPause with invalid controlParameter: ${controlParameter}`)
}
break
}
@ -116,7 +117,7 @@ export default class FitnessMachineControlPointCharacteristic extends bleno.Char
}
default:
console.log(`opCode ${opCode} is not supported`)
log.info(`opCode ${opCode} is not supported`)
callback(this.buildResponse(opCode, ResultCode.opCodeNotSupported))
}
}
@ -130,7 +131,7 @@ export default class FitnessMachineControlPointCharacteristic extends bleno.Char
callback(this.buildResponse(opCode, ResultCode.operationFailed))
}
} else {
console.log(`initating command '${opName}' requires 'requestControl'`)
log.info(`initating command '${opName}' requires 'requestControl'`)
callback(this.buildResponse(opCode, ResultCode.controlNotPermitted))
}
}

View File

@ -10,6 +10,7 @@
shall be exposed by the Server. Otherwise, supporting the Fitness Machine Status characteristic is optional.
*/
import bleno from '@abandonware/bleno'
import log from 'loglevel'
// see page 67 https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0
const StatusOpCode = {
@ -49,20 +50,20 @@ export default class FitnessMachineStatusCharacteristic extends bleno.Characteri
}
onSubscribe (maxValueSize, updateValueCallback) {
console.log('FitnessMachineStatusCharacteristic - central subscribed')
log.debug('FitnessMachineStatusCharacteristic - central subscribed')
this._updateValueCallback = updateValueCallback
return this.RESULT_SUCCESS
}
onUnsubscribe () {
console.log('FitnessMachineStatusCharacteristic - central unsubscribed')
log.debug('FitnessMachineStatusCharacteristic - central unsubscribed')
this._updateValueCallback = null
return this.RESULT_UNLIKELY_ERROR
}
notify (status) {
if (!(status && status.name)) {
console.log('can not deliver status without name')
log.error('can not deliver status without name')
return this.RESULT_SUCCESS
}
if (this._updateValueCallback) {
@ -78,11 +79,11 @@ export default class FitnessMachineStatusCharacteristic extends bleno.Characteri
buffer.writeUInt8(StatusOpCode.startedOrResumedByUser, 0)
break
default:
console.log(`status ${status.name} is not supported`)
log.error(`status ${status.name} is not supported`)
}
this._updateValueCallback(buffer)
} else {
// console.log('can not notify status, no central subscribed')
log.debug('can not notify status, no central subscribed')
}
return this.RESULT_SUCCESS
}

View File

@ -19,6 +19,7 @@
while in a connection and the interval is not configurable by the Client
*/
import bleno from '@abandonware/bleno'
import log from 'loglevel'
export default class IndoorBikeDataCharacteristic extends bleno.Characteristic {
constructor () {
@ -32,13 +33,13 @@ export default class IndoorBikeDataCharacteristic extends bleno.Characteristic {
}
onSubscribe (maxValueSize, updateValueCallback) {
console.log('IndooBikeDataCharacteristic - central subscribed')
log.debug('IndooBikeDataCharacteristic - central subscribed')
this._updateValueCallback = updateValueCallback
return this.RESULT_SUCCESS
};
onUnsubscribe () {
console.log('IndooBikeDataCharacteristic - central unsubscribed')
log.debug('IndooBikeDataCharacteristic - central unsubscribed')
this._updateValueCallback = null
return this.RESULT_UNLIKELY_ERROR
};
@ -46,7 +47,7 @@ export default class IndoorBikeDataCharacteristic extends bleno.Characteristic {
notify (data) {
// ignore events without the mandatory fields
if (!data.speed) {
console.log('can not deliver bike data without mandatory fields')
log.error('can not deliver bike data without mandatory fields')
return this.RESULT_SUCCESS
}
@ -87,7 +88,7 @@ export default class IndoorBikeDataCharacteristic extends bleno.Characteristic {
}
this._updateValueCallback(buffer)
} else {
// console.log('can not notify indoor bike data, no central subscribed')
log.debug('can not notify indoor bike data, no central subscribed')
}
return this.RESULT_SUCCESS
}

View File

@ -8,6 +8,7 @@
are supported in IndoorBikeDataCharacteristic and FitnessMachineControlPointCharacteristic.
*/
import bleno from '@abandonware/bleno'
import log from 'loglevel'
export default class IndoorBikeDataCharacteristic extends bleno.Characteristic {
constructor (uuid, description, value) {
@ -29,7 +30,7 @@ export default class IndoorBikeDataCharacteristic extends bleno.Characteristic {
// none
// 0000000 0000000
const features = [0x04, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
console.log('Features of Indoor Bike requested')
log.debug('Features of Indoor Bike requested')
callback(this.RESULT_SUCCESS, features.slice(offset, features.length))
}
}

View File

@ -12,6 +12,7 @@
while in a connection and the interval is not configurable by the Client
*/
import bleno from '@abandonware/bleno'
import log from 'loglevel'
export default class RowerDataCharacteristic extends bleno.Characteristic {
constructor () {
@ -25,13 +26,13 @@ export default class RowerDataCharacteristic extends bleno.Characteristic {
}
onSubscribe (maxValueSize, updateValueCallback) {
console.log('RowerDataCharacteristic - central subscribed')
log.debug('RowerDataCharacteristic - central subscribed')
this._updateValueCallback = updateValueCallback
return this.RESULT_SUCCESS
};
onUnsubscribe () {
console.log('RowerDataCharacteristic - central unsubscribed')
log.debug('RowerDataCharacteristic - central unsubscribed')
this._updateValueCallback = null
return this.RESULT_UNLIKELY_ERROR
};
@ -85,7 +86,7 @@ export default class RowerDataCharacteristic extends bleno.Characteristic {
}
this._updateValueCallback(buffer)
} else {
// console.log('can not notify rower data, no central subscribed')
log.debug('can not notify rower data, no central subscribed')
}
return this.RESULT_SUCCESS
}

View File

@ -8,6 +8,7 @@
are supported in RowerDataCharacteristic and FitnessMachineControlPointCharacteristic.
*/
import bleno from '@abandonware/bleno'
import log from 'loglevel'
export default class RowerFeatureCharacteristic extends bleno.Characteristic {
constructor () {
@ -29,7 +30,7 @@ export default class RowerFeatureCharacteristic extends bleno.Characteristic {
// none
// 0000000 0000000
const features = [0x24, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
console.log('Features of Rower requested')
log.debug('Features of Rower requested')
callback(this.RESULT_SUCCESS, features.slice(offset, features.length))
};
}

View File

@ -15,6 +15,7 @@ import bleno from '@abandonware/bleno'
import { EventEmitter } from 'events'
import FitnessMachineService from './FitnessMachineService.js'
import DeviceInformationService from './DeviceInformationService.js'
import log from 'loglevel'
function createRowingMachinePeripheral (options) {
const emitter = new EventEmitter()
@ -29,7 +30,7 @@ function createRowingMachinePeripheral (options) {
peripheralName,
[fitnessMachineService.uuid, deviceInformationService.uuid],
(error) => {
if (error) console.log(error)
if (error) log.error(error)
}
)
} else {
@ -42,46 +43,45 @@ function createRowingMachinePeripheral (options) {
bleno.setServices(
[fitnessMachineService, deviceInformationService],
(error) => {
if (error) console.log(error)
if (error) log.error(error)
})
}
})
bleno.on('accept', (clientAddress) => {
console.log(`ble central connected: ${clientAddress}`)
log.debug(`ble central connected: ${clientAddress}`)
// todo: do we need this?
bleno.updateRssi()
})
bleno.on('disconnect', (clientAddress) => {
console.log(`ble central disconnected: ${clientAddress}`)
log.debug(`ble central disconnected: ${clientAddress}`)
})
/*
bleno.on('platform', (event) => {
console.log('platform', event)
log.debug('platform', event)
})
bleno.on('addressChange', (event) => {
console.log('addressChange', event)
log.debug('addressChange', event)
})
bleno.on('mtuChange', (event) => {
console.log('mtuChange', event)
log.debug('mtuChange', event)
})
bleno.on('advertisingStartError', (event) => {
console.log('advertisingStartError', event)
log.debug('advertisingStartError', event)
})
bleno.on('advertisingStop', (event) => {
console.log('advertisingStop', event)
log.debug('advertisingStop', event)
})
bleno.on('servicesSet', (event) => {
console.log('servicesSet', event)
log.debug('servicesSet', event)
})
bleno.on('servicesSetError', (event) => {
console.log('servicesSetError', event)
log.debug('servicesSetError', event)
})
bleno.on('rssiUpdate', (event) => {
console.log('rssiUpdate', event)
log.debug('rssiUpdate', event)
})
*/
function controlPointCallback (event) {
const obj = {

View File

@ -48,12 +48,12 @@ span.unit {
button {
outline:none;
background-color: #0059B3;
background-color: #00468c;
border: 0;
color: white;
padding: 1vw 2vw;
padding: 1.5vw 2vw;
margin: 1vw;
font-size: 60%;
font-size: 80%;
text-align: center;
text-decoration: none;
display: inline-block;

View File

@ -10,6 +10,7 @@
Physics of Rowing by Anu Dudhia: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics
Also Dave Vernooy has some good explanations here: https://dvernooy.github.io/projects/ergware
*/
import log from 'loglevel'
import { createAverager } from './Averager.js'
import { createTimer } from './Timer.js'
@ -84,7 +85,7 @@ function createRowingEngine () {
// todo: we should inform the workoutHandler in this case
// (if we want to track the training history)
if (currentDt > 3.0) {
console.log(`training pause detected: ${currentDt}`)
log.debug(`training pause detected: ${currentDt}`)
return
}
@ -136,25 +137,25 @@ function createRowingEngine () {
if (!isInDrivePhase && !wasInDrivePhase) { updateRecoveryPhase(currentDt) }
timer.updateTimers(currentDt)
// console.log(`𝑑t: ${currentDt} ω: ${omegaVector[0].toFixed(2)} ωdot: ${omegaDotVector[0].toFixed(2)} ωdotdot: ${omegaDotDot.toFixed(2)} aPos: ${accelerationIsPositive} aChange: ${accelerationIsChanging}`)
log.debug(`𝑑t: ${currentDt} ω: ${omegaVector[0].toFixed(2)} ωdot: ${omegaDotVector[0].toFixed(2)} ωdotdot: ${omegaDotDot.toFixed(2)} aPos: ${accelerationIsPositive} aChange: ${accelerationIsChanging}`)
}
function startDrivePhase (currentDt) {
// console.log('*** drive phase started')
log.debug('*** drive phase started')
timer.start('drive')
jPower = 0.0
kPower = 0.0
if (strokeElapsed - driveElapsed !== 0) {
kDampEstimatorAverager.pushValue(kDampEstimator / (strokeElapsed - driveElapsed))
}
// console.log(`estimated kDamp: ${jMoment * (-1 * kDampEstimatorAverager.weightedAverage())}`)
// console.log(`estimated omegaDotDivOmegaSquare: ${-1 * kDampEstimatorAverager.weightedAverage()}`)
log.debug(`estimated kDamp: ${jMoment * (-1 * kDampEstimatorAverager.weightedAverage())}`)
log.debug(`estimated omegaDotDivOmegaSquare: ${-1 * kDampEstimatorAverager.weightedAverage()}`)
}
function updateDrivePhase (currentDt) {
jPower = jPower + jMoment * omegaVector[0] * omegaDotVector[0] * currentDt
kPower = kPower + kDamp * (omegaVector[0] * omegaVector[0] * omegaVector[0]) * currentDt
// console.log(`Jpower: ${jPower}, kPower: ${kPower}`)
log.debug(`Jpower: ${jPower}, kPower: ${kPower}`)
}
function startRecoveryPhase () {
@ -162,7 +163,7 @@ function createRowingEngine () {
timer.stop('drive')
strokeElapsed = timer.getValue('stroke')
timer.stop('stroke')
// console.log(`driveElapsed: ${driveElapsed}, strokeElapsed: ${strokeElapsed}`)
log.debug(`driveElapsed: ${driveElapsed}, strokeElapsed: ${strokeElapsed}`)
timer.start('stroke')
if (strokeElapsed !== 0 && workoutHandler) {
@ -177,7 +178,7 @@ function createRowingEngine () {
// stroke finished, reset stroke specific measurements
kDampEstimator = 0.0
strokeDistance = 0
// console.log('*** recovery phase started')
log.debug('*** recovery phase started')
}
function updateRecoveryPhase (currentDt) {

View File

@ -6,10 +6,6 @@
everything together while figuring out the physics and model of the application.
todo: refactor this as we progress
*/
import { createRowingMachinePeripheral } from './ble/RowingMachinePeripheral.js'
import { createRowingEngine } from './engine/RowingEngine.js'
import { createRowingStatistics } from './engine/RowingStatistics.js'
// import { recordRowingSession } from './tools/RowingRecorder.js'
// import readline from 'readline'
// import fs from 'fs'
import { fork } from 'child_process'
@ -17,7 +13,14 @@ import WebSocket from 'ws'
import finalhandler from 'finalhandler'
import http from 'http'
import serveStatic from 'serve-static'
import log from 'loglevel'
import { createRowingMachinePeripheral } from './ble/RowingMachinePeripheral.js'
import { createRowingEngine } from './engine/RowingEngine.js'
import { createRowingStatistics } from './engine/RowingStatistics.js'
// import { recordRowingSession } from './tools/RowingRecorder.js'
// sets the global log level
log.setLevel(log.levels.INFO)
let websocket
// recordRowingSession('recordings/wrx700_2magnets.csv')
const peripheral = createRowingMachinePeripheral({
@ -28,31 +31,30 @@ peripheral.on('controlPoint', (event) => {
if (event?.req?.name === 'requestControl') {
event.res = true
} else if (event?.req?.name === 'reset') {
console.log('reset requested')
log.debug('reset requested')
rowingStatistics.reset()
peripheral.notifyStatus({ name: 'reset' })
event.res = true
// todo: we could use these controls once we implement a concept of a rowing session
} else if (event?.req?.name === 'stop') {
console.log('stop requested')
log.debug('stop requested')
peripheral.notifyStatus({ name: 'stoppedOrPausedByUser' })
event.res = true
} else if (event?.req?.name === 'pause') {
console.log('pause requested')
log.debug('pause requested')
peripheral.notifyStatus({ name: 'stoppedOrPausedByUser' })
event.res = true
} else if (event?.req?.name === 'startOrResume') {
console.log('startOrResume requested')
log.debug('startOrResume requested')
peripheral.notifyStatus({ name: 'startedOrResumedByUser' })
event.res = true
} else {
console.log('unhandled Command', event.req)
log.info('unhandled Command', event.req)
}
})
const gpioTimerService = fork('./app/gpio/GpioTimerService.js')
gpioTimerService.on('message', (dataPoint) => {
// console.log(dataPoint)
rowingEngine.handleRotationImpulse(dataPoint)
})
@ -61,7 +63,7 @@ const rowingStatistics = createRowingStatistics()
rowingEngine.notify(rowingStatistics)
rowingStatistics.on('strokeFinished', (data) => {
console.log(`stroke: ${data.strokesTotal}, dur: ${data.strokeTime}s, power: ${data.power}w` +
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`)
@ -110,10 +112,10 @@ wss.on('connection', function connection (ws) {
rowingStatistics.reset()
peripheral.notifyStatus({ name: 'reset' })
} else {
console.log(`invalid command received: ${data}`)
log.info(`invalid command received: ${data}`)
}
} catch (err) {
console.log(err)
log.error(err)
}
})
/*
@ -155,7 +157,6 @@ function simulateRowing () {
strokesPerMinute: 10 + 20 * (Math.random() - 0.5),
speed: (15 + 20 * (Math.random() - 0.5)).toFixed(2)
}
// console.log(metrics)
peripheral.notifyData(metrics)
}
*/

View File

@ -7,14 +7,15 @@
import { fork } from 'child_process'
import fs from 'fs'
import log from 'loglevel'
function recordRowingSession (filename) {
// measure the gpio interrupts in another process, since we need
// to track time close to realtime
const gpioTimerService = fork('./app/tools/GpioTimerService.js')
gpioTimerService.on('message', (dataPoint) => {
console.log(dataPoint.delta)
fs.appendFile(filename, `${dataPoint.delta}\n`, (err) => { if (err) console.log(err) })
log.debug(dataPoint.delta)
fs.appendFile(filename, `${dataPoint.delta}\n`, (err) => { if (err) log.error(err) })
})
}

View File

@ -6,4 +6,4 @@ Open Rowing Monitor uses some great work by others. Thank you for all the great
* Dave Vernooy's project description on [ErgWare](https://dvernooy.github.io/projects/ergware) has some good information on the maths involved in a rowing ergometer.
* The app icon is based on this [image of a rowing machine](https://thenounproject.com/term/rowing-machine/659265) by [Gan Khoon Lay](https://thenounproject.com/leremy/) licensed under [CC BY 2.0](https://creativecommons.org/licenses/by/2.0/).
* The app icon is based on this [image of a rowing machine](https://thenounproject.com/term/rowing-machine/659265) by [Gan Khoon Lay](https://thenounproject.com/leremy/) licensed under [CC BY 2.0](https://creativecommons.org/licenses/by/2.0/).

View File

@ -8,16 +8,15 @@ This is the very minimalistic Backlog for further development of this project.
* handle training interruptions (set stroke specific metrics to "0" if no impulse detected for x seconds)
* check todo markers in code and add them to this backlog
* cleanup of the server.js start file
* add a logging framework
* figure out where to set the Service Advertising Data (FTMS.pdf p 15)
* Web UI: Replace Fullscreen Button with Exit Button when started from Homescreen
* investigate bug: crash, when one unsubscribe to BLE "Generic Attribute", probably a bleno bug "handleAttribute.emit is not a function"
* set up a raspi with the installation instructions to see if they are correct
## Later
* add a config file
* presets for rowing machine specific config parameters
* set a more appropriate ble Appearance (currently 0x2A01 Generic Computer)
* validate FTMS with more Training Applications and harden implementation
* make Web UI a proper Web Application (tooling and SPA framework)
* record the workout and show a visual graph of metrics
@ -25,5 +24,7 @@ This is the very minimalistic Backlog for further development of this project.
## Ideas
* add support for BLE Heart Rate Sensor and show pulse
* add Video playback in Background of Web UI
* implement or integrate some rowing games
* add possibility to define Workouts (i.e. training intervals with goals)

5
package-lock.json generated
View File

@ -1627,6 +1627,11 @@
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
},
"loglevel": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.7.1.tgz",
"integrity": "sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw=="
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",

View File

@ -22,6 +22,7 @@
"@abandonware/bleno": "^0.5.1-3",
"finalhandler": "^1.1.2",
"http": "0.0.1-security",
"loglevel": "^1.7.1",
"onoff": "^6.0.1",
"serve-static": "^1.14.1",
"ws": "^7.4.3"