211 lines
7.2 KiB
JavaScript
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 }
|