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` + + Upload to Strava? +

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() } }