openrowingmonitor/app/engine/WorkoutRecorder.js

211 lines
7.2 KiB
JavaScript

'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()
}
// impulse recordings a currently only used to create raw data files, so we can skip it
// if raw data file creation is disabled
if (config.recordRawData) {
rotationImpulses.push(impulse)
}
}
function recordStroke (stroke) {
if (startTime === undefined) {
startTime = new Date()
}
// stroke recordings are currently only used to create tcx files, so we can skip it
// if tcx file creation is disabled
if (config.createTcxFiles) {
strokes.push(stroke)
}
}
async function createTcxFile () {
const stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
const filename = `${directory}/${stringifiedStartTime}_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 stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
const filename = `${directory}/${stringifiedStartTime}_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: Math.round(lastStroke.caloriesTotal),
/* 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: Math.round(stroke.strokesPerMinute),
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) })
}
async function reset () {
await createRecordings()
strokes = []
rotationImpulses = []
startTime = undefined
}
function handlePause () {
createRecordings()
}
async 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) {
await createRawDataFile()
}
if (config.createTcxFiles) {
await createTcxFile()
}
}
return {
recordStroke,
recordRotationImpulse,
handlePause,
reset
}
}
export { createWorkoutRecorder }