311 lines
10 KiB
JavaScript
311 lines
10 KiB
JavaScript
'use strict'
|
|
/*
|
|
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
|
|
|
This start file is currently a mess, as this currently is the devlopment playground to plug
|
|
everything together while figuring out the physics and model of the application.
|
|
todo: refactor this as we progress
|
|
*/
|
|
import os from 'os'
|
|
import child_process from 'child_process'
|
|
import { promisify } from 'util'
|
|
import log from 'loglevel'
|
|
import config from './tools/ConfigManager.js'
|
|
import { createRowingStatistics } from './engine/RowingStatistics.js'
|
|
import { createWebServer } from './WebServer.js'
|
|
import { createPeripheralManager } from './peripherals/PeripheralManager.js'
|
|
// eslint-disable-next-line no-unused-vars
|
|
import { replayRowingSession } from './tools/RowingRecorder.js'
|
|
import { createWorkoutRecorder } from './engine/WorkoutRecorder.js'
|
|
import { createWorkoutUploader } from './engine/WorkoutUploader.js'
|
|
import { secondsToTimeString } from './tools/Helper.js'
|
|
const exec = promisify(child_process.exec)
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
log.info(`==== Open Rowing Monitor ${process.env.npm_package_version || ''} ====\n`)
|
|
|
|
if (config.appPriority) {
|
|
// setting the (near-real-time?) priority for the main process, to prevent blocking the GPIO thread
|
|
const mainPriority = Math.min((config.appPriority), 0)
|
|
log.debug(`Setting priority for the main server thread to ${mainPriority}`)
|
|
try {
|
|
// setting priority of current process
|
|
os.setPriority(mainPriority)
|
|
} catch (err) {
|
|
log.debug('need root permission to set priority of main server thread')
|
|
}
|
|
}
|
|
|
|
// a hook for setting session parameters that the rower has to obey
|
|
// Hopefully this will be filled through the WebGUI or through the BLE interface (PM5-BLE can do this...)
|
|
// When set, ORM will terminate the session after reaching the target. If not set, it will behave as usual (a "Just row" session).
|
|
// When set, the GUI will behave similar to a PM5 in that it counts down from the target to 0
|
|
const intervalSettings = []
|
|
|
|
/* an example of the workout setting that RowingStatistics will obey: a 1 minute warmup, a 2K timed piece followed by a 1 minute cooldown
|
|
// This should normally come from the PM5 interface or the webinterface
|
|
intervalSettings[0] = {
|
|
targetDistance: 0,
|
|
targetTime: 60
|
|
}
|
|
|
|
/* Additional intervals for testing
|
|
intervalSettings[1] = {
|
|
targetDistance: 2000,
|
|
targetTime: 0
|
|
}
|
|
|
|
intervalSettings[2] = {
|
|
targetDistance: 0,
|
|
targetTime: 60
|
|
}
|
|
*/
|
|
|
|
const peripheralManager = createPeripheralManager()
|
|
|
|
peripheralManager.on('control', (event) => {
|
|
switch (event?.req?.name) {
|
|
case 'requestControl':
|
|
event.res = true
|
|
break
|
|
case 'reset':
|
|
log.debug('reset requested')
|
|
resetWorkout()
|
|
event.res = true
|
|
break
|
|
// todo: we could use these controls once we implement a concept of a rowing session
|
|
case 'stop':
|
|
log.debug('stop requested')
|
|
stopWorkout()
|
|
peripheralManager.notifyStatus({ name: 'stoppedOrPausedByUser' })
|
|
event.res = true
|
|
break
|
|
case 'pause':
|
|
log.debug('pause requested')
|
|
pauseWorkout()
|
|
peripheralManager.notifyStatus({ name: 'stoppedOrPausedByUser' })
|
|
event.res = true
|
|
break
|
|
case 'startOrResume':
|
|
log.debug('startOrResume requested')
|
|
resumeWorkout()
|
|
peripheralManager.notifyStatus({ name: 'startedOrResumedByUser' })
|
|
event.res = true
|
|
break
|
|
case 'blePeripheralMode':
|
|
webServer.notifyClients('config', getConfig())
|
|
event.res = true
|
|
break
|
|
case 'antPeripheralMode':
|
|
webServer.notifyClients('config', getConfig())
|
|
event.res = true
|
|
break
|
|
case 'hrmPeripheralMode':
|
|
webServer.notifyClients('config', getConfig())
|
|
event.res = true
|
|
break
|
|
default:
|
|
log.info('unhandled Command', event.req)
|
|
}
|
|
})
|
|
|
|
peripheralManager.on('heartRateMeasurement', (heartRateMeasurement) => {
|
|
rowingStatistics.handleHeartRateMeasurement(heartRateMeasurement)
|
|
})
|
|
|
|
function pauseWorkout () {
|
|
rowingStatistics.pause()
|
|
}
|
|
|
|
function stopWorkout () {
|
|
rowingStatistics.stop()
|
|
}
|
|
|
|
function resumeWorkout () {
|
|
rowingStatistics.resume()
|
|
}
|
|
|
|
function resetWorkout () {
|
|
workoutRecorder.reset()
|
|
rowingStatistics.reset()
|
|
peripheralManager.notifyStatus({ name: 'reset' })
|
|
}
|
|
|
|
const gpioTimerService = child_process.fork('./app/gpio/GpioTimerService.js')
|
|
gpioTimerService.on('message', handleRotationImpulse)
|
|
|
|
process.once('SIGINT', async (signal) => {
|
|
log.debug(`${signal} signal was received, shutting down gracefully`)
|
|
await peripheralManager.shutdownAllPeripherals()
|
|
process.exit(0)
|
|
})
|
|
process.once('SIGTERM', async (signal) => {
|
|
log.debug(`${signal} signal was received, shutting down gracefully`)
|
|
await peripheralManager.shutdownAllPeripherals()
|
|
process.exit(0)
|
|
})
|
|
process.once('uncaughtException', async (error) => {
|
|
log.error('Uncaught Exception:', error)
|
|
await peripheralManager.shutdownAllPeripherals()
|
|
process.exit(1)
|
|
})
|
|
|
|
function handleRotationImpulse (dataPoint) {
|
|
workoutRecorder.recordRotationImpulse(dataPoint)
|
|
rowingStatistics.handleRotationImpulse(dataPoint)
|
|
}
|
|
|
|
const rowingStatistics = createRowingStatistics(config)
|
|
if (intervalSettings.length > 0) {
|
|
// There is an interval defined at startup, let's inform RowingStatistics
|
|
// ToDo: update these settings when the PM5 or webinterface tells us to
|
|
rowingStatistics.setIntervalParameters(intervalSettings)
|
|
} else {
|
|
log.info('Starting a just row session, no time or distance target set')
|
|
}
|
|
|
|
const workoutRecorder = createWorkoutRecorder()
|
|
const workoutUploader = createWorkoutUploader(workoutRecorder)
|
|
|
|
rowingStatistics.on('driveFinished', (metrics) => {
|
|
webServer.notifyClients('metrics', metrics)
|
|
peripheralManager.notifyMetrics('strokeStateChanged', metrics)
|
|
})
|
|
|
|
rowingStatistics.on('recoveryFinished', (metrics) => {
|
|
logMetrics(metrics)
|
|
webServer.notifyClients('metrics', metrics)
|
|
peripheralManager.notifyMetrics('strokeFinished', metrics)
|
|
workoutRecorder.recordStroke(metrics)
|
|
})
|
|
|
|
rowingStatistics.on('webMetricsUpdate', (metrics) => {
|
|
webServer.notifyClients('metrics', metrics)
|
|
})
|
|
|
|
rowingStatistics.on('peripheralMetricsUpdate', (metrics) => {
|
|
peripheralManager.notifyMetrics('metricsUpdate', metrics)
|
|
})
|
|
|
|
rowingStatistics.on('rowingPaused', (metrics) => {
|
|
logMetrics(metrics)
|
|
workoutRecorder.recordStroke(metrics)
|
|
workoutRecorder.handlePause()
|
|
webServer.notifyClients('metrics', metrics)
|
|
peripheralManager.notifyMetrics('metricsUpdate', metrics)
|
|
})
|
|
|
|
rowingStatistics.on('intervalTargetReached', (metrics) => {
|
|
// This is called when the RowingStatistics conclude the intervaltarget is reached
|
|
// Update all screens to reflect this change, as targetTime and targetDistance have changed
|
|
// ToDo: recording this event in the recordings accordingly should be done as well
|
|
webServer.notifyClients('metrics', metrics)
|
|
peripheralManager.notifyMetrics('metricsUpdate', metrics)
|
|
})
|
|
|
|
rowingStatistics.on('rowingStopped', (metrics) => {
|
|
// This is called when the rowingmachine is stopped for some reason, could be reaching the end of the session,
|
|
// could be user intervention
|
|
logMetrics(metrics)
|
|
workoutRecorder.recordStroke(metrics)
|
|
webServer.notifyClients('metrics', metrics)
|
|
peripheralManager.notifyMetrics('metricsUpdate', metrics)
|
|
workoutRecorder.writeRecordings()
|
|
})
|
|
|
|
rowingStatistics.on('HRRecoveryUpdate', (hrMetrics) => {
|
|
// This is called at minute intervals after the rowingmachine has stopped, to record the Recovery heartrate in the tcx
|
|
workoutRecorder.updateHRRecovery(hrMetrics)
|
|
})
|
|
|
|
workoutUploader.on('authorizeStrava', (data, client) => {
|
|
webServer.notifyClient(client, 'authorizeStrava', data)
|
|
})
|
|
|
|
workoutUploader.on('resetWorkout', () => {
|
|
resetWorkout()
|
|
})
|
|
|
|
const webServer = createWebServer()
|
|
webServer.on('messageReceived', async (message, client) => {
|
|
switch (message.command) {
|
|
case 'switchBlePeripheralMode':
|
|
peripheralManager.switchBlePeripheralMode()
|
|
break
|
|
case 'switchAntPeripheralMode':
|
|
peripheralManager.switchAntPeripheralMode()
|
|
break
|
|
case 'switchHrmMode':
|
|
peripheralManager.switchHrmMode()
|
|
break
|
|
case 'reset':
|
|
resetWorkout()
|
|
break
|
|
case 'uploadTraining':
|
|
workoutUploader.upload(client)
|
|
break
|
|
case 'shutdown':
|
|
await shutdown()
|
|
break
|
|
case 'stravaAuthorizationCode':
|
|
workoutUploader.stravaAuthorizationCode(message.data)
|
|
break
|
|
default:
|
|
log.warn('invalid command received:', message)
|
|
}
|
|
})
|
|
|
|
webServer.on('clientConnected', (client) => {
|
|
webServer.notifyClient(client, 'config', getConfig())
|
|
})
|
|
|
|
// todo: extract this into some kind of state manager
|
|
function getConfig () {
|
|
return {
|
|
blePeripheralMode: peripheralManager.getBlePeripheralMode(),
|
|
antPeripheralMode: peripheralManager.getAntPeripheralMode(),
|
|
hrmPeripheralMode: peripheralManager.getHrmPeripheralMode(),
|
|
stravaUploadEnabled: !!config.stravaClientId && !!config.stravaClientSecret,
|
|
shutdownEnabled: !!config.shutdownCommand
|
|
}
|
|
}
|
|
|
|
// This shuts down the pi, use with caution!
|
|
async function shutdown () {
|
|
stopWorkout()
|
|
if (getConfig().shutdownEnabled) {
|
|
console.info('shutting down device...')
|
|
try {
|
|
const { stdout, stderr } = await exec(config.shutdownCommand)
|
|
if (stderr) {
|
|
log.error('can not shutdown: ', stderr)
|
|
}
|
|
log.info(stdout)
|
|
} catch (error) {
|
|
log.error('can not shutdown: ', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
function logMetrics (metrics) {
|
|
log.info(`stroke: ${metrics.totalNumberOfStrokes}, dist: ${metrics.totalLinearDistance.toFixed(1)}m, speed: ${metrics.cycleLinearVelocity.toFixed(2)}m/s` +
|
|
`, pace: ${secondsToTimeString(metrics.cyclePace)}/500m, power: ${Math.round(metrics.cyclePower)}W, cal: ${metrics.totalCalories.toFixed(1)}kcal` +
|
|
`, SPM: ${metrics.cycleStrokeRate.toFixed(1)}, drive dur: ${metrics.driveDuration.toFixed(2)}s, rec. dur: ${metrics.recoveryDuration.toFixed(2)}s` +
|
|
`, stroke dur: ${metrics.cycleDuration.toFixed(2)}s`)
|
|
}
|
|
|
|
/*
|
|
replayRowingSession(handleRotationImpulse, {
|
|
filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', // Example row from a Concept 2 RowErg, 2000 meters
|
|
realtime: true,
|
|
loop: false
|
|
})
|
|
*/
|