implements tcx and raw export

This commit is contained in:
Lars Berning 2021-05-20 13:40:42 +02:00
parent c87e78b000
commit 602766c4a0
No known key found for this signature in database
GPG Key ID: 028E73C9E1D8A0B3
11 changed files with 658 additions and 400 deletions

1
.gitignore vendored
View File

@ -78,3 +78,4 @@ node_modules
tmp/
build/
config/config.js
data/

View File

@ -45,6 +45,14 @@ Fitness Machine Service (FTMS) is a standardized GATT protocol for different typ
**Concept2 PM:** Open Rowing Monitor also implements part of the Concept2 PM Bluetooth Smart Communication Interface Definition. This is still work in progress and only implements the most common parts of the spec, so it will not work with all applications that support C2 rowing machines. It currently works with all the samples from [The Erg Arcade](https://ergarcade.com), i.e. you can [row in the clouds](https://ergarcade.github.io/mrdoob-clouds/). This also works very well with [EXR](https://www.exrgame.com).
### Export of Training Sessions
Open Rowing Monitor can create Training Center XML files (TCX). You can upload these files to training platforms like [Strava](https://www.strava.com), [Garmin Connect](https://connect.garmin.com) or [Trainingpeaks](https://trainingpeaks.com) to track your training sessions.
Currently this is a manual step. The easiest way to do so is to place the configurable `data` directory onto a network share and then just grab the files from there.
Open Rowing Monitor can also store the raw measurements of the flywheel into CSV files. These files are great to start your own exploration of your rowing style and also to learn about the specifics of your rowing machine (some Excel files that can help with this are included in the `docs` folder).
## Installation
You will need a Raspberry Pi Zero W, Raspberry Pi 3 or a Raspberry Pi 4 with a fresh installation of Raspberry Pi OS 10 (Lite) for this. Connect to the device with SSH and initiate the following command to set up all required dependencies and to install Open Rowing Monitor as an automatically starting system service:

View File

@ -19,10 +19,12 @@ export function createApp () {
return 'FTMS Rower'
}
},
distanceTotal:
(value) => value >= 10000
distanceTotal: (value) => value >= 10000
? { value: (value / 1000).toFixed(1), unit: 'km' }
: { value, unit: 'm' }
: { value: Math.round(value), unit: 'm' },
caloriesTotal: (value) => Math.round(value),
power: (value) => Math.round(value),
strokesPerMinute: (value) => Math.round(value)
}
const standalone = (window.location.hash === '#:standalone:')

View File

@ -28,9 +28,10 @@ function createRowingStatistics () {
let durationTotal = 0
let strokesTotal = 0
let caloriesTotal = 0.0
let heartrate = 0
let heartrate
let heartrateBatteryLevel = 0
let lastStrokeDuration = 0.0
let lastStrokeDistance = 0.0
let lastStrokeState = 'RECOVERY'
let lastMetrics = {}
@ -63,6 +64,7 @@ function createRowingStatistics () {
distanceTotal += stroke.distance
strokesTotal++
lastStrokeDuration = stroke.duration
lastStrokeDistance = stroke.distance
lastStrokeState = stroke.strokeState
emitter.emit('strokeFinished', getMetrics())
@ -73,6 +75,7 @@ function createRowingStatistics () {
function handlePause (duration) {
caloriesAveragerMinute.pushValue(0, duration)
caloriesAveragerHour.pushValue(0, duration)
emitter.emit('rowingPaused')
}
// initiated when the stroke state changes
@ -101,17 +104,18 @@ function createRowingStatistics () {
durationTotal,
durationTotalFormatted: secondsToTimeString(durationTotal),
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
distanceTotal: distanceTotal, // meters
caloriesTotal: caloriesTotal, // kcal
caloriesPerMinute: caloriesAveragerMinute.average(),
caloriesPerHour: caloriesAveragerHour.average(),
strokeTime: lastStrokeDuration, // seconds
distance: lastStrokeDistance, // meters
power: powerAverager.weightedAverage(), // watts
split: splitTime, // seconds/500m
splitFormatted: secondsToTimeString(splitTime),
powerRatio: powerRatioAverager.weightedAverage().toFixed(2),
strokesPerMinute: strokeAverager.weightedAverage() !== 0 ? (60.0 / strokeAverager.weightedAverage()).toFixed(1) : 0,
speed: (speedAverager.weightedAverage() * 3.6).toFixed(2), // km/h
powerRatio: powerRatioAverager.weightedAverage(),
strokesPerMinute: strokeAverager.weightedAverage() !== 0 ? (60.0 / strokeAverager.weightedAverage()) : 0,
speed: (speedAverager.weightedAverage() * 3.6), // km/h
strokeState: lastStrokeState,
heartrate,
heartrateBatteryLevel
@ -150,6 +154,7 @@ function createRowingStatistics () {
speedAverager.reset()
powerRatioAverager.reset()
lastStrokeState = 'RECOVERY'
emitter.emit('rowingPaused')
}
function startDurationTimer () {

View File

@ -0,0 +1,201 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
This Module captures the metrics of a rowing session and persists them.
Todo: split this into multiple modules
*/
import log from 'loglevel'
import fs from 'fs'
import { mkdir } from 'fs/promises'
import xml2js from 'xml2js'
import config from '../tools/ConfigManager.js'
function createWorkoutRecorder () {
let strokes = []
let rotationImpulses = []
let startTime
function recordRotationImpulse (impulse) {
if (startTime === undefined) {
startTime = new Date()
}
rotationImpulses.push(impulse)
}
function recordStroke (stroke) {
if (startTime === undefined) {
startTime = new Date()
}
strokes.push(stroke)
}
async function createTcxFile () {
const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
const filename = `${directory}/${startTime.toISOString()}_rowing.tcx`
log.info(`saving session as tcx file ${filename}...`)
try {
await mkdir(directory, { recursive: true })
} catch (error) {
if (error.code !== 'EEXIST') {
log.error(`can not create directory ${directory}`, error)
}
}
buildAndSaveTcxFile({
id: startTime.toISOString(),
filename,
startTime,
strokes
})
}
async function createRawDataFile () {
const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
const filename = `${directory}/${startTime.toISOString()}_raw.csv`
log.info(`saving session as raw data file ${filename}...`)
try {
await mkdir(directory, { recursive: true })
} catch (error) {
if (error.code !== 'EEXIST') {
log.error(`can not create directory ${directory}`, error)
}
}
fs.writeFile(filename, rotationImpulses.join('\n'), (err) => { if (err) log.error(err) })
}
function buildAndSaveTcxFile (workout) {
let versionArray = process.env.npm_package_version.split('.')
if (versionArray.length < 3) versionArray = [0, 0, 0]
const lastStroke = workout.strokes[strokes.length - 1]
const tcxObject = {
TrainingCenterDatabase: {
$: { xmlns: 'http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2', 'xmlns:ns2': 'http://www.garmin.com/xmlschemas/ActivityExtension/v2' },
Activities: {
Activity: {
$: { Sport: 'Other' },
Id: workout.id,
Lap: [
{
$: { StartTime: workout.startTime.toISOString() },
TotalTimeSeconds: workout.strokes.reduce((acc, stroke) => acc + stroke.strokeTime, 0).toFixed(1),
DistanceMeters: lastStroke.distanceTotal.toFixed(1),
// tcx uses meters per second as unit for speed
MaximumSpeed: (workout.strokes.map((stroke) => stroke.speed).reduce((acc, speed) => Math.max(acc, speed)) / 3.6).toFixed(2),
Calories: lastStroke.caloriesTotal.toFixed(1),
/* todo: calculate heart rate metrics...
AverageHeartRateBpm: { Value: 76 },
MaximumHeartRateBpm: { Value: 76 },
*/
Intensity: 'Active',
// todo: calculate average SPM
// Cadence: 20,
TriggerMethod: 'Manual',
Track: {
Trackpoint: (() => {
let trackPointTime = workout.startTime
return workout.strokes.map((stroke) => {
trackPointTime = new Date(trackPointTime.getTime() + stroke.strokeTime * 1000)
const trackpoint = {
Time: trackPointTime.toISOString(),
DistanceMeters: stroke.distanceTotal.toFixed(2),
Cadence: stroke.strokesPerMinute.toFixed(1),
Extensions: {
'ns2:TPX': {
// tcx uses meters per second as unit for speed
'ns2:Speed': (stroke.speed / 3.6).toFixed(2),
'ns2:Watts': Math.round(stroke.power)
}
}
}
if (stroke.heartrate !== undefined) {
trackpoint.HeartRateBpm = { Value: stroke.heartrate }
}
return trackpoint
})
})()
},
Extensions: {
'ns2:LX': {
/* todo: calculate these metrics...
'ns2:AvgSpeed': 12,
'ns2:AvgWatts': 133,
*/
'ns2:MaxWatts': Math.round(workout.strokes.map((stroke) => stroke.power).reduce((acc, power) => Math.max(acc, power)))
}
}
}
],
Notes: 'Rowing Session'
}
},
Author: {
$: { 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', 'xsi:type': 'Application_t' },
Name: 'Open Rowing Monitor',
Build: {
Version: {
VersionMajor: versionArray[0],
VersionMinor: versionArray[1],
BuildMajor: versionArray[2],
BuildMinor: 0
},
LangID: 'en',
PartNumber: 'OPE-NROWI-NG'
}
}
}
}
const builder = new xml2js.Builder()
const tcxXml = builder.buildObject(tcxObject)
fs.writeFile(workout.filename, tcxXml, (err) => { if (err) log.error(err) })
}
function reset () {
createRecordings()
strokes = []
rotationImpulses = []
startTime = undefined
}
function handlePause () {
createRecordings()
}
function createRecordings () {
if (!config.recordRawData && !config.createTcxFiles) {
return
}
const minimumRecordingTimeInSeconds = 10
const rotationImpulseTimeTotal = rotationImpulses.reduce((acc, impulse) => acc + impulse, 0)
const strokeTimeTotal = strokes.reduce((acc, stroke) => acc + stroke.strokeTime, 0)
if (rotationImpulseTimeTotal < minimumRecordingTimeInSeconds && strokeTimeTotal < minimumRecordingTimeInSeconds) {
log.debug(`recording time is less than ${minimumRecordingTimeInSeconds}s, skipping creation of recording files...`)
return
}
if (config.recordRawData) {
createRawDataFile()
}
if (config.createTcxFiles) {
createTcxFile()
}
}
return {
recordStroke,
recordRotationImpulse,
handlePause,
reset
}
}
export { createWorkoutRecorder }

View File

@ -18,6 +18,7 @@ import { createPeripheralManager } from './ble/PeripheralManager.js'
import { createAntManager } from './ant/AntManager.js'
// eslint-disable-next-line no-unused-vars
import { replayRowingSession } from './tools/RowingRecorder.js'
import { createWorkoutRecorder } from './engine/WorkoutRecorder.js'
// set the log levels
log.setLevel(config.loglevel.default)
@ -36,8 +37,7 @@ peripheralManager.on('control', (event) => {
event.res = true
} else if (event?.req?.name === 'reset') {
log.debug('reset requested')
rowingStatistics.reset()
peripheralManager.notifyStatus({ name: 'reset' })
resetWorkout()
event.res = true
// todo: we could use these controls once we implement a concept of a rowing session
} else if (event?.req?.name === 'stop') {
@ -60,41 +60,39 @@ peripheralManager.on('control', (event) => {
}
})
function resetWorkout () {
rowingStatistics.reset()
peripheralManager.notifyStatus({ name: 'reset' })
workoutRecorder.reset()
}
const gpioTimerService = fork('./app/gpio/GpioTimerService.js')
gpioTimerService.on('message', (dataPoint) => {
gpioTimerService.on('message', handleRotationImpulse)
function handleRotationImpulse (dataPoint) {
if (config.recordRawData) {
workoutRecorder.recordRotationImpulse(dataPoint)
}
rowingEngine.handleRotationImpulse(dataPoint)
// fs.appendFile('recordings/WRX700_2magnets.csv', `${dataPoint}\n`, (err) => { if (err) log.error(err) })
})
}
const rowingEngine = createRowingEngine(config.rowerSettings)
const rowingStatistics = createRowingStatistics()
rowingEngine.notify(rowingStatistics)
const workoutRecorder = createWorkoutRecorder()
rowingStatistics.on('strokeFinished', (metrics) => {
log.info(`stroke: ${metrics.strokesTotal}, dur: ${metrics.strokeTime}s, power: ${metrics.power}w` +
`, split: ${metrics.splitFormatted}, ratio: ${metrics.powerRatio}, dist: ${metrics.distanceTotal}m` +
`, cal: ${metrics.caloriesTotal}kcal, SPM: ${metrics.strokesPerMinute}, speed: ${metrics.speed}km/h` +
`, cal/hour: ${metrics.caloriesPerHour}kcal, cal/minute: ${metrics.caloriesPerMinute}kcal`)
// Quick hack to generate tcx-trackpoints to get the basic concepts of TCX-export working
/*
const d = new Date()
const timestamp = d.toISOString()
fs.appendFile('exports/currentlog.tcx',
`<Trackpoint>\n <Time>${timestamp}</Time>\n` +
`<DistanceMeters>${metrics.distanceTotal}</DistanceMeters>\n` +
'<HeartRateBpm>\n' +
` <Value>${metrics.heartrate}</Value>\n` +
'</HeartRateBpm>\n' +
`<Cadence>${Math.round(metrics.strokesPerMinute)}</Cadence>\n` +
'<SensorState>Present</SensorState>\n' +
'<Extensions>\n <ns3:TPX>\n' +
`<ns3:Watts>${metrics.power}</ns3:Watts>\n` +
`<ns3:Speed>${(metrics.speed / 3.6).toFixed(2)}</ns3:Speed>\n` +
'</ns3:TPX>\n </Extensions>\n</Trackpoint>\n',
(err) => { if (err) log.error(err) })
*/
log.info(`stroke: ${metrics.strokesTotal}, dur: ${metrics.strokeTime.toFixed(2)}s, power: ${Math.round(metrics.power)}w` +
`, split: ${metrics.splitFormatted}, ratio: ${metrics.powerRatio.toFixed(2)}, dist: ${metrics.distanceTotal.toFixed(1)}m` +
`, cal: ${metrics.caloriesTotal.toFixed(1)}kcal, SPM: ${metrics.strokesPerMinute.toFixed(1)}, speed: ${metrics.speed.toFixed(2)}km/h` +
`, cal/hour: ${metrics.caloriesPerHour.toFixed(1)}kcal, cal/minute: ${metrics.caloriesPerMinute.toFixed(1)}kcal`)
webServer.notifyClients(metrics)
peripheralManager.notifyMetrics('strokeFinished', metrics)
// currently recording is only used if we want to create tcx files
if (config.createTcxFiles) {
workoutRecorder.recordStroke(metrics)
}
})
rowingStatistics.on('strokeStateChanged', (metrics) => {
@ -106,6 +104,10 @@ rowingStatistics.on('metricsUpdate', (metrics) => {
peripheralManager.notifyMetrics('metricsUpdate', metrics)
})
rowingStatistics.on('rowingPaused', () => {
workoutRecorder.handlePause()
})
if (config.heartrateMonitorBLE) {
const bleCentralService = fork('./app/ble/CentralService.js')
bleCentralService.on('message', (heartrateMeasurement) => {
@ -123,8 +125,7 @@ if (config.heartrateMonitorANT) {
const webServer = createWebServer()
webServer.on('messageReceived', (message) => {
if (message.command === 'reset') {
rowingStatistics.reset()
peripheralManager.notifyStatus({ name: 'reset' })
resetWorkout()
} else if (message.command === 'switchPeripheralMode') {
peripheralManager.switchPeripheralMode()
} else {
@ -137,9 +138,9 @@ webServer.on('clientConnected', () => {
})
/*
replayRowingSession(rowingEngine.handleRotationImpulse, {
replayRowingSession(handleRotationImpulse, {
filename: 'recordings/WRX700_2magnets.csv',
realtime: true,
loop: true
realtime: false,
loop: false
})
*/

View File

@ -48,6 +48,16 @@ export default {
// - Garmin mini ANT+ (ID 0x1009)
heartrateMonitorANT: false,
// The directory in which to store user specific content
// currently this directory holds the recorded training sessions
dataDirectory: 'data',
// Stores the training sessions as TCX files
createTcxFiles: true,
// Stores the raw sensor data in CSV files
recordRawData: false,
// Defines the name that is used to announce the FTMS Rower via Bluetooth Low Energy (BLE)
// Some rowing training applications expect that the rowing device is announced with a certain name
ftmsRowerPeripheralName: 'OpenRowingMonitor',

View File

@ -7,16 +7,16 @@ This is the very minimalistic Backlog for further development of this project.
* add an option to the installation script to directly attach a touchscreen to the Raspberry Pi and automatically show WebUI on this in kiosk mode
* validate FTMS with more training applications and harden implementation (i.e. Holofit and Coxswain)
* record a longer rowing session and analyze two encountered problems: 1) rarely the stroke rate doubles for a short duration (might be a problem with stroke detection when measurements are imprecise), 2) in one occasion the measured power jumped to a very high value after a break (40000 watts)
* add an option to automatically feed the measured damping constant back into the rowing engine
* add an option to select the damper setting in the Web UI
## Later
* automatically upload recorded rowing sessions to training platforms (i.e. Strava)
* figure out where to set the Service Advertising Data (FTMS.pdf p 15)
* add some attributes to BLE DeviceInformationService
* improve the physics model for water rowers
* make Web UI a proper Web Application (tooling and SPA framework)
* record the workout and show a visual graph of metrics
* export the workout
## Ideas

View File

@ -21,6 +21,7 @@ This guide roughly explains how to set up the rowing software and hardware.
* Install **Raspberry Pi OS Lite** on the SD Card i.e. with the [Raspberry Pi Imager](https://www.raspberrypi.org/software)
* Connect the device to your network ([headless](https://www.raspberrypi.org/documentation/configuration/wireless/headless.md) or via [command line](https://www.raspberrypi.org/documentation/configuration/wireless/wireless-cli.md))
* Enable [SSH](https://www.raspberrypi.org/documentation/remote-access/ssh/README.md)
* Starting with Raspberry Pi Imager 1.6 it is now possible to directly set the network and ssh configuration when writing the SD Card, just press `Ctrl-Shift-X`(see [here](https://www.raspberrypi.org/blog/raspberry-pi-imager-update-to-v1-6/) for a description)
### Installation of the Open Rowing Monitor

734
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -33,7 +33,8 @@
"nosleep.js": "^0.12.0",
"onoff": "^6.0.3",
"serve-static": "^1.14.1",
"ws": "^7.4.5"
"ws": "^7.4.5",
"xml2js": "^0.4.23"
},
"devDependencies": {
"eslint": "^7.26.0",