Merge pull request #66 from laberning/strava_upload

adds new features Strava upload and Device Shutdown
This commit is contained in:
Lars Berning 2022-02-12 20:50:34 +01:00 committed by GitHub
commit c8c0719ec0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1017 additions and 341 deletions

1
.gitignore vendored
View File

@ -78,4 +78,5 @@ node_modules
tmp/
build/
config/config.js
config/stravatoken
data/

View File

@ -28,14 +28,14 @@ function createWebServer () {
const wss = new WebSocketServer({ server })
wss.on('connection', function connection (ws) {
wss.on('connection', function connection (client) {
log.debug('websocket client connected')
emitter.emit('clientConnected', ws)
ws.on('message', function incoming (data) {
emitter.emit('clientConnected', client)
client.on('message', function incoming (data) {
try {
const message = JSON.parse(data)
if (message) {
emitter.emit('messageReceived', message)
emitter.emit('messageReceived', message, client)
} else {
log.warn(`invalid message received: ${data}`)
}
@ -43,13 +43,24 @@ function createWebServer () {
log.error(err)
}
})
ws.on('close', function () {
client.on('close', function () {
log.debug('websocket client disconnected')
})
})
function notifyClients (message) {
const messageString = JSON.stringify(message)
function notifyClient (client, type, data) {
const messageString = JSON.stringify({ type, data })
if (wss.clients.has(client)) {
if (client.readyState === WebSocket.OPEN) {
client.send(messageString)
}
} else {
log.error('trying to send message to a client that does not exist')
}
}
function notifyClients (type, data) {
const messageString = JSON.stringify({ type, data })
wss.clients.forEach(function each (client) {
if (client.readyState === WebSocket.OPEN) {
client.send(messageString)
@ -58,6 +69,7 @@ function createWebServer () {
}
return Object.assign(emitter, {
notifyClient,
notifyClients
})
}

View File

@ -0,0 +1,111 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Component that renders a html dialog
*/
import { AppElement, html, css } from './AppElement.js'
import { customElement, property } from 'lit/decorators.js'
import { ref, createRef } from 'lit/directives/ref.js'
@customElement('app-dialog')
export class AppDialog extends AppElement {
constructor () {
super()
this.dialog = createRef()
}
static styles = css`
dialog {
border: none;
color: var(--theme-font-color);
background-color: var(--theme-widget-color);
border-radius: var(--theme-border-radius);
box-shadow: rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 2px 6px 2px;
padding: 1.6rem;
max-width: 80%;
}
dialog::backdrop {
background: none;
backdrop-filter: contrast(15%) blur(2px);
}
button {
outline:none;
background-color: var(--theme-button-color);
border: 0;
border-radius: var(--theme-border-radius);
color: var(--theme-font-color);
margin: 0.2em 0;
font-size: 60%;
text-decoration: none;
display: inline-flex;
width: 4em;
height: 2.5em;
justify-content: center;
align-items: center;
}
button:hover {
filter: brightness(150%);
}
fieldset {
border: 0;
margin: unset;
padding: unset;
margin-block-end: 1em;
}
::slotted(*) { font-size: 80%; }
::slotted(p) { font-size: 55%; }
menu {
display: flex;
gap: 0.5em;
justify-content: flex-end;
margin: 0;
padding: 0;
}
`
@property({ type: Boolean, reflect: true })
dialogOpen
render () {
return html`
<dialog ${ref(this.dialog)} @close=${this.close}>
<form method="dialog">
<fieldset role="document">
<slot></slot>
</fieldset>
<menu>
<button value="cancel">Cancel</button>
<button value="confirm">OK</button>
</menu>
</form>
</dialog>
`
}
close (event) {
if (event.target.returnValue !== 'confirm') {
this.dispatchEvent(new CustomEvent('close', { detail: 'cancel' }))
} else {
this.dispatchEvent(new CustomEvent('close', { detail: 'confirm' }))
}
}
firstUpdated () {
this.dialog.value.showModal()
}
updated (changedProperties) {
if (changedProperties.has('dialogOpen')) {
if (this.dialogOpen) {
this.dialog.value.showModal()
} else {
this.dialog.value.close()
}
}
}
}

View File

@ -6,8 +6,9 @@
*/
import { AppElement, html, css } from './AppElement.js'
import { customElement } from 'lit/decorators.js'
import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth } from '../lib/icons.js'
import { customElement, state } from 'lit/decorators.js'
import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload } from '../lib/icons.js'
import './AppDialog.js'
@customElement('dashboard-actions')
export class DashboardActions extends AppElement {
@ -16,14 +17,23 @@ export class DashboardActions extends AppElement {
outline:none;
background-color: var(--theme-button-color);
border: 0;
border-radius: var(--theme-border-radius);
color: var(--theme-font-color);
padding: 0.5em 0.9em 0.3em 0.9em;
margin: 0.2em 0;
font-size: 60%;
text-align: center;
text-decoration: none;
display: inline-block;
display: inline-flex;
width: 3.5em;
height: 2.5em;
justify-content: center;
align-items: center;
}
button:hover {
filter: brightness(150%);
}
#fullscreen-icon {
display: inline-flex;
}
#windowed-icon {
@ -31,7 +41,7 @@ export class DashboardActions extends AppElement {
}
.icon {
height: 1.8em;
height: 1.7em;
}
.peripheral-mode {
@ -43,18 +53,22 @@ export class DashboardActions extends AppElement {
display: none;
}
#windowed-icon {
display: inline;
display: inline-flex;
}
}
`
@state({ type: Object })
dialog
render () {
return html`
<button @click=${this.reset}>${icon_undo}</button>
${this.renderOptionalButtons()}
<button @click=${this.switchPeripheralMode}>${icon_bluetooth}</button>
<div class="peripheral-mode">${this.peripheralMode()}</div>
`
<button @click=${this.reset}>${icon_undo}</button>
${this.renderOptionalButtons()}
<button @click=${this.switchPeripheralMode}>${icon_bluetooth}</button>
<div class="peripheral-mode">${this.peripheralMode()}</div>
${this.dialog ? this.dialog : ''}
`
}
renderOptionalButtons () {
@ -62,33 +76,41 @@ export class DashboardActions extends AppElement {
// changing to fullscreen mode only makes sence when the app is openend in a regular
// webbrowser (kiosk and standalone mode are always in fullscreen view) and if the
// browser supports this feature
if (this.appState.appMode === 'BROWSER' && document.documentElement.requestFullscreen) {
if (this.appState?.appMode === 'BROWSER' && document.documentElement.requestFullscreen) {
buttons.push(html`
<button @click=${this.toggleFullscreen}>
<div id="fullscreen-icon">${icon_expand}</div>
<div id="windowed-icon">${icon_compress}</div>
</button>
`)
<button @click=${this.toggleFullscreen}>
<div id="fullscreen-icon">${icon_expand}</div>
<div id="windowed-icon">${icon_compress}</div>
</button>
`)
}
// a shutdown button only makes sence when the app is openend as app on a mobile
// device. at some point we might also think of using this to power down the raspi
// when we are running in kiosk mode
if (this.appState.appMode === 'STANDALONE') {
// add a button to power down the device, if browser is running on the device in kiosk mode
// and the shutdown feature is enabled
// (might also make sence to enable this for all clients but then we would need visual feedback)
if (this.appState?.appMode === 'KIOSK' && this.appState?.config?.shutdownEnabled) {
buttons.push(html`
<button @click=${this.close}>${icon_poweroff}</button>
`)
<button @click=${this.shutdown}>${icon_poweroff}</button>
`)
}
if (this.appState?.config?.stravaUploadEnabled) {
buttons.push(html`
<button @click=${this.uploadTraining}>${icon_upload}</button>
`)
}
return buttons
}
peripheralMode () {
const value = this.appState?.peripheralMode
const value = this.appState?.config?.peripheralMode
if (value === 'PM5') {
return 'C2 PM5'
} else if (value === 'FTMSBIKE') {
return 'FTMS Bike'
} else {
} else if (value === 'FTMS') {
return 'FTMS Rower'
} else {
return ''
}
}
@ -103,10 +125,6 @@ export class DashboardActions extends AppElement {
}
}
close () {
window.close()
}
reset () {
this.sendEvent('triggerAction', { command: 'reset' })
}
@ -114,4 +132,34 @@ export class DashboardActions extends AppElement {
switchPeripheralMode () {
this.sendEvent('triggerAction', { command: 'switchPeripheralMode' })
}
uploadTraining () {
this.dialog = html`
<app-dialog @close=${dialogClosed}>
<legend>${icon_upload}<br/>Upload to Strava?</legend>
<p>Do you want to finish your workout and upload it to Strava?</p>
</app-dialog>
`
function dialogClosed (event) {
this.dialog = undefined
if (event.detail === 'confirm') {
this.sendEvent('triggerAction', { command: 'uploadTraining' })
}
}
}
shutdown () {
this.dialog = html`
<app-dialog @close=${dialogClosed}>
<legend>${icon_poweroff}<br/>Shutdown Open Rowing Monitor?</legend>
<p>Do you want to shutdown the device?</p>
</app-dialog>
`
function dialogClosed (event) {
this.dialog = undefined
if (event.detail === 'confirm') {
this.sendEvent('triggerAction', { command: 'shutdown' })
}
}
}
}

View File

@ -37,6 +37,7 @@ export class PerformanceDashboard extends AppElement {
text-align: center;
position: relative;
padding: 0.5em 0.2em 0 0.2em;
border-radius: var(--theme-border-radius);
}
dashboard-actions {

View File

@ -23,6 +23,7 @@
--theme-font-family: Verdana, "Lucida Sans Unicode", sans-serif;
--theme-font-color: #f5f5f5;
--theme-warning-color: #ff0000;
--theme-border-radius: 3px;
}
body {

View File

@ -12,23 +12,40 @@ const rowingMetricsFields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', '
'heartrateBatteryLevel', 'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted']
export function createApp (app) {
const mode = window.location.hash
const appMode = mode === '#:standalone:' ? 'STANDALONE' : mode === '#:kiosk:' ? 'KIOSK' : 'BROWSER'
const urlParameters = new URLSearchParams(window.location.search)
const mode = urlParameters.get('mode')
const appMode = mode === 'standalone' ? 'STANDALONE' : mode === 'kiosk' ? 'KIOSK' : 'BROWSER'
app.updateState({ ...app.getState(), appMode })
const stravaAuthorizationCode = urlParameters.get('code')
let socket
initWebsocket()
resetFields()
requestWakeLock()
function websocketOpened () {
if (stravaAuthorizationCode) {
handleStravaAuthorization(stravaAuthorizationCode)
}
}
function handleStravaAuthorization (stravaAuthorizationCode) {
if (socket)socket.send(JSON.stringify({ command: 'stravaAuthorizationCode', data: stravaAuthorizationCode }))
}
let initialWebsocketOpenend = true
function initWebsocket () {
// use the native websocket implementation of browser to communicate with backend
// eslint-disable-next-line no-undef
socket = new WebSocket(`ws://${location.host}/websocket`)
socket.addEventListener('open', (event) => {
console.log('websocket opened')
if (initialWebsocketOpenend) {
websocketOpened()
initialWebsocketOpenend = false
}
})
socket.addEventListener('error', (error) => {
@ -46,21 +63,37 @@ export function createApp (app) {
// todo: we should use different types of messages to make processing easier
socket.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data)
let activeFields = rowingMetricsFields
// if we are in reset state only update heart rate
if (data.strokesTotal === 0) {
activeFields = ['heartrate', 'heartrateBatteryLevel']
const message = JSON.parse(event.data)
if (!message.type) {
console.error('message does not contain messageType specifier', message)
return
}
const data = message.data
switch (message.type) {
case 'config': {
app.updateState({ ...app.getState(), config: data })
break
}
case 'metrics': {
let activeFields = rowingMetricsFields
// if we are in reset state only update heart rate
if (data.strokesTotal === 0) {
activeFields = ['heartrate', 'heartrateBatteryLevel']
}
const filteredData = filterObjectByKeys(data, activeFields)
let updatedState = { ...app.getState(), metrics: filteredData }
if (data.peripheralMode) {
updatedState = { ...app.getState(), peripheralMode: data.peripheralMode }
const filteredData = filterObjectByKeys(data, activeFields)
app.updateState({ ...app.getState(), metrics: filteredData })
break
}
case 'authorizeStrava': {
const currentUrl = encodeURIComponent(window.location.href)
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`
break
}
default: {
console.error(`unknown message type: ${message.type}`, message.data)
}
}
app.updateState(updatedState)
} catch (err) {
console.log(err)
}
@ -90,13 +123,27 @@ export function createApp (app) {
}
function handleAction (action) {
if (action.command === 'switchPeripheralMode') {
if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' }))
} else if (action.command === 'reset') {
resetFields()
if (socket)socket.send(JSON.stringify({ command: 'reset' }))
} else {
console.error('no handler defined for action', action)
switch (action.command) {
case 'switchPeripheralMode': {
if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' }))
break
}
case 'reset': {
resetFields()
if (socket)socket.send(JSON.stringify({ command: 'reset' }))
break
}
case 'uploadTraining': {
if (socket)socket.send(JSON.stringify({ command: 'uploadTraining' }))
break
}
case 'shutdown': {
if (socket)socket.send(JSON.stringify({ command: 'shutdown' }))
break
}
default: {
console.error('no handler defined for action', action)
}
}
}

View File

@ -24,3 +24,4 @@ export const icon_poweroff = svg`<svg aria-hidden="true" focusable="false" class
export const icon_expand = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M0 180V56c0-13.3 10.7-24 24-24h124c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H64v84c0 6.6-5.4 12-12 12H12c-6.6 0-12-5.4-12-12zM288 44v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12V56c0-13.3-10.7-24-24-24H300c-6.6 0-12 5.4-12 12zm148 276h-40c-6.6 0-12 5.4-12 12v84h-84c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24V332c0-6.6-5.4-12-12-12zM160 468v-40c0-6.6-5.4-12-12-12H64v-84c0-6.6-5.4-12-12-12H12c-6.6 0-12 5.4-12 12v124c0 13.3 10.7 24 24 24h124c6.6 0 12-5.4 12-12z"></path></svg>`
export const icon_compress = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M436 192H312c-13.3 0-24-10.7-24-24V44c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v84h84c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm-276-24V44c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v84H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24zm0 300V344c0-13.3-10.7-24-24-24H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm192 0v-84h84c6.6 0 12-5.4 12-12v-40c0-6.6-5.4-12-12-12H312c-13.3 0-24 10.7-24 24v124c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12z"></path></svg>`
export const icon_bluetooth = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M292.6 171.1L249.7 214l-.3-86 43.2 43.1m-43.2 219.8l43.1-43.1-42.9-42.9-.2 86zM416 259.4C416 465 344.1 512 230.9 512S32 465 32 259.4 115.4 0 228.6 0 416 53.9 416 259.4zm-158.5 0l79.4-88.6L211.8 36.5v176.9L138 139.6l-27 26.9 92.7 93-92.7 93 26.9 26.9 73.8-73.8 2.3 170 127.4-127.5-83.9-88.7z"></path></svg>`
export const icon_upload = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"></path></svg>`

View File

@ -9,8 +9,8 @@
"type": "image/png"
}
],
"background_color": "#0059B3",
"background_color": "#002b57",
"display": "fullscreen",
"orientation": "any",
"start_url": "/#:standalone:"
"start_url": "/?mode=standalone"
}

View File

@ -8,8 +8,14 @@
export const APP_STATE = {
// currently can be STANDALONE (Mobile Home Screen App), KIOSK (Raspberry Pi deployment) or '' (default)
appMode: '',
// currently can be FTMS, FTMSBIKE or PM5
peripheralMode: 'FTMS',
// contains all the rowing metrics that are delivered from the backend
metrics: {}
metrics: {},
config: {
// currently can be FTMS, FTMSBIKE or PM5
peripheralMode: '',
// true if upload to strava is enabled
stravaUploadEnabled: false,
// true if remote device shutdown is enabled
shutdownEnabled: false
}
}

View File

@ -8,7 +8,7 @@
*/
import log from 'loglevel'
import zlib from 'zlib'
import { mkdir, writeFile } from 'fs/promises'
import fs from 'fs/promises'
import xml2js from 'xml2js'
import config from '../tools/ConfigManager.js'
import { promisify } from 'util'
@ -34,33 +34,7 @@ function createWorkoutRecorder () {
if (startTime === undefined) {
startTime = new Date()
}
// stroke recordings are currently only used to create tcx files, so we can skip it
// if tcx file creation is disabled
if (config.createTcxFiles) {
strokes.push(stroke)
}
}
async function createTcxFile () {
const stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
const filename = `${directory}/${stringifiedStartTime}_rowing.tcx${config.gzipTcxFiles ? '.gz' : ''}`
log.info(`saving session as tcx file ${filename}...`)
try {
await mkdir(directory, { recursive: true })
} catch (error) {
if (error.code !== 'EEXIST') {
log.error(`can not create directory ${directory}`, error)
}
}
await buildAndSaveTcxFile({
id: startTime.toISOString(),
filename,
startTime,
strokes
})
strokes.push(stroke)
}
async function createRawDataFile () {
@ -70,7 +44,7 @@ function createWorkoutRecorder () {
log.info(`saving session as raw data file ${filename}...`)
try {
await mkdir(directory, { recursive: true })
await fs.mkdir(directory, { recursive: true })
} catch (error) {
if (error.code !== 'EEXIST') {
log.error(`can not create directory ${directory}`, error)
@ -79,7 +53,46 @@ function createWorkoutRecorder () {
await createFile(rotationImpulses.join('\n'), filename, config.gzipRawDataFiles)
}
async function buildAndSaveTcxFile (workout) {
async function createTcxFile () {
const tcxRecord = await activeWorkoutToTcx()
if (tcxRecord === undefined) {
log.error('error creating tcx file')
return
}
const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
const filename = `${directory}/${tcxRecord.filename}${config.gzipTcxFiles ? '.gz' : ''}`
log.info(`saving session as tcx file ${filename}...`)
try {
await fs.mkdir(directory, { recursive: true })
} catch (error) {
if (error.code !== 'EEXIST') {
log.error(`can not create directory ${directory}`, error)
}
}
await createFile(tcxRecord.tcx, `${filename}`, config.gzipTcxFiles)
}
async function activeWorkoutToTcx () {
// we need at least two strokes to generate a valid tcx file
if (strokes.length < 2) return
const stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
const filename = `${stringifiedStartTime}_rowing.tcx`
const tcx = await workoutToTcx({
id: startTime.toISOString(),
startTime,
strokes
})
return {
tcx,
filename
}
}
async function workoutToTcx (workout) {
let versionArray = process.env.npm_package_version.split('.')
if (versionArray.length < 3) versionArray = [0, 0, 0]
const lastStroke = workout.strokes[strokes.length - 1]
@ -164,7 +177,7 @@ function createWorkoutRecorder () {
}
const builder = new xml2js.Builder()
await createFile(builder.buildObject(tcxObject), workout.filename, config.gzipTcxFiles)
return builder.buildObject(tcxObject)
}
async function reset () {
@ -177,9 +190,9 @@ function createWorkoutRecorder () {
async function createFile (content, filename, compress = false) {
if (compress) {
const gzipContent = await gzip(content)
await writeFile(filename, gzipContent, (err) => { if (err) log.error(err) })
await fs.writeFile(filename, gzipContent, (err) => { if (err) log.error(err) })
} else {
await writeFile(filename, content, (err) => { if (err) log.error(err) })
await fs.writeFile(filename, content, (err) => { if (err) log.error(err) })
}
}
@ -192,11 +205,8 @@ function createWorkoutRecorder () {
return
}
const minimumRecordingTimeInSeconds = 10
const rotationImpulseTimeTotal = rotationImpulses.reduce((acc, impulse) => acc + impulse, 0)
const strokeTimeTotal = strokes.reduce((acc, stroke) => acc + stroke.strokeTime, 0)
if (Math.max(rotationImpulseTimeTotal, strokeTimeTotal) < minimumRecordingTimeInSeconds) {
log.debug(`recording time is less than ${minimumRecordingTimeInSeconds}s, skipping creation of recording files...`)
if (!minimumRecordingTimeHasPassed()) {
log.debug('workout is shorter than minimum workout time, skipping automatic creation of recordings...')
return
}
@ -211,10 +221,18 @@ function createWorkoutRecorder () {
await Promise.all(parallelCalls)
}
function minimumRecordingTimeHasPassed () {
const minimumRecordingTimeInSeconds = 10
const rotationImpulseTimeTotal = rotationImpulses.reduce((acc, impulse) => acc + impulse, 0)
const strokeTimeTotal = strokes.reduce((acc, stroke) => acc + stroke.strokeTime, 0)
return (Math.max(rotationImpulseTimeTotal, strokeTimeTotal) > minimumRecordingTimeInSeconds)
}
return {
recordStroke,
recordRotationImpulse,
handlePause,
activeWorkoutToTcx,
reset
}
}

View File

@ -0,0 +1,57 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Handles uploading workout data to different cloud providers
*/
import log from 'loglevel'
import EventEmitter from 'events'
import { createStravaAPI } from '../tools/StravaAPI.js'
import config from '../tools/ConfigManager.js'
function createWorkoutUploader (workoutRecorder) {
const emitter = new EventEmitter()
let stravaAuthorizationCodeResolver
let requestingClient
function getStravaAuthorizationCode () {
return new Promise((resolve) => {
emitter.emit('authorizeStrava', { stravaClientId: config.stravaClientId }, requestingClient)
stravaAuthorizationCodeResolver = resolve
})
}
const stravaAPI = createStravaAPI(getStravaAuthorizationCode)
function stravaAuthorizationCode (stravaAuthorizationCode) {
if (stravaAuthorizationCodeResolver) {
stravaAuthorizationCodeResolver(stravaAuthorizationCode)
stravaAuthorizationCodeResolver = undefined
}
}
async function upload (client) {
log.debug('uploading workout to strava...')
try {
requestingClient = client
// todo: we might signal back to the client whether we had success or not
const tcxActivity = await workoutRecorder.activeWorkoutToTcx()
if (tcxActivity !== undefined) {
await stravaAPI.uploadActivityTcx(tcxActivity)
emitter.emit('resetWorkout')
} else {
log.error('can not upload an empty workout to strava')
}
} catch (error) {
log.error('can not upload workout to strava:', error.message)
}
}
return Object.assign(emitter, {
upload,
stravaAuthorizationCode
})
}
export { createWorkoutUploader }

View File

@ -6,7 +6,8 @@
everything together while figuring out the physics and model of the application.
todo: refactor this as we progress
*/
import { fork } from 'child_process'
import child_process from 'child_process'
import { promisify } from 'util'
import log from 'loglevel'
import config from './tools/ConfigManager.js'
import { createRowingEngine } from './engine/RowingEngine.js'
@ -17,6 +18,8 @@ import { createAntManager } from './ant/AntManager.js'
// eslint-disable-next-line no-unused-vars
import { replayRowingSession } from './tools/RowingRecorder.js'
import { createWorkoutRecorder } from './engine/WorkoutRecorder.js'
import { createWorkoutUploader } from './engine/WorkoutUploader.js'
const exec = promisify(child_process.exec)
// set the log levels
log.setLevel(config.loglevel.default)
@ -51,7 +54,7 @@ peripheralManager.on('control', (event) => {
peripheralManager.notifyStatus({ name: 'startedOrResumedByUser' })
event.res = true
} else if (event?.req?.name === 'peripheralMode') {
webServer.notifyClients({ peripheralMode: event.req.peripheralMode })
webServer.notifyClients('config', getConfig())
event.res = true
} else {
log.info('unhandled Command', event.req)
@ -65,7 +68,7 @@ function resetWorkout () {
peripheralManager.notifyStatus({ name: 'reset' })
}
const gpioTimerService = fork('./app/gpio/GpioTimerService.js')
const gpioTimerService = child_process.fork('./app/gpio/GpioTimerService.js')
gpioTimerService.on('message', handleRotationImpulse)
function handleRotationImpulse (dataPoint) {
@ -77,9 +80,10 @@ const rowingEngine = createRowingEngine(config.rowerSettings)
const rowingStatistics = createRowingStatistics(config)
rowingEngine.notify(rowingStatistics)
const workoutRecorder = createWorkoutRecorder()
const workoutUploader = createWorkoutUploader(workoutRecorder)
rowingStatistics.on('driveFinished', (metrics) => {
webServer.notifyClients(metrics)
webServer.notifyClients('metrics', metrics)
peripheralManager.notifyMetrics('strokeStateChanged', metrics)
})
@ -88,7 +92,7 @@ rowingStatistics.on('recoveryFinished', (metrics) => {
`, split: ${metrics.splitFormatted}, ratio: ${metrics.powerRatio.toFixed(2)}, dist: ${metrics.distanceTotal.toFixed(1)}m` +
`, cal: ${metrics.caloriesTotal.toFixed(1)}kcal, SPM: ${metrics.strokesPerMinute.toFixed(1)}, speed: ${metrics.speed.toFixed(2)}km/h` +
`, cal/hour: ${metrics.caloriesPerHour.toFixed(1)}kcal, cal/minute: ${metrics.caloriesPerMinute.toFixed(1)}kcal`)
webServer.notifyClients(metrics)
webServer.notifyClients('metrics', metrics)
peripheralManager.notifyMetrics('strokeFinished', metrics)
if (metrics.sessionState === 'rowing') {
workoutRecorder.recordStroke(metrics)
@ -96,7 +100,7 @@ rowingStatistics.on('recoveryFinished', (metrics) => {
})
rowingStatistics.on('webMetricsUpdate', (metrics) => {
webServer.notifyClients(metrics)
webServer.notifyClients('metrics', metrics)
})
rowingStatistics.on('peripheralMetricsUpdate', (metrics) => {
@ -108,7 +112,7 @@ rowingStatistics.on('rowingPaused', () => {
})
if (config.heartrateMonitorBLE) {
const bleCentralService = fork('./app/ble/CentralService.js')
const bleCentralService = child_process.fork('./app/ble/CentralService.js')
bleCentralService.on('message', (heartrateMeasurement) => {
rowingStatistics.handleHeartrateMeasurement(heartrateMeasurement)
})
@ -121,21 +125,67 @@ if (config.heartrateMonitorANT) {
})
}
workoutUploader.on('authorizeStrava', (data, client) => {
webServer.notifyClient(client, 'authorizeStrava', data)
})
workoutUploader.on('resetWorkout', () => {
resetWorkout()
})
const webServer = createWebServer()
webServer.on('messageReceived', (message) => {
if (message.command === 'reset') {
resetWorkout()
} else if (message.command === 'switchPeripheralMode') {
peripheralManager.switchPeripheralMode()
} else {
log.warn('invalid command received:', message)
webServer.on('messageReceived', async (message, client) => {
switch (message.command) {
case 'switchPeripheralMode': {
peripheralManager.switchPeripheralMode()
break
}
case 'reset': {
resetWorkout()
break
}
case 'uploadTraining': {
workoutUploader.upload(client)
break
}
case 'shutdown': {
if (getConfig().shutdownEnabled) {
console.info('shutting down device...')
try {
const { stdout, stderr } = await exec(config.shutdownCommand)
if (stderr) {
log.error('can not shutdown: ', stderr)
}
log.info(stdout)
} catch (error) {
log.error('can not shutdown: ', error)
}
}
break
}
case 'stravaAuthorizationCode': {
workoutUploader.stravaAuthorizationCode(message.data)
break
}
default: {
log.warn('invalid command received:', message)
}
}
})
webServer.on('clientConnected', () => {
webServer.notifyClients({ peripheralMode: peripheralManager.getPeripheralMode() })
webServer.on('clientConnected', (client) => {
webServer.notifyClient(client, 'config', getConfig())
})
// todo: extract this into some kind of state manager
function getConfig () {
return {
peripheralMode: peripheralManager.getPeripheralMode(),
stravaUploadEnabled: !!config.stravaClientId && !!config.stravaClientSecret,
shutdownEnabled: !!config.shutdownCommand
}
}
/*
replayRowingSession(handleRotationImpulse, {
filename: 'recordings/WRX700_2magnets.csv',

View File

@ -0,0 +1,130 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Creates an OAuth authorized connection to Strava (https://developers.strava.com/)
*/
import log from 'loglevel'
import axios from 'axios'
import FormData from 'form-data'
import config from './ConfigManager.js'
import fs from 'fs/promises'
const clientId = config.stravaClientId
const clientSecret = config.stravaClientSecret
const stravaTokenFile = './config/stravatoken'
function createAuthorizedConnection (getStravaAuthorizationCode) {
let accessToken
let refreshToken
const authorizedConnection = axios.create({
baseURL: 'https://www.strava.com/api/v3'
})
authorizedConnection.interceptors.request.use(async config => {
if (!refreshToken) {
try {
refreshToken = await fs.readFile(stravaTokenFile, 'utf-8')
} catch (error) {
log.info('no strava token available yet')
}
}
// if no refresh token is set, then the app has not yet been authorized with Strava
// start oAuth authorization process
if (!refreshToken) {
const authorizationCode = await getStravaAuthorizationCode();
({ accessToken, refreshToken } = await authorize(authorizationCode))
await writeToken('', refreshToken)
// otherwise we just need to get a valid accessToken
} else {
const oldRefreshToken = refreshToken;
({ accessToken, refreshToken } = await getAccessTokens(refreshToken))
if (!refreshToken) {
log.error(`strava token is invalid, deleting ${stravaTokenFile}...`)
await fs.unlink(stravaTokenFile)
// if the refreshToken has changed, persist it
} else {
await writeToken(oldRefreshToken, refreshToken)
}
}
if (!accessToken) {
log.error('strava authorization not successful')
}
Object.assign(config.headers, { Authorization: `Bearer ${accessToken}` })
if (config.data instanceof FormData) {
Object.assign(config.headers, config.data.getHeaders())
}
return config
})
authorizedConnection.interceptors.response.use(function (response) {
return response
}, function (error) {
if (error?.response?.status === 401 || error?.message === 'canceled') {
return Promise.reject(new Error('user unauthorized'))
} else {
return Promise.reject(error)
}
})
async function oAuthTokenRequest (token, grantType) {
let responsePayload
const payload = {
client_id: clientId,
client_secret: clientSecret,
grant_type: grantType
}
if (grantType === 'authorization_code') {
payload.code = token
} else {
payload.refresh_token = token
}
try {
const response = await axios.post('https://www.strava.com/oauth/token', payload)
if (response?.status === 200) {
responsePayload = response.data
} else {
log.error(`response error at strava oAuth request for ${grantType}: ${response?.data?.message || response}`)
}
} catch (e) {
log.error(`general error at strava oAuth request for ${grantType}: ${e?.response?.data?.message || e}`)
}
return responsePayload
}
async function authorize (authorizationCode) {
const response = await oAuthTokenRequest(authorizationCode, 'authorization_code')
return {
refreshToken: response?.refresh_token,
accessToken: response?.access_token
}
}
async function getAccessTokens (refreshToken) {
const response = await oAuthTokenRequest(refreshToken, 'refresh_token')
return {
refreshToken: response?.refresh_token,
accessToken: response?.access_token
}
}
async function writeToken (oldToken, newToken) {
if (oldToken !== newToken) {
try {
await fs.writeFile(stravaTokenFile, newToken, 'utf-8')
} catch (error) {
log.info(`can not write strava token to file ${stravaTokenFile}`, error)
}
}
}
return authorizedConnection
}
export {
createAuthorizedConnection
}

40
app/tools/StravaAPI.js Normal file
View File

@ -0,0 +1,40 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Implements required parts of the Strava API (https://developers.strava.com/)
*/
import zlib from 'zlib'
import FormData from 'form-data'
import { promisify } from 'util'
import { createAuthorizedConnection } from './AuthorizedStravaConnection.js'
const gzip = promisify(zlib.gzip)
function createStravaAPI (getStravaAuthorizationCode) {
const authorizedStravaConnection = createAuthorizedConnection(getStravaAuthorizationCode)
async function uploadActivityTcx (tcxRecord) {
const form = new FormData()
form.append('file', await gzip(tcxRecord.tcx), tcxRecord.filename)
form.append('data_type', 'tcx.gz')
form.append('name', 'Indoor Rowing Session')
form.append('description', 'Uploaded from Open Rowing Monitor')
form.append('trainer', 'true')
form.append('activity_type', 'Rowing')
return await authorizedStravaConnection.post('/uploads', form)
}
async function getAthlete () {
return (await authorizedStravaConnection.get('/athlete')).data
}
return {
uploadActivityTcx,
getAthlete
}
}
export {
createStravaAPI
}

View File

@ -99,5 +99,23 @@ export default {
// the device to the profiles.
// !! Only change this setting in the config/config.js file, and leave this on DEFAULT as that
// is the fallback for the default profile settings
rowerSettings: rowerProfiles.DEFAULT
rowerSettings: rowerProfiles.DEFAULT,
// command to shutdown the device via the user interface, leave empty to disable this feature
shutdownCommand: 'halt',
// Configures the connection to Strava (to directly upload workouts to Strava)
// Note that these values are not your Strava credentials
// Instead you have to create a Strava API Application as described here:
// https://developers.strava.com/docs/getting-started/#account and use the corresponding values
// When creating your Strava API application, set the "Authorization Callback Domain" to the IP address
// of your Raspberry Pi
// WARNING: if you enabled the network share via the installer script, then this config file will be
// exposed via network share on your local network. You might consider disabling (or password protect)
// the Configuration share in smb.conf
// The "Client ID" of your Strava API Application
stravaClientId: '',
// The "Client Secret" of your Strava API Application
stravaClientSecret: ''
}

View File

@ -55,7 +55,7 @@ Fitness Machine Service (FTMS) is a standardized GATT protocol for different typ
Open Rowing Monitor can create Training Center XML files (TCX). You can upload these files to training platforms like [Strava](https://www.strava.com), [Garmin Connect](https://connect.garmin.com) or [Trainingpeaks](https://trainingpeaks.com) to track your training sessions.
Currently this is a manual step. The installer can set up a network share that contains all training data so it is easy to grab the files from there and upload them to the training platform of your choice.
Uploading your sessions to Strava is an integrated feature, for all other platforms this is currently a manual step. The installer can set up a network share that contains all training data so it is easy to grab the files from there and upload them to the training platform of your choice.
Open Rowing Monitor can also store the raw measurements of the flywheel into CSV files. These files are great to start your own exploration of your rowing style and also to learn about the specifics of your rowing machine (some Excel files that can help with this are included in the `docs` folder).

View File

@ -12,7 +12,6 @@ If you would like to contribute to this project, please read the [Contributing G
## Later
* automatically upload recorded rowing sessions to training platforms (i.e. Strava)
* figure out where to set the Service Advertising Data (FTMS.pdf p 15)
* add some attributes to BLE DeviceInformationService
* record the workout and show a visual graph of metrics

View File

@ -12,4 +12,4 @@ openbox-session &
# Start Chromium in kiosk mode
sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' ~/.config/chromium/'Local State'
sed -i 's/"exited_cleanly":false/"exited_cleanly":true/; s/"exit_type":"[^"]\+"/"exit_type":"Normal"/' ~/.config/chromium/Default/Preferences
chromium-browser --disable-infobars --kiosk --noerrdialogs --disable-session-crashed-bubble --disable-pinch --check-for-update-interval=604800 --app="http://127.0.0.1/#:kiosk:"
chromium-browser --disable-infobars --disable-features=AudioServiceSandbox --kiosk --noerrdialogs --disable-session-crashed-bubble --disable-pinch --check-for-update-interval=604800 --app="http://127.0.0.1/?mode=kiosk"

View File

@ -1,5 +1,9 @@
{
"compilerOptions": {
"target": "es2021",
"moduleResolution": "node",
"checkJs": true,
"esModuleInterop": true,
"experimentalDecorators": true
}
}

561
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "openrowingmonitor",
"version": "0.8.1",
"description": "A rowing monitor for rowing exercise machines",
"version": "0.8.2",
"description": "A free and open source performance monitor for rowing machines",
"main": "app/server.js",
"author": "Lars Berning",
"license": "GPL-3.0",
@ -21,7 +21,7 @@
"lint": "eslint ./app ./config && markdownlint-cli2 '**/*.md' '#node_modules'",
"start": "node app/server.js",
"dev": "npm-run-all --parallel dev:backend dev:frontend",
"dev:backend": "nodemon app/server.js",
"dev:backend": "nodemon --ignore 'app/client/**/*' app/server.js",
"dev:frontend": "snowpack dev",
"build": "rollup -c",
"build:watch": "rollup -cw",
@ -35,6 +35,7 @@
"@abandonware/noble": "1.9.2-15",
"ant-plus": "0.1.24",
"finalhandler": "1.1.2",
"form-data": "4.0.0",
"lit": "2.1.2",
"loglevel": "1.8.0",
"nosleep.js": "0.12.0",
@ -62,6 +63,7 @@
"@rollup/plugin-node-resolve": "13.1.3",
"@snowpack/plugin-babel": "2.1.7",
"@web/rollup-plugin-html": "1.10.1",
"axios": "0.25.0",
"eslint": "8.8.0",
"eslint-config-standard": "17.0.0-0",
"eslint-plugin-import": "2.25.4",

View File

@ -43,7 +43,6 @@ export default {
upgrade: (req, socket, head) => {
const defaultWSHandler = (err, req, socket, head) => {
if (err) {
console.error('proxy error', err)
socket.destroy()
}
}