implements handling of rowing pauses
This commit is contained in:
parent
c317b22a6d
commit
3d08f06336
12
README.md
12
README.md
|
|
@ -9,8 +9,6 @@ Open Rowing Monitor is a Node.js application that runs on a Raspberry Pi and mea
|
|||
|
||||
I currently develop and test it with a [Raspberry Pi 3 Modell B](https://www.raspberrypi.org/products/raspberry-pi-3-model-b/) and a Sportstech WRX700 waterrower. But it should run fine with any rowing machine that uses an air or water resistance mechanism as long as you can add something to measure the speed of the flywheel.
|
||||
I suspect it works well with DIY rowing machines like the [Openergo](https://openergo.webs.com) too.
|
||||
<!-- markdownlint-disable-next-line no-inline-html -->
|
||||
<br clear="left">
|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -34,7 +32,7 @@ The web interface visualizes the rowing metrics on any device that can run a bro
|
|||
<!-- markdownlint-disable-next-line no-inline-html -->
|
||||
<img src="doc/img/openrowingmonitor_frontend.png" width="700"><br clear="left">
|
||||
|
||||
### BLE FTMS Support
|
||||
### Bluetooth Low Energy Fitness Machine Service (BLE FTMS)
|
||||
|
||||
Open Rowing Monitor also implements the Bluetooth Low Energy (BLE) protocol for Fitness Machine Service (FTMS). This allows using your rowing machine with any Fitness Application that supports FTMS.
|
||||
|
||||
|
|
@ -44,9 +42,9 @@ FTMS supports different types of fitness machines. Open Rowing Monitor currently
|
|||
|
||||
**FTMS Indoor Bike** is widely adopted by training applications for bike training. The simulated Indoor Bike offers metrics such as power and distance to the biking application. So why not use your rowing machine to row up a mountain in Zwift, Bkool, Sufferfest or similar :-)
|
||||
|
||||
## Why it all started
|
||||
## How it all started
|
||||
|
||||
I originally started this project, because my rowing machine (Sportstech WRX700) has a very simple computer and I wanted to build something with a clean and simple interface that calculates more realistic metrics.
|
||||
I originally started this project, because my rowing machine (Sportstech WRX700) has a very simple computer and I wanted to build something with a clean and simple interface that calculates more realistic metrics. Also, this was a good reason to learn a bit more about Bluetooth and all its specifics.
|
||||
|
||||
The original proof of concept version started as a sketch on an ardunio, but when I started adding things like a web frontend and BLE I moved it to the much more powerful Raspberry Pi. Maybe using a Raspi for this small IoT-project is a bit of an overkill, but it has the capacity for further features such as syncing training data or rowing games. And it has USB-Ports that I can use to charge my phone while rowing :-)
|
||||
|
||||
|
|
@ -58,4 +56,6 @@ Feel free to leave a message in the [GitHub Discussions](https://github.com/labe
|
|||
|
||||
Here are some basic [Installation Instructions](doc/installation.md).
|
||||
|
||||
This project uses some great work by others, see the [attribution here](doc/attribution.md).
|
||||
I plan to add more features, here is the [Development Roadmap](doc/backlog.md).
|
||||
|
||||
This project uses some great work by others, see the [Attribution here](doc/attribution.md).
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export default class IndoorBikeDataCharacteristic extends bleno.Characteristic {
|
|||
|
||||
notify (data) {
|
||||
// ignore events without the mandatory fields
|
||||
if (!data.speed) {
|
||||
if (!('speed' in data)) {
|
||||
log.error('can not deliver bike data without mandatory fields')
|
||||
return this.RESULT_SUCCESS
|
||||
}
|
||||
|
|
@ -66,15 +66,15 @@ export default class IndoorBikeDataCharacteristic extends bleno.Characteristic {
|
|||
// Instantaneous Speed in km/h
|
||||
buffer.writeUInt16LE(data.speed * 100, 2)
|
||||
// Total Distance in meters
|
||||
if (data.distanceTotal) {
|
||||
if ('distanceTotal' in data) {
|
||||
writeUInt24LE(data.distanceTotal, buffer, 4)
|
||||
}
|
||||
// Instantaneous Power in watts
|
||||
if (data.power) {
|
||||
if ('power' in data) {
|
||||
buffer.writeUInt16LE(data.power, 7)
|
||||
}
|
||||
// Energy
|
||||
if (data.caloriesTotal) {
|
||||
if ('caloriesTotal' in data) {
|
||||
// Total energy in kcal
|
||||
buffer.writeUInt16LE(data.caloriesTotal, 9)
|
||||
// Energy per hour
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export default class RowerDataCharacteristic extends bleno.Characteristic {
|
|||
|
||||
notify (data) {
|
||||
// ignore events without the mandatory fields
|
||||
if (!(data.strokesPerMinute && data.strokesTotal)) {
|
||||
if (!('strokesPerMinute' in data && 'strokesTotal' in data)) {
|
||||
return this.RESULT_SUCCESS
|
||||
}
|
||||
|
||||
|
|
@ -60,19 +60,19 @@ export default class RowerDataCharacteristic extends bleno.Characteristic {
|
|||
// Stroke Count
|
||||
buffer.writeUInt16LE(data.strokesTotal, 3)
|
||||
// Total Distance in meters
|
||||
if (data.distanceTotal) {
|
||||
if ('distanceTotal' in data) {
|
||||
writeUInt24LE(data.distanceTotal, buffer, 5)
|
||||
}
|
||||
// Instantaneous Pace in seconds/500m
|
||||
if (data.split) {
|
||||
if ('split' in data) {
|
||||
buffer.writeUInt16LE(data.split, 8)
|
||||
}
|
||||
// Instantaneous Power in watts
|
||||
if (data.power) {
|
||||
if ('power' in data) {
|
||||
buffer.writeUInt16LE(data.power, 10)
|
||||
}
|
||||
// Energy
|
||||
if (data.caloriesTotal) {
|
||||
// Energy in kcal
|
||||
if ('caloriesTotal' in data) {
|
||||
// Total energy in kcal
|
||||
buffer.writeUInt16LE(data.caloriesTotal, 12)
|
||||
// Energy per hour
|
||||
|
|
|
|||
|
|
@ -82,10 +82,9 @@ function createRowingEngine () {
|
|||
|
||||
// called if the sensor detected an impulse, currentDt is an interval in seconds
|
||||
function handleRotationImpulse (currentDt) {
|
||||
// todo: we should inform the workoutHandler in this case
|
||||
// (if we want to track the training history)
|
||||
// impulses that take longer than 3 seconds are considered a pause
|
||||
if (currentDt > 3.0) {
|
||||
log.debug(`training pause detected: ${currentDt}`)
|
||||
workoutHandler.handlePause(currentDt)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
This Module calculates the training specific metrics.
|
||||
*/
|
||||
import log from 'loglevel'
|
||||
import { EventEmitter } from 'events'
|
||||
import { createAverager } from './Averager.js'
|
||||
|
||||
|
|
@ -17,14 +18,21 @@ function createRowingStatistics () {
|
|||
const powerAverager = createAverager(numOfDataPointsForAveraging)
|
||||
const speedAverager = createAverager(numOfDataPointsForAveraging)
|
||||
const powerRatioAverager = createAverager(numOfDataPointsForAveraging)
|
||||
let trainingRunning = false
|
||||
let durationTimer
|
||||
let rowingPausedTimer
|
||||
let distanceTotal = 0.0
|
||||
let durationTotal = 0
|
||||
let strokesTotal = 0
|
||||
let caloriesTotal = 0.0
|
||||
|
||||
function handleStroke (stroke) {
|
||||
if (!durationTimer) startDurationTimer()
|
||||
if (!trainingRunning) startTraining()
|
||||
|
||||
// if we do not get a stroke for 6 seconds we treat this as a rowing pause
|
||||
if (rowingPausedTimer)clearInterval(rowingPausedTimer)
|
||||
rowingPausedTimer = setTimeout(() => pauseRowing(), 6000)
|
||||
|
||||
powerAverager.pushValue(stroke.power)
|
||||
speedAverager.pushValue(stroke.distance / stroke.duration)
|
||||
powerRatioAverager.pushValue(stroke.durationDrivePhase / stroke.duration)
|
||||
|
|
@ -50,8 +58,24 @@ function createRowingStatistics () {
|
|||
})
|
||||
}
|
||||
|
||||
function reset () {
|
||||
// initiated by the rowing engine in case an impulse was not considered
|
||||
// because it was to large
|
||||
function handlePause (duration) {
|
||||
}
|
||||
|
||||
function startTraining () {
|
||||
trainingRunning = true
|
||||
startDurationTimer()
|
||||
}
|
||||
|
||||
function stopTraining () {
|
||||
trainingRunning = false
|
||||
stopDurationTimer()
|
||||
if (rowingPausedTimer)clearInterval(rowingPausedTimer)
|
||||
}
|
||||
|
||||
function resetTraining () {
|
||||
stopTraining()
|
||||
distanceTotal = 0.0
|
||||
strokesTotal = 0
|
||||
caloriesTotal = 0.0
|
||||
|
|
@ -62,6 +86,20 @@ function createRowingStatistics () {
|
|||
powerRatioAverager.reset()
|
||||
}
|
||||
|
||||
// clear the displayed metrics in case the user pauses rowing
|
||||
function pauseRowing () {
|
||||
strokeAverager.reset()
|
||||
powerAverager.reset()
|
||||
speedAverager.reset()
|
||||
powerRatioAverager.reset()
|
||||
log.debug('rowing pause detected')
|
||||
emitter.emit('rowingPaused', {
|
||||
strokesTotal: strokesTotal,
|
||||
distanceTotal: Math.round(distanceTotal),
|
||||
caloriesTotal: Math.round(caloriesTotal)
|
||||
})
|
||||
}
|
||||
|
||||
function startDurationTimer () {
|
||||
durationTimer = setInterval(() => {
|
||||
durationTotal++
|
||||
|
|
@ -86,7 +124,8 @@ function createRowingStatistics () {
|
|||
|
||||
return Object.assign(emitter, {
|
||||
handleStroke,
|
||||
reset
|
||||
handlePause,
|
||||
reset: resetTraining
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
everything together while figuring out the physics and model of the application.
|
||||
todo: refactor this as we progress
|
||||
*/
|
||||
// import readline from 'readline'
|
||||
// import fs from 'fs'
|
||||
import { fork } from 'child_process'
|
||||
import WebSocket from 'ws'
|
||||
import finalhandler from 'finalhandler'
|
||||
|
|
@ -17,11 +15,12 @@ import log from 'loglevel'
|
|||
import { createRowingMachinePeripheral } from './ble/RowingMachinePeripheral.js'
|
||||
import { createRowingEngine } from './engine/RowingEngine.js'
|
||||
import { createRowingStatistics } from './engine/RowingStatistics.js'
|
||||
// import { recordRowingSession } from './tools/RowingRecorder.js'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { recordRowingSession, replayRowingSession } from './tools/RowingRecorder.js'
|
||||
|
||||
// sets the global log level
|
||||
log.setLevel(log.levels.INFO)
|
||||
// recordRowingSession('recordings/wrx700_2magnets.csv')
|
||||
|
||||
const peripheral = createRowingMachinePeripheral({
|
||||
simulateIndoorBike: true
|
||||
})
|
||||
|
|
@ -81,6 +80,22 @@ rowingStatistics.on('strokeFinished', (data) => {
|
|||
peripheral.notifyData(metrics)
|
||||
})
|
||||
|
||||
rowingStatistics.on('rowingPaused', (data) => {
|
||||
const metrics = {
|
||||
strokesTotal: data.strokesTotal,
|
||||
distanceTotal: data.distanceTotal,
|
||||
caloriesTotal: data.caloriesTotal,
|
||||
strokesPerMinute: 0,
|
||||
power: 0,
|
||||
// todo: setting split to 0 might be dangerous, depending on what the client does with this
|
||||
splitFormatted: '00:00',
|
||||
split: 0,
|
||||
speed: 0
|
||||
}
|
||||
notifyWebClients(metrics)
|
||||
peripheral.notifyData(metrics)
|
||||
})
|
||||
|
||||
rowingStatistics.on('durationUpdate', (data) => {
|
||||
notifyWebClients({
|
||||
durationTotal: data.durationTotal
|
||||
|
|
@ -127,13 +142,12 @@ function notifyWebClients (message) {
|
|||
})
|
||||
}
|
||||
|
||||
// recordRowingSession('recordings/wrx700_2magnets.csv')
|
||||
/*
|
||||
const readInterface = readline.createInterface({
|
||||
input: fs.createReadStream('recordings/wrx700_2magnets.csv')
|
||||
})
|
||||
|
||||
readInterface.on('line', function (line) {
|
||||
rowingEngine.handleRotationImpulse(parseFloat(line))
|
||||
replayRowingSession(rowingEngine.handleRotationImpulse, {
|
||||
filename: 'recordings/wrx700_2magnets.csv',
|
||||
realtime: true,
|
||||
loop: true
|
||||
})
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
'use strict'
|
||||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
*/
|
||||
import process from 'process'
|
||||
import { Gpio } from 'onoff'
|
||||
|
||||
export function createGpioTimerService () {
|
||||
// mode can be rising, falling, both
|
||||
const reedSensor = new Gpio(17, 'in', 'rising')
|
||||
let hrStartTime = process.hrtime()
|
||||
|
||||
// assumes that GPIO-Port 17 is set to pullup and reed is connected to GND
|
||||
// therefore the value is 1 if the reed sensor is open
|
||||
reedSensor.watch((err, value) => {
|
||||
if (err) {
|
||||
throw err
|
||||
}
|
||||
const hrDelta = process.hrtime(hrStartTime)
|
||||
hrStartTime = process.hrtime()
|
||||
const delta = hrDelta[0] + hrDelta[1] / 1e9
|
||||
process.send({ delta, value })
|
||||
})
|
||||
}
|
||||
|
||||
createGpioTimerService()
|
||||
|
|
@ -2,21 +2,57 @@
|
|||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
A quick hack to record measurements from the rowing machine for development purposes.
|
||||
A utility to record and replay flywheel measurements for development purposes.
|
||||
*/
|
||||
import { fork } from 'child_process'
|
||||
|
||||
import fs from 'fs'
|
||||
import readline from 'readline'
|
||||
import log from 'loglevel'
|
||||
|
||||
function recordRowingSession (filename) {
|
||||
// measure the gpio interrupts in another process, since we need
|
||||
// to track time close to realtime
|
||||
const gpioTimerService = fork('./app/tools/GpioTimerService.js')
|
||||
const gpioTimerService = fork('./app/gpio/GpioTimerService.js')
|
||||
gpioTimerService.on('message', (dataPoint) => {
|
||||
log.debug(dataPoint.delta)
|
||||
fs.appendFile(filename, `${dataPoint.delta}\n`, (err) => { if (err) log.error(err) })
|
||||
})
|
||||
}
|
||||
|
||||
export { recordRowingSession }
|
||||
async function replayRowingSession (rotationImpulseHandler, options) {
|
||||
if (!options?.filename) {
|
||||
log.error('can not replay rowing session without filename')
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
await replayRowingFile(rotationImpulseHandler, options)
|
||||
// infinite looping only available when using realtime
|
||||
} while (options.loop && options.realtime)
|
||||
}
|
||||
|
||||
async function replayRowingFile (rotationImpulseHandler, options) {
|
||||
const fileStream = fs.createReadStream(options.filename)
|
||||
const readLine = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of readLine) {
|
||||
const dt = parseFloat(line)
|
||||
// if we want to replay in the original time, wait dt seconds
|
||||
if (options.realtime) await wait(dt * 1000)
|
||||
rotationImpulseHandler(dt)
|
||||
}
|
||||
}
|
||||
|
||||
async function wait (ms) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
recordRowingSession,
|
||||
replayRowingSession
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,3 +7,5 @@ Open Rowing Monitor uses some great work by others. Thank you for all the great
|
|||
* Dave Vernooy's project description on [ErgWare](https://dvernooy.github.io/projects/ergware) has some good information on the maths involved in a rowing ergometer.
|
||||
|
||||
* The app icon is based on this [image of a rowing machine](https://thenounproject.com/term/rowing-machine/659265) by [Gan Khoon Lay](https://thenounproject.com/leremy/) licensed under [CC BY 2.0](https://creativecommons.org/licenses/by/2.0/).
|
||||
|
||||
* Bluetooth is quite a complex biest, luckily the Bluetooth SIG releases all the [specifications here](https://www.bluetooth.com/specifications/specs)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ This is the very minimalistic Backlog for further development of this project.
|
|||
|
||||
## Soon
|
||||
|
||||
* handle training interruptions (set stroke specific metrics to "0" if no impulse detected for x seconds)
|
||||
* check todo markers in code and add them to this backlog
|
||||
* cleanup of the server.js start file
|
||||
* figure out where to set the Service Advertising Data (FTMS.pdf p 15)
|
||||
|
|
|
|||
Loading…
Reference in New Issue