From 943518b986f31bebad41608c9f45835d012717d6 Mon Sep 17 00:00:00 2001
From: Lars Berning <151194+laberning@users.noreply.github.com>
Date: Fri, 11 Feb 2022 22:37:36 +0000
Subject: [PATCH] implements confirmation dialogs, improvements to strava
upload
---
app/WebServer.js | 22 +++-
app/client/components/AppDialog.js | 106 +++++++++++++++
app/client/components/DashboardActions.js | 124 +++++++++++-------
app/client/components/PerformanceDashboard.js | 1 +
app/client/index.html | 1 +
app/client/lib/app.js | 12 +-
app/client/store/appState.js | 10 +-
app/engine/WorkoutRecorder.js | 13 +-
app/engine/WorkoutUploader.js | 24 ++--
app/server.js | 26 +++-
app/tools/AuthorizedStravaConnection.js | 32 +++--
config/default.config.js | 3 +
snowpack.config.js | 1 -
13 files changed, 280 insertions(+), 95 deletions(-)
create mode 100644 app/client/components/AppDialog.js
diff --git a/app/WebServer.js b/app/WebServer.js
index af1e11f..6eb1eec 100644
--- a/app/WebServer.js
+++ b/app/WebServer.js
@@ -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,11 +43,22 @@ function createWebServer () {
log.error(err)
}
})
- ws.on('close', function () {
+ client.on('close', function () {
log.debug('websocket client disconnected')
})
})
+ 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) {
@@ -58,6 +69,7 @@ function createWebServer () {
}
return Object.assign(emitter, {
+ notifyClient,
notifyClients
})
}
diff --git a/app/client/components/AppDialog.js b/app/client/components/AppDialog.js
new file mode 100644
index 0000000..d2f4c8e
--- /dev/null
+++ b/app/client/components/AppDialog.js
@@ -0,0 +1,106 @@
+'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::backdrop {
+ background: none;
+ backdrop-filter: contrast(15%) blur(2px);
+ }
+ 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%;
+ }
+ 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;
+ }
+ 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`
+
+ `
+ }
+
+ 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()
+ }
+ }
+ }
+}
diff --git a/app/client/components/DashboardActions.js b/app/client/components/DashboardActions.js
index 38e8e39..85f39f2 100644
--- a/app/client/components/DashboardActions.js
+++ b/app/client/components/DashboardActions.js
@@ -6,8 +6,9 @@
*/
import { AppElement, html, css } from './AppElement.js'
-import { customElement } from 'lit/decorators.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,6 +17,7 @@ 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);
margin: 0.2em 0;
font-size: 60%;
@@ -53,75 +55,97 @@ export class DashboardActions extends AppElement {
}
`
- render () {
- return html`
+ @state({ type: Object })
+ dialog
+
+ render () {
+ return html`
${this.renderOptionalButtons()}
-
${this.peripheralMode()}
+ ${this.dialog ? this.dialog : ''}
`
- }
+ }
- renderOptionalButtons () {
- const buttons = []
- // 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) {
- buttons.push(html`
+ renderOptionalButtons () {
+ const buttons = []
+ // 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) {
+ buttons.push(html`
`)
- }
- // 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') {
- buttons.push(html`
+ }
+ // 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') {
+ buttons.push(html`
`)
- }
- return buttons
- }
+ }
- peripheralMode () {
- const value = this.appState?.peripheralMode
- if (value === 'PM5') {
- return 'C2 PM5'
- } else if (value === 'FTMSBIKE') {
- return 'FTMS Bike'
- } else {
- return 'FTMS Rower'
+ if (this.appState.config.stravaUploadEnabled) {
+ buttons.push(html`
+
+ `)
+ }
+ return buttons
}
- }
- toggleFullscreen () {
- const fullscreenElement = document.getElementsByTagName('web-app')[0]
- if (!document.fullscreenElement) {
- fullscreenElement.requestFullscreen({ navigationUI: 'hide' })
- } else {
- if (document.exitFullscreen) {
- document.exitFullscreen()
+ peripheralMode () {
+ const value = this.appState?.config?.peripheralMode
+ if (value === 'PM5') {
+ return 'C2 PM5'
+ } else if (value === 'FTMSBIKE') {
+ return 'FTMS Bike'
+ } else if (value === 'FTMS') {
+ return 'FTMS Rower'
+ } else {
+ return ''
}
}
- }
- close () {
- window.close()
- }
+ toggleFullscreen () {
+ const fullscreenElement = document.getElementsByTagName('web-app')[0]
+ if (!document.fullscreenElement) {
+ fullscreenElement.requestFullscreen({ navigationUI: 'hide' })
+ } else {
+ if (document.exitFullscreen) {
+ document.exitFullscreen()
+ }
+ }
+ }
- reset () {
- this.sendEvent('triggerAction', { command: 'reset' })
- }
+ close () {
+ window.close()
+ }
- switchPeripheralMode () {
- this.sendEvent('triggerAction', { command: 'switchPeripheralMode' })
- }
+ reset () {
+ this.sendEvent('triggerAction', { command: 'reset' })
+ }
- uploadTraining () {
- this.sendEvent('triggerAction', { command: 'uploadTraining' })
- }
+ switchPeripheralMode () {
+ this.sendEvent('triggerAction', { command: 'switchPeripheralMode' })
+ }
+
+ uploadTraining () {
+ this.dialog = html`
+
+
+ Do you want to finish your workout and upload it to Strava?
+
+ `
+ function dialogClosed (event) {
+ this.dialog = undefined
+ if (event.detail === 'confirm') {
+ this.sendEvent('triggerAction', { command: 'uploadTraining' })
+ }
+ }
+ }
}
diff --git a/app/client/components/PerformanceDashboard.js b/app/client/components/PerformanceDashboard.js
index a928b28..0d04013 100644
--- a/app/client/components/PerformanceDashboard.js
+++ b/app/client/components/PerformanceDashboard.js
@@ -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 {
diff --git a/app/client/index.html b/app/client/index.html
index b49ccde..22ef8e8 100644
--- a/app/client/index.html
+++ b/app/client/index.html
@@ -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 {
diff --git a/app/client/lib/app.js b/app/client/lib/app.js
index 75fc22f..1dfd847 100644
--- a/app/client/lib/app.js
+++ b/app/client/lib/app.js
@@ -70,6 +70,10 @@ export function createApp (app) {
}
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
@@ -78,11 +82,7 @@ export function createApp (app) {
}
const filteredData = filterObjectByKeys(data, activeFields)
- let updatedState = { ...app.getState(), metrics: filteredData }
- if (data.peripheralMode) {
- updatedState = { ...app.getState(), peripheralMode: data.peripheralMode }
- }
- app.updateState(updatedState)
+ app.updateState({ ...app.getState(), metrics: filteredData })
break
}
case 'authorizeStrava': {
@@ -91,7 +91,7 @@ export function createApp (app) {
break
}
default: {
- console.error(`unknown message type: ${message.type}`)
+ console.error(`unknown message type: ${message.type}`, message.data)
}
}
} catch (err) {
diff --git a/app/client/store/appState.js b/app/client/store/appState.js
index 2a44f9b..81ae24a 100644
--- a/app/client/store/appState.js
+++ b/app/client/store/appState.js
@@ -8,8 +8,12 @@
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 configured
+ stravaUploadEnabled: false
+ }
}
diff --git a/app/engine/WorkoutRecorder.js b/app/engine/WorkoutRecorder.js
index 66877ec..192b6ef 100644
--- a/app/engine/WorkoutRecorder.js
+++ b/app/engine/WorkoutRecorder.js
@@ -55,6 +55,10 @@ function createWorkoutRecorder () {
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}...`)
@@ -71,6 +75,8 @@ function createWorkoutRecorder () {
}
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`
@@ -199,8 +205,8 @@ function createWorkoutRecorder () {
return
}
- if (!canCreateRecordings()) {
- log.debug('workout is shorter than minimum workout time, skipping creation of recordings...')
+ if (!minimumRecordingTimeHasPassed()) {
+ log.debug('workout is shorter than minimum workout time, skipping automatic creation of recordings...')
return
}
@@ -215,7 +221,7 @@ function createWorkoutRecorder () {
await Promise.all(parallelCalls)
}
- function canCreateRecordings () {
+ function minimumRecordingTimeHasPassed () {
const minimumRecordingTimeInSeconds = 10
const rotationImpulseTimeTotal = rotationImpulses.reduce((acc, impulse) => acc + impulse, 0)
const strokeTimeTotal = strokes.reduce((acc, stroke) => acc + stroke.strokeTime, 0)
@@ -225,7 +231,6 @@ function createWorkoutRecorder () {
return {
recordStroke,
recordRotationImpulse,
- canCreateRecordings,
handlePause,
activeWorkoutToTcx,
reset
diff --git a/app/engine/WorkoutUploader.js b/app/engine/WorkoutUploader.js
index de4c08e..1f4e4af 100644
--- a/app/engine/WorkoutUploader.js
+++ b/app/engine/WorkoutUploader.js
@@ -13,11 +13,11 @@ function createWorkoutUploader (workoutRecorder) {
const emitter = new EventEmitter()
let stravaAuthorizationCodeResolver
+ let requestingClient
function getStravaAuthorizationCode () {
return new Promise((resolve) => {
- log.info('please open https://www.strava.com/oauth/authorize?client_id=&response_type=code&redirect_uri=http://localhost/index.html&approval_prompt=force&scope=activity:write')
- emitter.emit('authorizeStrava', { stravaClientId: config.stravaClientId })
+ emitter.emit('authorizeStrava', { stravaClientId: config.stravaClientId }, requestingClient)
stravaAuthorizationCodeResolver = resolve
})
}
@@ -31,12 +31,20 @@ function createWorkoutUploader (workoutRecorder) {
}
}
- async function upload () {
- if (workoutRecorder.canCreateRecordings()) {
- log.debug('uploading workout to strava...')
- await stravaAPI.uploadActivityTcx(await workoutRecorder.activeWorkoutToTcx())
- } else {
- log.debug('workout is shorter than minimum workout time, skipping upload')
+ 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)
}
}
diff --git a/app/server.js b/app/server.js
index 100fbac..33d9e51 100644
--- a/app/server.js
+++ b/app/server.js
@@ -52,7 +52,7 @@ peripheralManager.on('control', (event) => {
peripheralManager.notifyStatus({ name: 'startedOrResumedByUser' })
event.res = true
} else if (event?.req?.name === 'peripheralMode') {
- webServer.notifyClients('metrics', { peripheralMode: event.req.peripheralMode })
+ webServer.notifyClients('config', getConfig())
event.res = true
} else {
log.info('unhandled Command', event.req)
@@ -123,12 +123,16 @@ if (config.heartrateMonitorANT) {
})
}
-workoutUploader.on('authorizeStrava', (data) => {
- webServer.notifyClients('authorizeStrava', data)
+workoutUploader.on('authorizeStrava', (data, client) => {
+ webServer.notifyClient(client, 'authorizeStrava', data)
+})
+
+workoutUploader.on('resetWorkout', () => {
+ resetWorkout()
})
const webServer = createWebServer()
-webServer.on('messageReceived', (message) => {
+webServer.on('messageReceived', (message, client) => {
switch (message.command) {
case 'switchPeripheralMode': {
peripheralManager.switchPeripheralMode()
@@ -139,7 +143,7 @@ webServer.on('messageReceived', (message) => {
break
}
case 'uploadTraining': {
- workoutUploader.upload()
+ workoutUploader.upload(client)
break
}
case 'stravaAuthorizationCode': {
@@ -152,10 +156,18 @@ webServer.on('messageReceived', (message) => {
}
})
-webServer.on('clientConnected', () => {
- webServer.notifyClients('metrics', { 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
+ }
+}
+
/*
replayRowingSession(handleRotationImpulse, {
filename: 'recordings/WRX700_2magnets.csv',
diff --git a/app/tools/AuthorizedStravaConnection.js b/app/tools/AuthorizedStravaConnection.js
index eab8490..a653dfc 100644
--- a/app/tools/AuthorizedStravaConnection.js
+++ b/app/tools/AuthorizedStravaConnection.js
@@ -12,45 +12,45 @@ import fs from 'fs/promises'
const clientId = config.stravaClientId
const clientSecret = config.stravaClientSecret
+const stravaTokenFile = './config/stravatoken'
function createAuthorizedConnection (getStravaAuthorizationCode) {
- const controller = new AbortController()
let accessToken
let refreshToken
const authorizedConnection = axios.create({
- baseURL: 'https://www.strava.com/api/v3',
- signal: controller.signal
+ baseURL: 'https://www.strava.com/api/v3'
})
authorizedConnection.interceptors.request.use(async config => {
if (!refreshToken) {
try {
- refreshToken = await fs.readFile('./config/stravatoken', 'utf-8')
+ 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, delete config/stravatoken and try again')
+ log.error(`strava token is invalid, deleting ${stravaTokenFile}...`)
+ await fs.unlink(stravaTokenFile)
+ // if the refreshToken has changed, persist it
} else {
- try {
- await fs.writeFile('./config/stravatoken', refreshToken, 'utf-8')
- } catch (error) {
- log.info('can not persist strava token', error)
- }
+ await writeToken(oldRefreshToken, refreshToken)
}
}
if (!accessToken) {
log.error('strava authorization not successful')
- controller.abort()
}
Object.assign(config.headers, { Authorization: `Bearer ${accessToken}` })
@@ -112,6 +112,16 @@ function createAuthorizedConnection (getStravaAuthorizationCode) {
}
}
+ 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
}
diff --git a/config/default.config.js b/config/default.config.js
index 3ac439e..59ff223 100644
--- a/config/default.config.js
+++ b/config/default.config.js
@@ -105,6 +105,9 @@ export default {
// 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
+ // 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: '',
diff --git a/snowpack.config.js b/snowpack.config.js
index dbebdce..83b32bb 100644
--- a/snowpack.config.js
+++ b/snowpack.config.js
@@ -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()
}
}