53 |
54 | `
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/client/components/PerformanceDashboard.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | Component that renders the dashboard
6 | */
7 |
8 | import { AppElement, html, css } from './AppElement.js'
9 | import { APP_STATE } from '../store/appState.js'
10 | import { customElement, property } from 'lit/decorators.js'
11 | import './DashboardMetric.js'
12 | import './DashboardActions.js'
13 | import './BatteryIcon.js'
14 | import { icon_route, icon_stopwatch, icon_bolt, icon_paddle, icon_heartbeat, icon_fire, icon_clock } from '../lib/icons.js'
15 |
16 | @customElement('performance-dashboard')
17 | export class PerformanceDashboard extends AppElement {
18 | static styles = css`
19 | :host {
20 | display: grid;
21 | height: calc(100vh - 2vw);
22 | padding: 1vw;
23 | grid-gap: 1vw;
24 | grid-template-columns: repeat(4, minmax(0, 1fr));
25 | grid-template-rows: repeat(2, minmax(0, 1fr));
26 | }
27 |
28 | @media (orientation: portrait) {
29 | :host {
30 | grid-template-columns: repeat(2, minmax(0, 1fr));
31 | grid-template-rows: repeat(4, minmax(0, 1fr));
32 | }
33 | }
34 |
35 | dashboard-metric, dashboard-actions {
36 | background: var(--theme-widget-color);
37 | text-align: center;
38 | position: relative;
39 | padding: 0.5em 0.2em 0 0.2em;
40 | border-radius: var(--theme-border-radius);
41 | }
42 |
43 | dashboard-actions {
44 | padding: 0.5em 0 0 0;
45 | }
46 | `
47 |
48 | @property({ type: Object })
49 | metrics
50 |
51 | @property({ type: Object })
52 | appState = APP_STATE
53 |
54 | render () {
55 | const metrics = this.calculateFormattedMetrics(this.appState.metrics)
56 | return html`
57 |
58 |
59 |
60 |
61 | ${metrics?.heartrate?.value
62 | ? html`
63 |
64 | ${metrics?.heartrateBatteryLevel?.value
65 | ? html`
66 |
67 | `
68 | : ''
69 | }
70 | `
71 | : html``}
72 |
73 |
74 |
75 | `
76 | }
77 |
78 | // todo: so far this is just a port of the formatter from the initial proof of concept client
79 | // we could split this up to make it more readable and testable
80 | calculateFormattedMetrics (metrics) {
81 | const fieldFormatter = {
82 | distanceTotal: (value) => value >= 10000
83 | ? { value: (value / 1000).toFixed(1), unit: 'km' }
84 | : { value: Math.round(value), unit: 'm' },
85 | caloriesTotal: (value) => Math.round(value),
86 | power: (value) => Math.round(value),
87 | strokesPerMinute: (value) => Math.round(value)
88 | }
89 |
90 | const formattedMetrics = {}
91 | for (const [key, value] of Object.entries(metrics)) {
92 | const valueFormatted = fieldFormatter[key] ? fieldFormatter[key](value) : value
93 | if (valueFormatted.value !== undefined && valueFormatted.unit !== undefined) {
94 | formattedMetrics[key] = {
95 | value: valueFormatted.value,
96 | unit: valueFormatted.unit
97 | }
98 | } else {
99 | formattedMetrics[key] = {
100 | value: valueFormatted
101 | }
102 | }
103 | }
104 | return formattedMetrics
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/app/client/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/app/client/icon.png
--------------------------------------------------------------------------------
/app/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | Open Rowing Monitor
16 |
17 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/app/client/index.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | Main Initialization Component of the Web Component App
6 | */
7 |
8 | import { LitElement, html } from 'lit'
9 | import { customElement, state } from 'lit/decorators.js'
10 | import { APP_STATE } from './store/appState.js'
11 | import { createApp } from './lib/app.js'
12 | import './components/PerformanceDashboard.js'
13 |
14 | @customElement('web-app')
15 | export class App extends LitElement {
16 | @state()
17 | appState = APP_STATE
18 |
19 | @state()
20 | metrics
21 |
22 | constructor () {
23 | super()
24 |
25 | this.app = createApp({
26 | updateState: this.updateState,
27 | getState: this.getState
28 | // todo: we also want a mechanism here to get notified of state changes
29 | })
30 |
31 | // this is how we implement changes to the global state:
32 | // once any child component sends this CustomEvent we update the global state according
33 | // to the changes that were passed to us
34 | this.addEventListener('appStateChanged', (event) => {
35 | this.updateState(event.detail)
36 | })
37 |
38 | // notify the app about the triggered action
39 | this.addEventListener('triggerAction', (event) => {
40 | this.app.handleAction(event.detail)
41 | })
42 | }
43 |
44 | // the global state is updated by replacing the appState with a copy of the new state
45 | // todo: maybe it is more convenient to just pass the state elements that should be changed?
46 | // i.e. do something like this.appState = { ..this.appState, ...newState }
47 | updateState = (newState) => {
48 | this.appState = { ...newState }
49 | }
50 |
51 | // return a deep copy of the state to other components to minimize risk of side effects
52 | getState = () => {
53 | // could use structuredClone once the browser support is wider
54 | // https://developer.mozilla.org/en-US/docs/Web/API/structuredClone
55 | return JSON.parse(JSON.stringify(this.appState))
56 | }
57 |
58 | // once we have multiple views, then we would rather reference some kind of router here
59 | // instead of embedding the performance-dashboard directly
60 | render () {
61 | return html`
62 |
66 | `
67 | }
68 |
69 | // there is no need to put this initialization component into a shadow root
70 | createRenderRoot () {
71 | return this
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/app/client/lib/app.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | Initialization file of the Open Rowing Monitor App
6 | */
7 |
8 | import NoSleep from 'nosleep.js'
9 | import { filterObjectByKeys } from './helper.js'
10 |
11 | const rowingMetricsFields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', 'power', 'heartrate',
12 | 'heartrateBatteryLevel', 'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted']
13 |
14 | export function createApp (app) {
15 | const urlParameters = new URLSearchParams(window.location.search)
16 | const mode = urlParameters.get('mode')
17 | const appMode = mode === 'standalone' ? 'STANDALONE' : mode === 'kiosk' ? 'KIOSK' : 'BROWSER'
18 | app.updateState({ ...app.getState(), appMode })
19 |
20 | const stravaAuthorizationCode = urlParameters.get('code')
21 |
22 | let socket
23 |
24 | initWebsocket()
25 | resetFields()
26 | requestWakeLock()
27 |
28 | function websocketOpened () {
29 | if (stravaAuthorizationCode) {
30 | handleStravaAuthorization(stravaAuthorizationCode)
31 | }
32 | }
33 |
34 | function handleStravaAuthorization (stravaAuthorizationCode) {
35 | if (socket)socket.send(JSON.stringify({ command: 'stravaAuthorizationCode', data: stravaAuthorizationCode }))
36 | }
37 |
38 | let initialWebsocketOpenend = true
39 | function initWebsocket () {
40 | // use the native websocket implementation of browser to communicate with backend
41 | socket = new WebSocket(`ws://${location.host}/websocket`)
42 |
43 | socket.addEventListener('open', (event) => {
44 | console.log('websocket opened')
45 | if (initialWebsocketOpenend) {
46 | websocketOpened()
47 | initialWebsocketOpenend = false
48 | }
49 | })
50 |
51 | socket.addEventListener('error', (error) => {
52 | console.log('websocket error', error)
53 | socket.close()
54 | })
55 |
56 | socket.addEventListener('close', (event) => {
57 | console.log('websocket closed, attempting reconnect')
58 | setTimeout(() => {
59 | initWebsocket()
60 | }, 1000)
61 | })
62 |
63 | // todo: we should use different types of messages to make processing easier
64 | socket.addEventListener('message', (event) => {
65 | try {
66 | const message = JSON.parse(event.data)
67 | if (!message.type) {
68 | console.error('message does not contain messageType specifier', message)
69 | return
70 | }
71 | const data = message.data
72 | switch (message.type) {
73 | case 'config': {
74 | app.updateState({ ...app.getState(), config: data })
75 | break
76 | }
77 | case 'metrics': {
78 | let activeFields = rowingMetricsFields
79 | // if we are in reset state only update heart rate
80 | if (data.strokesTotal === 0) {
81 | activeFields = ['heartrate', 'heartrateBatteryLevel']
82 | }
83 |
84 | const filteredData = filterObjectByKeys(data, activeFields)
85 | app.updateState({ ...app.getState(), metrics: filteredData })
86 | break
87 | }
88 | case 'authorizeStrava': {
89 | const currentUrl = encodeURIComponent(window.location.href)
90 | window.location.href = `https://www.strava.com/oauth/authorize?client_id=${data.stravaClientId}&response_type=code&redirect_uri=${currentUrl}&approval_prompt=force&scope=activity:write`
91 | break
92 | }
93 | default: {
94 | console.error(`unknown message type: ${message.type}`, message.data)
95 | }
96 | }
97 | } catch (err) {
98 | console.log(err)
99 | }
100 | })
101 | }
102 |
103 | async function requestWakeLock () {
104 | // Chrome enables the new Wake Lock API only if the connection is secured via SSL
105 | // This is quite annoying for IoT use cases like this one, where the device sits on the
106 | // local network and is directly addressed by its IP.
107 | // In this case the only way of using SSL is by creating a self signed certificate, and
108 | // that would pop up different warnings in the browser (and also prevents fullscreen via
109 | // a home screen icon so it can show these warnings). Okay, enough ranting :-)
110 | // In this case we use the good old hacky way of keeping the screen on via a hidden video.
111 | const noSleep = new NoSleep()
112 | document.addEventListener('click', function enableNoSleep () {
113 | document.removeEventListener('click', enableNoSleep, false)
114 | noSleep.enable()
115 | }, false)
116 | }
117 |
118 | function resetFields () {
119 | const appState = app.getState()
120 | // drop all metrics except heartrate
121 | appState.metrics = filterObjectByKeys(appState.metrics, ['heartrate', 'heartrateBatteryLevel'])
122 | app.updateState(appState)
123 | }
124 |
125 | function handleAction (action) {
126 | switch (action.command) {
127 | case 'switchPeripheralMode': {
128 | if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' }))
129 | break
130 | }
131 | case 'reset': {
132 | resetFields()
133 | if (socket)socket.send(JSON.stringify({ command: 'reset' }))
134 | break
135 | }
136 | case 'uploadTraining': {
137 | if (socket)socket.send(JSON.stringify({ command: 'uploadTraining' }))
138 | break
139 | }
140 | case 'shutdown': {
141 | if (socket)socket.send(JSON.stringify({ command: 'shutdown' }))
142 | break
143 | }
144 | default: {
145 | console.error('no handler defined for action', action)
146 | }
147 | }
148 | }
149 |
150 | return {
151 | handleAction
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/app/client/lib/helper.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | Helper functions
6 | */
7 |
8 | // Filters an object so that it only contains the attributes that are defined in a list
9 | export function filterObjectByKeys (object, keys) {
10 | return Object.keys(object)
11 | .filter(key => keys.includes(key))
12 | .reduce((obj, key) => {
13 | obj[key] = object[key]
14 | return obj
15 | }, {})
16 | }
17 |
--------------------------------------------------------------------------------
/app/client/lib/helper.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 | */
5 | import { test } from 'uvu'
6 | import * as assert from 'uvu/assert'
7 |
8 | import { filterObjectByKeys } from './helper.js'
9 |
10 | test('filterd list should only contain the elements specified', () => {
11 | const object1 = {
12 | a: ['a1', 'a2'],
13 | b: 'b'
14 | }
15 |
16 | const object2 = {
17 | a: ['a1', 'a2']
18 | }
19 |
20 | const filteredObject = filterObjectByKeys(object1, ['a'])
21 | assert.equal(filterObjectByKeys(filteredObject, ['a']), object2)
22 | })
23 |
24 | test.run()
25 |
--------------------------------------------------------------------------------
/app/client/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Rowing Monitor",
3 | "name": "Open Rowing Monitor",
4 | "description": "A rowing monitor for rowing exercise machines",
5 | "icons": [
6 | {
7 | "src": "icon.png",
8 | "sizes": "192x192",
9 | "type": "image/png"
10 | }
11 | ],
12 | "background_color": "#002b57",
13 | "display": "fullscreen",
14 | "orientation": "any",
15 | "start_url": "/?mode=standalone"
16 | }
17 |
--------------------------------------------------------------------------------
/app/client/store/appState.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | Defines the global state of the app
6 | */
7 |
8 | export const APP_STATE = {
9 | // currently can be STANDALONE (Mobile Home Screen App), KIOSK (Raspberry Pi deployment) or '' (default)
10 | appMode: '',
11 | // contains all the rowing metrics that are delivered from the backend
12 | metrics: {},
13 | config: {
14 | // currently can be FTMS, FTMSBIKE or PM5
15 | peripheralMode: '',
16 | // true if upload to strava is enabled
17 | stravaUploadEnabled: false,
18 | // true if remote device shutdown is enabled
19 | shutdownEnabled: false
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/app/engine/RowingEngine.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 | */
5 | import { test } from 'uvu'
6 | import * as assert from 'uvu/assert'
7 | import loglevel from 'loglevel'
8 |
9 | import rowerProfiles from '../../config/rowerProfiles.js'
10 | import { createRowingEngine } from './RowingEngine.js'
11 | import { replayRowingSession } from '../tools/RowingRecorder.js'
12 | import { deepMerge } from '../tools/Helper.js'
13 |
14 | const log = loglevel.getLogger('RowingEngine.test')
15 | log.setLevel('warn')
16 |
17 | const createWorkoutEvaluator = function () {
18 | const strokes = []
19 |
20 | function handleDriveEnd (stroke) {
21 | strokes.push(stroke)
22 | log.info(`stroke: ${strokes.length}, power: ${Math.round(stroke.power)}w, duration: ${stroke.duration.toFixed(2)}s, ` +
23 | ` drivePhase: ${stroke.durationDrivePhase.toFixed(2)}s, distance: ${stroke.distance.toFixed(2)}m`)
24 | }
25 | function updateKeyMetrics () {}
26 | function handleRecoveryEnd () {}
27 | function handlePause () {}
28 | function getNumOfStrokes () {
29 | return strokes.length
30 | }
31 | function getMaxStrokePower () {
32 | return strokes.map((stroke) => stroke.power).reduce((acc, power) => Math.max(acc, power))
33 | }
34 | function getMinStrokePower () {
35 | return strokes.map((stroke) => stroke.power).reduce((acc, power) => Math.max(acc, power))
36 | }
37 | function getDistanceSum () {
38 | return strokes.map((stroke) => stroke.strokeDistance).reduce((acc, strokeDistance) => acc + strokeDistance)
39 | }
40 | function getDistanceTotal () {
41 | return strokes[strokes.length - 1].distance
42 | }
43 |
44 | return {
45 | handleDriveEnd,
46 | handleRecoveryEnd,
47 | updateKeyMetrics,
48 | handlePause,
49 | getNumOfStrokes,
50 | getMaxStrokePower,
51 | getMinStrokePower,
52 | getDistanceSum,
53 | getDistanceTotal
54 | }
55 | }
56 |
57 | test('sample data for WRX700 should produce plausible results with rower profile', async () => {
58 | const rowingEngine = createRowingEngine(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.WRX700))
59 | const workoutEvaluator = createWorkoutEvaluator()
60 | rowingEngine.notify(workoutEvaluator)
61 | await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv' })
62 | assert.is(workoutEvaluator.getNumOfStrokes(), 16, 'number of strokes does not meet expectation')
63 | assertPowerRange(workoutEvaluator, 50, 220)
64 | assertDistanceRange(workoutEvaluator, 165, 168)
65 | assertStrokeDistanceSumMatchesTotal(workoutEvaluator)
66 | })
67 |
68 | test('sample data for DKNR320 should produce plausible results with rower profile', async () => {
69 | const rowingEngine = createRowingEngine(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKNR320))
70 | const workoutEvaluator = createWorkoutEvaluator()
71 | rowingEngine.notify(workoutEvaluator)
72 | await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/DKNR320.csv' })
73 | assert.is(workoutEvaluator.getNumOfStrokes(), 10, 'number of strokes does not meet expectation')
74 | assertPowerRange(workoutEvaluator, 75, 200)
75 | assertDistanceRange(workoutEvaluator, 71, 73)
76 | assertStrokeDistanceSumMatchesTotal(workoutEvaluator)
77 | })
78 |
79 | test('sample data for RX800 should produce plausible results with rower profile', async () => {
80 | const rowingEngine = createRowingEngine(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.RX800))
81 | const workoutEvaluator = createWorkoutEvaluator()
82 | rowingEngine.notify(workoutEvaluator)
83 | await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/RX800.csv' })
84 | assert.is(workoutEvaluator.getNumOfStrokes(), 10, 'number of strokes does not meet expectation')
85 | assertPowerRange(workoutEvaluator, 80, 200)
86 | assertDistanceRange(workoutEvaluator, 70, 80)
87 | assertStrokeDistanceSumMatchesTotal(workoutEvaluator)
88 | })
89 |
90 | function assertPowerRange (evaluator, minPower, maxPower) {
91 | assert.ok(evaluator.getMinStrokePower() > minPower, `minimum stroke power should be above ${minPower}w, but is ${evaluator.getMinStrokePower()}w`)
92 | assert.ok(evaluator.getMaxStrokePower() < maxPower, `maximum stroke power should be below ${maxPower}w, but is ${evaluator.getMaxStrokePower()}w`)
93 | }
94 |
95 | function assertDistanceRange (evaluator, minDistance, maxDistance) {
96 | assert.ok(evaluator.getDistanceSum() >= minDistance && evaluator.getDistanceSum() <= maxDistance, `distance should be between ${minDistance}m and ${maxDistance}m, but is ${evaluator.getDistanceSum().toFixed(2)}m`)
97 | }
98 |
99 | function assertStrokeDistanceSumMatchesTotal (evaluator) {
100 | assert.ok(evaluator.getDistanceSum().toFixed(2) === evaluator.getDistanceTotal().toFixed(2), `sum of distance of all strokes is ${evaluator.getDistanceSum().toFixed(2)}m, but total in last stroke is ${evaluator.getDistanceTotal().toFixed(2)}m`)
101 | }
102 |
103 | test.run()
104 |
--------------------------------------------------------------------------------
/app/engine/Timer.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | Stopwatch used to measure multiple time intervals
6 | */
7 | function createTimer () {
8 | const timerMap = new Map()
9 |
10 | function start (key) {
11 | timerMap.set(key, 0.0)
12 | }
13 |
14 | function stop (key) {
15 | timerMap.delete(key)
16 | }
17 |
18 | function getValue (key) {
19 | return timerMap.get(key) || 0.0
20 | }
21 |
22 | function updateTimers (currentDt) {
23 | timerMap.forEach((value, key) => {
24 | timerMap.set(key, value + currentDt)
25 | })
26 | }
27 |
28 | return {
29 | start,
30 | stop,
31 | getValue,
32 | updateTimers
33 | }
34 | }
35 |
36 | export { createTimer }
37 |
--------------------------------------------------------------------------------
/app/engine/WorkoutUploader.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | Handles uploading workout data to different cloud providers
6 | */
7 | import log from 'loglevel'
8 | import EventEmitter from 'events'
9 | import { createStravaAPI } from '../tools/StravaAPI.js'
10 | import config from '../tools/ConfigManager.js'
11 |
12 | function createWorkoutUploader (workoutRecorder) {
13 | const emitter = new EventEmitter()
14 |
15 | let stravaAuthorizationCodeResolver
16 | let requestingClient
17 |
18 | function getStravaAuthorizationCode () {
19 | return new Promise((resolve) => {
20 | emitter.emit('authorizeStrava', { stravaClientId: config.stravaClientId }, requestingClient)
21 | stravaAuthorizationCodeResolver = resolve
22 | })
23 | }
24 |
25 | const stravaAPI = createStravaAPI(getStravaAuthorizationCode)
26 |
27 | function stravaAuthorizationCode (stravaAuthorizationCode) {
28 | if (stravaAuthorizationCodeResolver) {
29 | stravaAuthorizationCodeResolver(stravaAuthorizationCode)
30 | stravaAuthorizationCodeResolver = undefined
31 | }
32 | }
33 |
34 | async function upload (client) {
35 | log.debug('uploading workout to strava...')
36 | try {
37 | requestingClient = client
38 | // todo: we might signal back to the client whether we had success or not
39 | const tcxActivity = await workoutRecorder.activeWorkoutToTcx()
40 | if (tcxActivity !== undefined) {
41 | await stravaAPI.uploadActivityTcx(tcxActivity)
42 | emitter.emit('resetWorkout')
43 | } else {
44 | log.error('can not upload an empty workout to strava')
45 | }
46 | } catch (error) {
47 | log.error('can not upload workout to strava:', error.message)
48 | }
49 | }
50 |
51 | return Object.assign(emitter, {
52 | upload,
53 | stravaAuthorizationCode
54 | })
55 | }
56 |
57 | export { createWorkoutUploader }
58 |
--------------------------------------------------------------------------------
/app/engine/averager/MovingAverager.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | This Averager can calculate the moving average of a continuous flow of data points
6 |
7 | Please note: The array contains flankLength + 1 measured currentDt's, thus flankLength number
8 | of flanks between them.
9 | They are arranged that dataPoints[0] is the youngest, and dataPoints[flankLength] the oldest
10 | */
11 | function createMovingAverager (length, initValue) {
12 | let dataPoints
13 | reset()
14 |
15 | function pushValue (dataPoint) {
16 | // add the new dataPoint to the array, we have to move data points starting at the oldest ones
17 | let i = length - 1
18 | while (i > 0) {
19 | // older data points are moved towards the higher numbers
20 | dataPoints[i] = dataPoints[i - 1]
21 | i = i - 1
22 | }
23 | dataPoints[0] = dataPoint
24 | }
25 |
26 | function replaceLastPushedValue (dataPoint) {
27 | // replace the newest dataPoint in the array, as it was faulty
28 | dataPoints[0] = dataPoint
29 | }
30 |
31 | function getAverage () {
32 | let i = length - 1
33 | let arrayTotal = 0.0
34 | while (i >= 0) {
35 | // summarize the value of the moving average
36 | arrayTotal = arrayTotal + dataPoints[i]
37 | i = i - 1
38 | }
39 | const arrayAverage = arrayTotal / length
40 | return arrayAverage
41 | }
42 |
43 | function reset () {
44 | dataPoints = new Array(length)
45 | dataPoints.fill(initValue)
46 | }
47 |
48 | return {
49 | pushValue,
50 | replaceLastPushedValue,
51 | getAverage,
52 | reset
53 | }
54 | }
55 |
56 | export { createMovingAverager }
57 |
--------------------------------------------------------------------------------
/app/engine/averager/MovingAverager.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 | */
5 | import { test } from 'uvu'
6 | import * as assert from 'uvu/assert'
7 |
8 | import { createMovingAverager } from './MovingAverager.js'
9 |
10 | test('average should be initValue on empty dataset', () => {
11 | const movingAverager = createMovingAverager(10, 5.5)
12 | assert.is(movingAverager.getAverage(), 5.5)
13 | })
14 |
15 | test('an averager of length 1 should return the last added value', () => {
16 | const movingAverager = createMovingAverager(1, 3)
17 | movingAverager.pushValue(9)
18 | assert.is(movingAverager.getAverage(), 9)
19 | })
20 |
21 | test('an averager of length 2 should return average of last 2 added elements', () => {
22 | const movingAverager = createMovingAverager(2, 3)
23 | movingAverager.pushValue(9)
24 | movingAverager.pushValue(4)
25 | assert.is(movingAverager.getAverage(), 6.5)
26 | })
27 |
28 | test('elements outside of range should not be considered', () => {
29 | const movingAverager = createMovingAverager(2, 3)
30 | movingAverager.pushValue(9)
31 | movingAverager.pushValue(4)
32 | movingAverager.pushValue(3)
33 | assert.is(movingAverager.getAverage(), 3.5)
34 | })
35 |
36 | test('replacing the last element should work as expected', () => {
37 | const movingAverager = createMovingAverager(2, 3)
38 | movingAverager.pushValue(9)
39 | movingAverager.pushValue(5)
40 | movingAverager.replaceLastPushedValue(12)
41 | assert.is(movingAverager.getAverage(), 10.5)
42 | })
43 |
44 | test.run()
45 |
--------------------------------------------------------------------------------
/app/engine/averager/MovingIntervalAverager.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | This Averager calculates the average forecast for a moving interval of a continuous flow
6 | of data points for a certain (time) interval
7 | */
8 | function createMovingIntervalAverager (movingDuration) {
9 | let dataPoints
10 | let duration
11 | let sum
12 | reset()
13 |
14 | function pushValue (dataValue, dataDuration) {
15 | // add the new data point to the front of the array
16 | dataPoints.unshift({ value: dataValue, duration: dataDuration })
17 | duration += dataDuration
18 | sum += dataValue
19 | while (duration > movingDuration) {
20 | const removedDataPoint = dataPoints.pop()
21 | duration -= removedDataPoint.duration
22 | sum -= removedDataPoint.value
23 | }
24 | }
25 |
26 | function getAverage () {
27 | if (duration > 0) {
28 | return sum / duration * movingDuration
29 | } else {
30 | return 0
31 | }
32 | }
33 |
34 | function reset () {
35 | dataPoints = []
36 | duration = 0.0
37 | sum = 0.0
38 | }
39 |
40 | return {
41 | pushValue,
42 | getAverage,
43 | reset
44 | }
45 | }
46 |
47 | export { createMovingIntervalAverager }
48 |
--------------------------------------------------------------------------------
/app/engine/averager/MovingIntervalAverager.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 | */
5 | import { test } from 'uvu'
6 | import * as assert from 'uvu/assert'
7 |
8 | import { createMovingIntervalAverager } from './MovingIntervalAverager.js'
9 |
10 | test('average of a data point with duration of averager is equal to datapoint', () => {
11 | const movingAverager = createMovingIntervalAverager(10)
12 | movingAverager.pushValue(5, 10)
13 | assert.is(movingAverager.getAverage(), 5)
14 | })
15 |
16 | test('average of a data point with half duration of averager is double to datapoint', () => {
17 | const movingAverager = createMovingIntervalAverager(20)
18 | movingAverager.pushValue(5, 10)
19 | assert.is(movingAverager.getAverage(), 10)
20 | })
21 |
22 | test('average of two identical data points with half duration of averager is equal to datapoint sum', () => {
23 | const movingAverager = createMovingIntervalAverager(20)
24 | movingAverager.pushValue(5, 10)
25 | movingAverager.pushValue(5, 10)
26 | assert.is(movingAverager.getAverage(), 10)
27 | })
28 |
29 | test('average does not consider data points that are outside of duration', () => {
30 | const movingAverager = createMovingIntervalAverager(20)
31 | movingAverager.pushValue(10, 10)
32 | movingAverager.pushValue(5, 10)
33 | movingAverager.pushValue(5, 10)
34 | assert.is(movingAverager.getAverage(), 10)
35 | })
36 |
37 | test('average works with lots of values', () => {
38 | // one hour
39 | const movingAverager = createMovingIntervalAverager(3000)
40 | for (let i = 0; i < 1000; i++) {
41 | movingAverager.pushValue(10, 1)
42 | }
43 | for (let i = 0; i < 1000; i++) {
44 | movingAverager.pushValue(20, 1)
45 | }
46 | for (let i = 0; i < 1000; i++) {
47 | movingAverager.pushValue(30, 2)
48 | }
49 | assert.is(movingAverager.getAverage(), 50000)
50 | })
51 |
52 | test('average should return 0 on empty dataset', () => {
53 | const movingAverager = createMovingIntervalAverager(10)
54 | assert.is(movingAverager.getAverage(), 0)
55 | })
56 |
57 | test.run()
58 |
--------------------------------------------------------------------------------
/app/engine/averager/WeightedAverager.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | This Averager can calculate the weighted average of a continuous flow of data points
6 | */
7 | function createWeightedAverager (maxNumOfDataPoints) {
8 | let dataPoints = []
9 |
10 | function pushValue (dataPoint) {
11 | // add the new data point to the front of the array
12 | dataPoints.unshift(dataPoint)
13 | // ensure that the array does not get longer than maxNumOfDataPoints
14 | if (dataPoints.length > maxNumOfDataPoints) {
15 | dataPoints.pop()
16 | }
17 | }
18 |
19 | function getAverage () {
20 | const numOfDataPoints = dataPoints.length
21 | if (numOfDataPoints > 0) {
22 | const sum = dataPoints
23 | .map((dataPoint, index) => Math.pow(2, numOfDataPoints - index - 1) * dataPoint)
24 | .reduce((acc, dataPoint) => acc + dataPoint, 0)
25 | const weight = Math.pow(2, numOfDataPoints) - 1
26 | return sum / weight
27 | } else {
28 | return 0
29 | }
30 | }
31 |
32 | function reset () {
33 | dataPoints = []
34 | }
35 |
36 | return {
37 | pushValue,
38 | getAverage,
39 | reset
40 | }
41 | }
42 |
43 | export { createWeightedAverager }
44 |
--------------------------------------------------------------------------------
/app/engine/averager/WeightedAverager.test.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 | */
5 | import { test } from 'uvu'
6 | import * as assert from 'uvu/assert'
7 |
8 | import { createWeightedAverager } from './WeightedAverager.js'
9 |
10 | test('average should be 0 on empty dataset', () => {
11 | const weightedAverager = createWeightedAverager(10)
12 | assert.is(weightedAverager.getAverage(), 0)
13 | })
14 |
15 | test('average of one value is value', () => {
16 | const weightedAverager = createWeightedAverager(10)
17 | weightedAverager.pushValue(13.78)
18 | assert.is(weightedAverager.getAverage(), 13.78)
19 | })
20 |
21 | test('average of a and b is (2*b + a) / 3', () => {
22 | const weightedAverager = createWeightedAverager(10)
23 | weightedAverager.pushValue(5) // a
24 | weightedAverager.pushValue(2) // b
25 | assert.is(weightedAverager.getAverage(), 3)
26 | })
27 |
28 | test('average should be 0 after reset', () => {
29 | const weightedAverager = createWeightedAverager(10)
30 | weightedAverager.pushValue(5)
31 | weightedAverager.pushValue(2)
32 | weightedAverager.reset()
33 | assert.is(weightedAverager.getAverage(), 0)
34 | })
35 |
36 | test('average should be a after pushing a after a reset', () => {
37 | const weightedAverager = createWeightedAverager(10)
38 | weightedAverager.pushValue(5)
39 | weightedAverager.pushValue(2)
40 | weightedAverager.reset()
41 | weightedAverager.pushValue(7)
42 | assert.is(weightedAverager.getAverage(), 7)
43 | })
44 |
45 | test.run()
46 |
--------------------------------------------------------------------------------
/app/gpio/GpioTimerService.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | Measures the time between impulses on the GPIO pin. Started in a
6 | separate thread, since we want the measured time to be as close as
7 | possible to real time.
8 | */
9 | import process from 'process'
10 | import { Gpio } from 'onoff'
11 | import os from 'os'
12 | import config from '../tools/ConfigManager.js'
13 | import log from 'loglevel'
14 |
15 | log.setLevel(config.loglevel.default)
16 |
17 | export function createGpioTimerService () {
18 | if (Gpio.accessible) {
19 | if (config.gpioHighPriority) {
20 | // setting top (near-real-time) priority for the Gpio process, as we don't want to miss anything
21 | log.debug('setting priority for the Gpio-service to maximum (-20)')
22 | try {
23 | // setting priority of current process
24 | os.setPriority(-20)
25 | } catch (err) {
26 | log.debug('need root permission to set priority of Gpio-Thread')
27 | }
28 | }
29 |
30 | // read the sensor data from one of the Gpio pins of Raspberry Pi
31 | const sensor = new Gpio(config.gpioPin, 'in', 'rising')
32 | // use hrtime for time measurement to get a higher time precision
33 | let hrStartTime = process.hrtime()
34 |
35 | // assumes that GPIO-Port 17 is set to pullup and reed is connected to GND
36 | // therefore the value is 1 if the reed sensor is open
37 | sensor.watch((err, value) => {
38 | if (err) {
39 | throw err
40 | }
41 | const hrDelta = process.hrtime(hrStartTime)
42 | hrStartTime = process.hrtime()
43 | const delta = hrDelta[0] + hrDelta[1] / 1e9
44 | process.send(delta)
45 | })
46 | } else {
47 | log.info('reading from Gpio is not (yet) supported on this platform')
48 | }
49 | }
50 |
51 | createGpioTimerService()
52 |
--------------------------------------------------------------------------------
/app/server.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | This start file is currently a mess, as this currently is the devlopment playground to plug
6 | everything together while figuring out the physics and model of the application.
7 | todo: refactor this as we progress
8 | */
9 | import child_process from 'child_process'
10 | import { promisify } from 'util'
11 | import log from 'loglevel'
12 | import config from './tools/ConfigManager.js'
13 | import { createRowingEngine } from './engine/RowingEngine.js'
14 | import { createRowingStatistics } from './engine/RowingStatistics.js'
15 | import { createWebServer } from './WebServer.js'
16 | import { createPeripheralManager } from './ble/PeripheralManager.js'
17 | import { createAntManager } from './ant/AntManager.js'
18 | // eslint-disable-next-line no-unused-vars
19 | import { replayRowingSession } from './tools/RowingRecorder.js'
20 | import { createWorkoutRecorder } from './engine/WorkoutRecorder.js'
21 | import { createWorkoutUploader } from './engine/WorkoutUploader.js'
22 | const exec = promisify(child_process.exec)
23 |
24 | // set the log levels
25 | log.setLevel(config.loglevel.default)
26 | for (const [loggerName, logLevel] of Object.entries(config.loglevel)) {
27 | if (loggerName !== 'default') {
28 | log.getLogger(loggerName).setLevel(logLevel)
29 | }
30 | }
31 |
32 | log.info(`==== Open Rowing Monitor ${process.env.npm_package_version || ''} ====\n`)
33 |
34 | const peripheralManager = createPeripheralManager()
35 |
36 | peripheralManager.on('control', (event) => {
37 | if (event?.req?.name === 'requestControl') {
38 | event.res = true
39 | } else if (event?.req?.name === 'reset') {
40 | log.debug('reset requested')
41 | resetWorkout()
42 | event.res = true
43 | // todo: we could use these controls once we implement a concept of a rowing session
44 | } else if (event?.req?.name === 'stop') {
45 | log.debug('stop requested')
46 | peripheralManager.notifyStatus({ name: 'stoppedOrPausedByUser' })
47 | event.res = true
48 | } else if (event?.req?.name === 'pause') {
49 | log.debug('pause requested')
50 | peripheralManager.notifyStatus({ name: 'stoppedOrPausedByUser' })
51 | event.res = true
52 | } else if (event?.req?.name === 'startOrResume') {
53 | log.debug('startOrResume requested')
54 | peripheralManager.notifyStatus({ name: 'startedOrResumedByUser' })
55 | event.res = true
56 | } else if (event?.req?.name === 'peripheralMode') {
57 | webServer.notifyClients('config', getConfig())
58 | event.res = true
59 | } else {
60 | log.info('unhandled Command', event.req)
61 | }
62 | })
63 |
64 | function resetWorkout () {
65 | workoutRecorder.reset()
66 | rowingEngine.reset()
67 | rowingStatistics.reset()
68 | peripheralManager.notifyStatus({ name: 'reset' })
69 | }
70 |
71 | const gpioTimerService = child_process.fork('./app/gpio/GpioTimerService.js')
72 | gpioTimerService.on('message', handleRotationImpulse)
73 |
74 | function handleRotationImpulse (dataPoint) {
75 | workoutRecorder.recordRotationImpulse(dataPoint)
76 | rowingEngine.handleRotationImpulse(dataPoint)
77 | }
78 |
79 | const rowingEngine = createRowingEngine(config.rowerSettings)
80 | const rowingStatistics = createRowingStatistics(config)
81 | rowingEngine.notify(rowingStatistics)
82 | const workoutRecorder = createWorkoutRecorder()
83 | const workoutUploader = createWorkoutUploader(workoutRecorder)
84 |
85 | rowingStatistics.on('driveFinished', (metrics) => {
86 | webServer.notifyClients('metrics', metrics)
87 | peripheralManager.notifyMetrics('strokeStateChanged', metrics)
88 | })
89 |
90 | rowingStatistics.on('recoveryFinished', (metrics) => {
91 | log.info(`stroke: ${metrics.strokesTotal}, dur: ${metrics.strokeTime.toFixed(2)}s, power: ${Math.round(metrics.power)}w` +
92 | `, split: ${metrics.splitFormatted}, ratio: ${metrics.powerRatio.toFixed(2)}, dist: ${metrics.distanceTotal.toFixed(1)}m` +
93 | `, cal: ${metrics.caloriesTotal.toFixed(1)}kcal, SPM: ${metrics.strokesPerMinute.toFixed(1)}, speed: ${metrics.speed.toFixed(2)}km/h` +
94 | `, cal/hour: ${metrics.caloriesPerHour.toFixed(1)}kcal, cal/minute: ${metrics.caloriesPerMinute.toFixed(1)}kcal`)
95 | webServer.notifyClients('metrics', metrics)
96 | peripheralManager.notifyMetrics('strokeFinished', metrics)
97 | if (metrics.sessionState === 'rowing') {
98 | workoutRecorder.recordStroke(metrics)
99 | }
100 | })
101 |
102 | rowingStatistics.on('webMetricsUpdate', (metrics) => {
103 | webServer.notifyClients('metrics', metrics)
104 | })
105 |
106 | rowingStatistics.on('peripheralMetricsUpdate', (metrics) => {
107 | peripheralManager.notifyMetrics('metricsUpdate', metrics)
108 | })
109 |
110 | rowingStatistics.on('rowingPaused', () => {
111 | workoutRecorder.handlePause()
112 | })
113 |
114 | if (config.heartrateMonitorBLE) {
115 | const bleCentralService = child_process.fork('./app/ble/CentralService.js')
116 | bleCentralService.on('message', (heartrateMeasurement) => {
117 | rowingStatistics.handleHeartrateMeasurement(heartrateMeasurement)
118 | })
119 | }
120 |
121 | if (config.heartrateMonitorANT) {
122 | const antManager = createAntManager()
123 | antManager.on('heartrateMeasurement', (heartrateMeasurement) => {
124 | rowingStatistics.handleHeartrateMeasurement(heartrateMeasurement)
125 | })
126 | }
127 |
128 | workoutUploader.on('authorizeStrava', (data, client) => {
129 | webServer.notifyClient(client, 'authorizeStrava', data)
130 | })
131 |
132 | workoutUploader.on('resetWorkout', () => {
133 | resetWorkout()
134 | })
135 |
136 | const webServer = createWebServer()
137 | webServer.on('messageReceived', async (message, client) => {
138 | switch (message.command) {
139 | case 'switchPeripheralMode': {
140 | peripheralManager.switchPeripheralMode()
141 | break
142 | }
143 | case 'reset': {
144 | resetWorkout()
145 | break
146 | }
147 | case 'uploadTraining': {
148 | workoutUploader.upload(client)
149 | break
150 | }
151 | case 'shutdown': {
152 | if (getConfig().shutdownEnabled) {
153 | console.info('shutting down device...')
154 | try {
155 | const { stdout, stderr } = await exec(config.shutdownCommand)
156 | if (stderr) {
157 | log.error('can not shutdown: ', stderr)
158 | }
159 | log.info(stdout)
160 | } catch (error) {
161 | log.error('can not shutdown: ', error)
162 | }
163 | }
164 | break
165 | }
166 | case 'stravaAuthorizationCode': {
167 | workoutUploader.stravaAuthorizationCode(message.data)
168 | break
169 | }
170 | default: {
171 | log.warn('invalid command received:', message)
172 | }
173 | }
174 | })
175 |
176 | webServer.on('clientConnected', (client) => {
177 | webServer.notifyClient(client, 'config', getConfig())
178 | })
179 |
180 | // todo: extract this into some kind of state manager
181 | function getConfig () {
182 | return {
183 | peripheralMode: peripheralManager.getPeripheralMode(),
184 | stravaUploadEnabled: !!config.stravaClientId && !!config.stravaClientSecret,
185 | shutdownEnabled: !!config.shutdownCommand
186 | }
187 | }
188 |
189 | /*
190 | replayRowingSession(handleRotationImpulse, {
191 | filename: 'recordings/WRX700_2magnets.csv',
192 | realtime: true,
193 | loop: true
194 | })
195 | */
196 |
--------------------------------------------------------------------------------
/app/tools/AuthorizedStravaConnection.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | Creates an OAuth authorized connection to Strava (https://developers.strava.com/)
6 | */
7 | import log from 'loglevel'
8 | import axios from 'axios'
9 | import FormData from 'form-data'
10 | import config from './ConfigManager.js'
11 | import fs from 'fs/promises'
12 |
13 | const clientId = config.stravaClientId
14 | const clientSecret = config.stravaClientSecret
15 | const stravaTokenFile = './config/stravatoken'
16 |
17 | function createAuthorizedConnection (getStravaAuthorizationCode) {
18 | let accessToken
19 | let refreshToken
20 |
21 | const authorizedConnection = axios.create({
22 | baseURL: 'https://www.strava.com/api/v3'
23 | })
24 |
25 | authorizedConnection.interceptors.request.use(async config => {
26 | if (!refreshToken) {
27 | try {
28 | refreshToken = await fs.readFile(stravaTokenFile, 'utf-8')
29 | } catch (error) {
30 | log.info('no strava token available yet')
31 | }
32 | }
33 | // if no refresh token is set, then the app has not yet been authorized with Strava
34 | // start oAuth authorization process
35 | if (!refreshToken) {
36 | const authorizationCode = await getStravaAuthorizationCode();
37 | ({ accessToken, refreshToken } = await authorize(authorizationCode))
38 | await writeToken('', refreshToken)
39 | // otherwise we just need to get a valid accessToken
40 | } else {
41 | const oldRefreshToken = refreshToken;
42 | ({ accessToken, refreshToken } = await getAccessTokens(refreshToken))
43 | if (!refreshToken) {
44 | log.error(`strava token is invalid, deleting ${stravaTokenFile}...`)
45 | await fs.unlink(stravaTokenFile)
46 | // if the refreshToken has changed, persist it
47 | } else {
48 | await writeToken(oldRefreshToken, refreshToken)
49 | }
50 | }
51 |
52 | if (!accessToken) {
53 | log.error('strava authorization not successful')
54 | }
55 |
56 | Object.assign(config.headers, { Authorization: `Bearer ${accessToken}` })
57 | if (config.data instanceof FormData) {
58 | Object.assign(config.headers, config.data.getHeaders())
59 | }
60 | return config
61 | })
62 |
63 | authorizedConnection.interceptors.response.use(function (response) {
64 | return response
65 | }, function (error) {
66 | if (error?.response?.status === 401 || error?.message === 'canceled') {
67 | return Promise.reject(new Error('user unauthorized'))
68 | } else {
69 | return Promise.reject(error)
70 | }
71 | })
72 |
73 | async function oAuthTokenRequest (token, grantType) {
74 | let responsePayload
75 | const payload = {
76 | client_id: clientId,
77 | client_secret: clientSecret,
78 | grant_type: grantType
79 | }
80 | if (grantType === 'authorization_code') {
81 | payload.code = token
82 | } else {
83 | payload.refresh_token = token
84 | }
85 |
86 | try {
87 | const response = await axios.post('https://www.strava.com/oauth/token', payload)
88 | if (response?.status === 200) {
89 | responsePayload = response.data
90 | } else {
91 | log.error(`response error at strava oAuth request for ${grantType}: ${response?.data?.message || response}`)
92 | }
93 | } catch (e) {
94 | log.error(`general error at strava oAuth request for ${grantType}: ${e?.response?.data?.message || e}`)
95 | }
96 | return responsePayload
97 | }
98 |
99 | async function authorize (authorizationCode) {
100 | const response = await oAuthTokenRequest(authorizationCode, 'authorization_code')
101 | return {
102 | refreshToken: response?.refresh_token,
103 | accessToken: response?.access_token
104 | }
105 | }
106 |
107 | async function getAccessTokens (refreshToken) {
108 | const response = await oAuthTokenRequest(refreshToken, 'refresh_token')
109 | return {
110 | refreshToken: response?.refresh_token,
111 | accessToken: response?.access_token
112 | }
113 | }
114 |
115 | async function writeToken (oldToken, newToken) {
116 | if (oldToken !== newToken) {
117 | try {
118 | await fs.writeFile(stravaTokenFile, newToken, 'utf-8')
119 | } catch (error) {
120 | log.info(`can not write strava token to file ${stravaTokenFile}`, error)
121 | }
122 | }
123 | }
124 |
125 | return authorizedConnection
126 | }
127 |
128 | export {
129 | createAuthorizedConnection
130 | }
131 |
--------------------------------------------------------------------------------
/app/tools/ConfigManager.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | Merges the different config files and presents the configuration to the application
6 | */
7 | import defaultConfig from '../../config/default.config.js'
8 | import { deepMerge } from './Helper.js'
9 |
10 | async function getConfig () {
11 | let customConfig
12 | try {
13 | customConfig = await import('../../config/config.js')
14 | } catch (exception) {}
15 |
16 | return customConfig !== undefined ? deepMerge(defaultConfig, customConfig.default) : defaultConfig
17 | }
18 |
19 | const config = await getConfig()
20 |
21 | export default config
22 |
--------------------------------------------------------------------------------
/app/tools/Helper.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | Helper functions
6 | */
7 |
8 | // deeply merges any number of objects into a new object
9 | export function deepMerge (...objects) {
10 | const isObject = obj => obj && typeof obj === 'object'
11 |
12 | return objects.reduce((prev, obj) => {
13 | Object.keys(obj).forEach(key => {
14 | const pVal = prev[key]
15 | const oVal = obj[key]
16 |
17 | if (Array.isArray(pVal) && Array.isArray(oVal)) {
18 | prev[key] = pVal.concat(...oVal)
19 | } else if (isObject(pVal) && isObject(oVal)) {
20 | prev[key] = deepMerge(pVal, oVal)
21 | } else {
22 | prev[key] = oVal
23 | }
24 | })
25 |
26 | return prev
27 | }, {})
28 | }
29 |
--------------------------------------------------------------------------------
/app/tools/RowingRecorder.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | A utility to record and replay flywheel measurements for development purposes.
6 | */
7 | import { fork } from 'child_process'
8 | import fs from 'fs'
9 | import readline from 'readline'
10 | import log from 'loglevel'
11 |
12 | function recordRowingSession (filename) {
13 | // measure the gpio interrupts in another process, since we need
14 | // to track time close to realtime
15 | const gpioTimerService = fork('./app/gpio/GpioTimerService.js')
16 | gpioTimerService.on('message', (dataPoint) => {
17 | log.debug(dataPoint)
18 | fs.appendFile(filename, `${dataPoint}\n`, (err) => { if (err) log.error(err) })
19 | })
20 | }
21 |
22 | async function replayRowingSession (rotationImpulseHandler, options) {
23 | if (!options?.filename) {
24 | log.error('can not replay rowing session without filename')
25 | return
26 | }
27 |
28 | do {
29 | await replayRowingFile(rotationImpulseHandler, options)
30 | // infinite looping only available when using realtime
31 | } while (options.loop && options.realtime)
32 | }
33 |
34 | async function replayRowingFile (rotationImpulseHandler, options) {
35 | const fileStream = fs.createReadStream(options.filename)
36 | const readLine = readline.createInterface({
37 | input: fileStream,
38 | crlfDelay: Infinity
39 | })
40 |
41 | for await (const line of readLine) {
42 | const dt = parseFloat(line)
43 | // if we want to replay in the original time, wait dt seconds
44 | if (options.realtime) await wait(dt * 1000)
45 | rotationImpulseHandler(dt)
46 | }
47 | }
48 |
49 | async function wait (ms) {
50 | return new Promise(resolve => {
51 | setTimeout(resolve, ms)
52 | })
53 | }
54 |
55 | export {
56 | recordRowingSession,
57 | replayRowingSession
58 | }
59 |
--------------------------------------------------------------------------------
/app/tools/StravaAPI.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | Implements required parts of the Strava API (https://developers.strava.com/)
6 | */
7 | import zlib from 'zlib'
8 | import FormData from 'form-data'
9 | import { promisify } from 'util'
10 | import { createAuthorizedConnection } from './AuthorizedStravaConnection.js'
11 | const gzip = promisify(zlib.gzip)
12 |
13 | function createStravaAPI (getStravaAuthorizationCode) {
14 | const authorizedStravaConnection = createAuthorizedConnection(getStravaAuthorizationCode)
15 |
16 | async function uploadActivityTcx (tcxRecord) {
17 | const form = new FormData()
18 |
19 | form.append('file', await gzip(tcxRecord.tcx), tcxRecord.filename)
20 | form.append('data_type', 'tcx.gz')
21 | form.append('name', 'Indoor Rowing Session')
22 | form.append('description', 'Uploaded from Open Rowing Monitor')
23 | form.append('trainer', 'true')
24 | form.append('activity_type', 'Rowing')
25 |
26 | return await authorizedStravaConnection.post('/uploads', form)
27 | }
28 |
29 | async function getAthlete () {
30 | return (await authorizedStravaConnection.get('/athlete')).data
31 | }
32 |
33 | return {
34 | uploadActivityTcx,
35 | getAthlete
36 | }
37 | }
38 | export {
39 | createStravaAPI
40 | }
41 |
--------------------------------------------------------------------------------
/babel.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "targets": {
7 | "esmodules": true
8 | },
9 | "shippedProposals": true,
10 | "bugfixes": true
11 | }
12 | ]
13 | ],
14 | "plugins": [
15 | ["@babel/plugin-proposal-decorators", { "decoratorsBeforeExport": true }]
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/bin/openrowingmonitor.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 | #
5 | # Start script for Open Rowing Monitor
6 | #
7 |
8 | # treat unset variables as an error when substituting
9 | set -u
10 | # exit when a command fails
11 | set -e
12 |
13 | print() {
14 | echo "$@"
15 | }
16 |
17 | CURRENT_DIR=$(pwd)
18 | SCRIPT_DIR="$( cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd )"
19 | INSTALL_DIR="$(dirname "$SCRIPT_DIR")"
20 |
21 | cd $INSTALL_DIR
22 | npm start
23 | cd $CURRENT_DIR
24 |
--------------------------------------------------------------------------------
/bin/updateopenrowingmonitor.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 | #
5 | # Update script for Open Rowing Monitor, use at your own risk!
6 | #
7 |
8 | # treat unset variables as an error when substituting
9 | set -u
10 | # exit when a command fails
11 | set -e
12 |
13 | print() {
14 | echo "$@"
15 | }
16 |
17 | cancel() {
18 | print "$@"
19 | exit 1
20 | }
21 |
22 | ask() {
23 | local prompt default reply
24 |
25 | if [[ ${2:-} = 'Y' ]]; then
26 | prompt='Y/n'
27 | default='Y'
28 | elif [[ ${2:-} = 'N' ]]; then
29 | prompt='y/N'
30 | default='N'
31 | else
32 | prompt='y/n'
33 | default=''
34 | fi
35 |
36 | while true; do
37 | echo -n "$1 [$prompt] "
38 | read -r reply /dev/null || true
82 | sudo git checkout -b $CURRENT_BRANCH origin/$CURRENT_BRANCH
83 |
84 | print "Updating Runtime dependencies..."
85 | sudo rm -rf node_modules
86 | sudo npm install
87 | sudo npm run build
88 |
89 | print "Starting Open Rowing Monitor..."
90 | sudo systemctl start openrowingmonitor
91 |
92 | print
93 | print "Switch to branch \"$CURRENT_BRANCH\" complete, Open Rowing Monitor now has the following exciting new features:"
94 | git log --reverse --pretty=format:"- %s" $LOCAL_VERSION..HEAD
95 | }
96 |
97 | CURRENT_DIR=$(pwd)
98 | SCRIPT_DIR="$( cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd )"
99 | INSTALL_DIR="$(dirname "$SCRIPT_DIR")"
100 | GIT_REMOTE="https://github.com/laberning/openrowingmonitor.git"
101 |
102 | cd $INSTALL_DIR
103 |
104 | CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
105 | LOCAL_VERSION=$(git rev-parse HEAD)
106 |
107 | print "Update script for Open Rowing Monitor"
108 | print
109 |
110 | if getopts "b:" arg; then
111 | if [ $CURRENT_BRANCH = $OPTARG ]; then
112 | cancel "No need to switch to branch \"$OPTARG\", it is already the active branch"
113 | fi
114 |
115 | echo "Checking for the existence of branch \"$OPTARG\"..."
116 | if [ $(git ls-remote --heads $GIT_REMOTE 2>/dev/null|awk -F 'refs/heads/' '{print $2}'|grep -x "$OPTARG"|wc -l) = 0 ]; then
117 | cancel "Branch \"$OPTARG\" does not exist in the repository, can not switch"
118 | fi
119 |
120 | if ask "Do you want to switch from branch \"$CURRENT_BRANCH\" to branch \"$OPTARG\"?" Y; then
121 | print "Switching to branch \"$OPTARG\"..."
122 | CURRENT_BRANCH=$OPTARG
123 | switch_branch
124 | else
125 | cancel "Stopping update - please run without -b parameter to do a regular update"
126 | fi
127 | else
128 | print "Checking for new version..."
129 | REMOTE_VERSION=$(git ls-remote $GIT_REMOTE refs/heads/$CURRENT_BRANCH | awk '{print $1;}')
130 |
131 | if [ "$LOCAL_VERSION" = "$REMOTE_VERSION" ]; then
132 | print "You are using the latest version of Open Rowing Monitor from branch \"$CURRENT_BRANCH\"."
133 | else
134 | if ask "A new version of Open Rowing Monitor is available from branch \"$CURRENT_BRANCH\". Do you want to update?" Y; then
135 | update_branch
136 | fi
137 | fi
138 | fi
139 |
140 | cd $CURRENT_DIR
141 |
--------------------------------------------------------------------------------
/config/default.config.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /*
3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
4 |
5 | This file contains the default configuration of the Open Rowing Monitor.
6 |
7 | !!! Note that changes to this file will be OVERWRITTEN when you update to a new version
8 | of Open Rowing Monitor. !!!
9 |
10 | To change the settings you should modify the 'config/config.js' file. Simply copy the
11 | options that you would like to change into that file. If 'config.js' does not exist, you
12 | can use the example file from the 'install' folder.
13 | */
14 | import rowerProfiles from './rowerProfiles.js'
15 |
16 | export default {
17 | // Available log levels: trace, debug, info, warn, error, silent
18 | loglevel: {
19 | // The default log level
20 | default: 'info',
21 | // The log level of of the rowing engine (stroke detection and physics model)
22 | RowingEngine: 'warn'
23 | },
24 |
25 | // Defines the GPIO Pin that is used to read the sensor data from the rowing machine
26 | // see: https://www.raspberrypi.org/documentation/usage/gpio for the pin layout of the device
27 | // If you want to use the internal pull-up resistor of the Raspberry Pi you should
28 | // also configure the pin for that in /boot/config.txt, i.e. 'gpio=17=pu,ip'
29 | // see: https://www.raspberrypi.org/documentation/configuration/config-txt/gpio.md
30 | gpioPin: 17,
31 |
32 | // Experimental setting: enable this to boost the system level priority of the thread that
33 | // measures the rotation speed of the flywheel. This might improve the precision of the
34 | // measurements (especially on rowers with a fast spinning flywheel)
35 | gpioHighPriority: false,
36 |
37 | // Selects the Bluetooth Low Energy Profile
38 | // Supported modes: FTMS, FTMSBIKE, PM5
39 | bluetoothMode: 'FTMS',
40 |
41 | // Turn this on if you want support for Bluetooth Low Energy heart rate monitors
42 | // Will currenty connect to the first device found
43 | heartrateMonitorBLE: true,
44 |
45 | // Turn this on if you want support for ANT+ heart rate monitors
46 | // You will need an ANT+ USB stick for this to work, the following models might work:
47 | // - Garmin USB or USB2 ANT+ or an off-brand clone of it (ID 0x1008)
48 | // - Garmin mini ANT+ (ID 0x1009)
49 | heartrateMonitorANT: false,
50 |
51 | // The directory in which to store user specific content
52 | // currently this directory holds the recorded training sessions
53 | dataDirectory: 'data',
54 |
55 | // Stores the training sessions as TCX files
56 | createTcxFiles: true,
57 |
58 | // Stores the raw sensor data in CSV files
59 | createRawDataFiles: false,
60 |
61 | // Apply gzip compression to the recorded tcx training sessions file (tcx.gz)
62 | // This will drastically reduce the file size of the files (only around 4% of the original file)
63 | // Some training tools can directly work with gzipped tcx file, however for most training websites
64 | // you will have to unzip the files before uploading
65 | gzipTcxFiles: false,
66 |
67 | // Apply gzip compression to the ras sensor data recording files (csv.gz)
68 | gzipRawDataFiles: true,
69 |
70 | // Defines the name that is used to announce the FTMS Rower via Bluetooth Low Energy (BLE)
71 | // Some rowing training applications expect that the rowing device is announced with a certain name
72 | ftmsRowerPeripheralName: 'OpenRowingMonitor',
73 |
74 | // Defines the name that is used to announce the FTMS Bike via Bluetooth Low Energy (BLE)
75 | // Most bike training applications are fine with any device name
76 | ftmsBikePeripheralName: 'OpenRowingBike',
77 |
78 | // The interval for updating all web clients (i.e. the monitor) in ms.
79 | // Advised is to update at least once per second, to make sure the timer moves nice and smoothly.
80 | // Around 100 ms results in a very smooth update experience
81 | // Please note that a smaller value will use more network and cpu ressources
82 | webUpdateInterval: 1000,
83 |
84 | // The number of stroke phases (i.e. Drive or Recovery) used to smoothen the data displayed on your
85 | // screens (i.e. the monitor, but also bluetooth devices, etc.). A nice smooth experience is found at 6
86 | // phases, a much more volatile (but more accurate and responsive) is found around 3. The minimum is 1,
87 | // but for recreational rowers that might feel much too restless to be useful
88 | numOfPhasesForAveragingScreenData: 6,
89 |
90 | // The time between strokes in seconds before the rower considers it a pause. Default value is set to 10.
91 | // It is not recommended to go below this value, as not recognizing a stroke could result in a pause
92 | // (as a typical stroke is between 2 to 3 seconds for recreational rowers). Increase it when you have
93 | // issues with your stroke detection and the rower is pausing unexpectedly
94 | maximumStrokeTime: 10,
95 |
96 | // The rower specific settings. Either choose a profile from config/rowerProfiles.js or
97 | // define the settings individually. If you find good settings for a new rowing device
98 | // please send them to us (together with a raw recording of 10 strokes) so we can add
99 | // the device to the profiles.
100 | // !! Only change this setting in the config/config.js file, and leave this on DEFAULT as that
101 | // is the fallback for the default profile settings
102 | rowerSettings: rowerProfiles.DEFAULT,
103 |
104 | // command to shutdown the device via the user interface, leave empty to disable this feature
105 | shutdownCommand: 'halt',
106 |
107 | // Configures the connection to Strava (to directly upload workouts to Strava)
108 | // Note that these values are not your Strava credentials
109 | // Instead you have to create a Strava API Application as described here:
110 | // https://developers.strava.com/docs/getting-started/#account and use the corresponding values
111 | // When creating your Strava API application, set the "Authorization Callback Domain" to the IP address
112 | // of your Raspberry Pi
113 | // WARNING: if you enabled the network share via the installer script, then this config file will be
114 | // exposed via network share on your local network. You might consider disabling (or password protect)
115 | // the Configuration share in smb.conf
116 | // The "Client ID" of your Strava API Application
117 | stravaClientId: '',
118 |
119 | // The "Client Secret" of your Strava API Application
120 | stravaClientSecret: ''
121 | }
122 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | _site
2 | .sass-cache
3 | .jekyll-metadata
4 | Gemfile
5 | Gemfile.lock
6 |
--------------------------------------------------------------------------------
/docs/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8 |
9 | ## Our Standards
10 |
11 | Examples of behavior that contributes to a positive environment for our community include:
12 |
13 | * Demonstrating empathy and kindness toward other people
14 | * Being respectful of differing opinions, viewpoints, and experiences
15 | * Giving and gracefully accepting constructive feedback
16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
17 | * Focusing on what is best not just for us as individuals, but for the overall community
18 |
19 | Examples of unacceptable behavior include:
20 |
21 | * The use of sexualized language or imagery, and sexual attention or advances of any kind
22 | * Trolling, insulting or derogatory comments, and personal or political attacks
23 | * Public or private harassment
24 | * Publishing others' private information, such as a physical or email address, without their explicit permission
25 | * Other conduct which could reasonably be considered inappropriate in a professional setting
26 |
27 | ## Enforcement Responsibilities
28 |
29 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
30 |
31 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
32 |
33 | ## Scope
34 |
35 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
36 |
37 | ## Enforcement
38 |
39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leader. All complaints will be reviewed and investigated promptly and fairly.
40 |
41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident.
42 |
43 | ## Enforcement Guidelines
44 |
45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
46 |
47 | ### 1. Correction
48 |
49 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
50 |
51 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
52 |
53 | ### 2. Warning
54 |
55 | **Community Impact**: A violation through a single incident or series of actions.
56 |
57 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
58 |
59 | ### 3. Temporary Ban
60 |
61 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
62 |
63 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
64 |
65 | ### 4. Permanent Ban
66 |
67 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
68 |
69 | **Consequence**: A permanent ban from any sort of public interaction within the community.
70 |
71 | ## Attribution
72 |
73 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available [here](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html).
74 |
75 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
76 |
77 | For answers to common questions about this code of conduct, see the [FAQ](https://www.contributor-covenant.org/faq).
78 |
--------------------------------------------------------------------------------
/docs/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing Guidelines to Open Rowing Monitor
2 |
3 | Thank you for considering contributing to Open Rowing Monitor.
4 |
5 | Please read the following sections in order to know how to ask questions and how to work on something. Open Rowing Monitor is a spare time project and by following these guidelines you help me to keep the time for managing this project reasonable.
6 |
7 | ## Code of Conduct
8 |
9 | All contributors are expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md). I want this to be a place where everyone feels comfortable. Please make sure you are welcoming and friendly to others.
10 |
11 | ## How can I contribute?
12 |
13 | Keep an open mind! There are many ways for helpful contributions, like:
14 |
15 | * Writing forum posts
16 | * Helping people on the forum
17 | * Submitting bug reports and feature requests
18 | * Improving the documentation
19 | * Submitting rower profiles / test recordings
20 | * Writing code which can be incorporated into the project itself
21 |
22 | ### Report bugs and submit feature requests
23 |
24 | Look for existing issues and pull requests if the problem or feature has already been reported. If you find an issue or pull request which is still open, add comments to it instead of opening a new one.
25 |
26 | Make sure that you are running the latest version of Open Rowing Monitor before submitting a bug report.
27 |
28 | If you report a bug, please include information that can help to investigate the issue further, such as:
29 |
30 | * Rower Model and Setup
31 | * Model of Raspberry Pi and version of operation system
32 | * Relevant parts of log messages
33 | * If possible, describe a [Minimal, Reproducible Example](https://stackoverflow.com/help/minimal-reproducible-example)
34 |
35 | ### Improving the Documentation
36 |
37 | The documentation is an important part of Open Rowing Monitor. It is essential that it remains simple and accurate. If you have improvements or find errors, feel free to submit changes via Pull Requests or by filing a bug report or feature request.
38 |
39 | ### Contributing to the Code
40 |
41 | Keep in mind that Open Rowing Monitor is a spare time project which I created to improve the performance of my rowing machine and to experiment with some concepts and technologies that I find interesting.
42 |
43 | I intend to keep the code base clean and maintainable by following some standards. I only accept Pull Requests that:
44 |
45 | * Fix bugs for existing functions
46 | * Enhance the API or implementation of an existing function, configuration or documentation
47 |
48 | If you want to contribute new features to the code, please first discuss the change you wish to make via issue, forum, email, or any other method with me before making a change. This will make sure that there is chance of it getting accepted before you spend time working on it.
49 |
50 | #### Standards for Contributions
51 |
52 | * Contributions should be as small as possible, preferably one new feature per contribution
53 | * All code should use the [JavaScript Standard Style](https://standardjs.com), if you don't skip the included `git hooks` you should not need to worry about this
54 | * All code should be thoroughly tested
55 | * If possible there should be automated test for your contribution (see the `*.test.js` files; the project uses `uvu`)
56 |
57 | #### Creating a Pull Request
58 |
59 | Only open a Pull Request when your contribution is ready for a review. I you want to get feedback on a contribution that does not yet match all criteria for a Pull Request you can open a [Draft pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests#draft-pull-requests).
60 |
61 | * Please include a brief summary of the change, mentioning any issues that are fixed (or partially fixed) by this change
62 | * Include relevant motivation and context
63 | * Make sure that the PR only includes your intended changes by carefully reviewing the changes in the diff
64 | * If possible / necessary, add tests and documentation to your contribution
65 | * If possible, [sign your commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits)
66 |
67 | I will review your contribution and respond as quickly as possible. Keep in mind that this is a spare time Open Source project, and it may take me some time to get back to you. Your patience is very much appreciated.
68 |
69 | ## Your First Contribution
70 |
71 | Don't worry if you are new to contributing to an Open Source project. Here are a couple of tutorials that you might want to check out to get up to speed:
72 |
73 | * [How to Contribute to an Open Source Project on GitHub](https://makeapullrequest.com)
74 | * [First Timers Only](https://www.firsttimersonly.com)
75 |
--------------------------------------------------------------------------------
/docs/Rowing_Settings_Analysis_Small.xlsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/docs/Rowing_Settings_Analysis_Small.xlsx
--------------------------------------------------------------------------------
/docs/_config.yml:
--------------------------------------------------------------------------------
1 | # Welcome to Jekyll!
2 | #
3 | # This config file is meant for settings that affect your whole blog, values
4 | # which you are expected to set up once and rarely edit after that. If you find
5 | # yourself editing this file very often, consider using Jekyll's data files
6 | # feature for the data you need to update frequently.
7 | #
8 | # For technical reasons, this file is *NOT* reloaded automatically when you use
9 | # 'bundle exec jekyll serve'. If you change this file, please restart the server process.
10 |
11 | # Site settings
12 | # These are used to personalize your new site. If you look in the HTML files,
13 | # you will see them accessed via {{ site.title }}, {{ site.email }}, and so on.
14 | # You can create any custom variable you would like, and they will be accessible
15 | # in the templates via {{ site.myvariable }}.
16 | title: Open Rowing Monitor
17 | #description: A free performance monitor for rowing machines
18 | # baseurl: "" # the subpath of your site, e.g. /blog
19 | # url: "" # the base hostname & protocol for your site, e.g. http://example.com
20 |
21 | author: Lars Berning
22 | twitter:
23 | username: laberning
24 | card: summary
25 | social:
26 | name: Lars Berning
27 | links:
28 | - https://twitter.com/laberning
29 | - http://www.linkedin.com/in/larsberning
30 | - https://github.com/laberning
31 | defaults:
32 | - scope:
33 | path: ""
34 | values:
35 | image: /img/icon.png
36 |
37 | github_username: laberning
38 | google_site_verification: kp2LqEz4JhvucGcmjdvFJXF0rpXA-asxk2uTTtQDTKA
39 |
40 | # Build settings
41 | markdown: kramdown
42 | theme: jekyll-theme-cayman
43 | plugins:
44 | - jekyll-feed
45 |
46 | navigation:
47 | - title: About
48 | url: /
49 | - title: Installation
50 | url: /installation.html
51 | - title: Physics
52 | url: /physics_openrowingmonitor.html
53 | - title: Backlog
54 | url: /backlog.html
55 |
--------------------------------------------------------------------------------
/docs/_layouts/default.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | {% seo %}
14 |
15 |
16 |
17 |
31 |