implements tcx and raw export
This commit is contained in:
parent
c87e78b000
commit
602766c4a0
|
|
@ -78,3 +78,4 @@ node_modules
|
|||
tmp/
|
||||
build/
|
||||
config/config.js
|
||||
data/
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:')
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
})
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue