implements confirmation dialogs, improvements to strava upload

This commit is contained in:
Lars Berning 2022-02-11 22:37:36 +00:00
parent dfc17c09b2
commit 943518b986
No known key found for this signature in database
GPG Key ID: 028E73C9E1D8A0B3
13 changed files with 280 additions and 95 deletions

View File

@ -28,14 +28,14 @@ function createWebServer () {
const wss = new WebSocketServer({ server }) const wss = new WebSocketServer({ server })
wss.on('connection', function connection (ws) { wss.on('connection', function connection (client) {
log.debug('websocket client connected') log.debug('websocket client connected')
emitter.emit('clientConnected', ws) emitter.emit('clientConnected', client)
ws.on('message', function incoming (data) { client.on('message', function incoming (data) {
try { try {
const message = JSON.parse(data) const message = JSON.parse(data)
if (message) { if (message) {
emitter.emit('messageReceived', message) emitter.emit('messageReceived', message, client)
} else { } else {
log.warn(`invalid message received: ${data}`) log.warn(`invalid message received: ${data}`)
} }
@ -43,11 +43,22 @@ function createWebServer () {
log.error(err) log.error(err)
} }
}) })
ws.on('close', function () { client.on('close', function () {
log.debug('websocket client disconnected') 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) { function notifyClients (type, data) {
const messageString = JSON.stringify({ type, data }) const messageString = JSON.stringify({ type, data })
wss.clients.forEach(function each (client) { wss.clients.forEach(function each (client) {
@ -58,6 +69,7 @@ function createWebServer () {
} }
return Object.assign(emitter, { return Object.assign(emitter, {
notifyClient,
notifyClients notifyClients
}) })
} }

View File

@ -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`
<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 { 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 { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload } from '../lib/icons.js'
import './AppDialog.js'
@customElement('dashboard-actions') @customElement('dashboard-actions')
export class DashboardActions extends AppElement { export class DashboardActions extends AppElement {
@ -16,6 +17,7 @@ export class DashboardActions extends AppElement {
outline:none; outline:none;
background-color: var(--theme-button-color); background-color: var(--theme-button-color);
border: 0; border: 0;
border-radius: var(--theme-border-radius);
color: var(--theme-font-color); color: var(--theme-font-color);
margin: 0.2em 0; margin: 0.2em 0;
font-size: 60%; font-size: 60%;
@ -53,13 +55,16 @@ export class DashboardActions extends AppElement {
} }
` `
@state({ type: Object })
dialog
render () { render () {
return html` return html`
<button @click=${this.reset}>${icon_undo}</button> <button @click=${this.reset}>${icon_undo}</button>
${this.renderOptionalButtons()} ${this.renderOptionalButtons()}
<button @click=${this.uploadTraining}>${icon_upload}</button>
<button @click=${this.switchPeripheralMode}>${icon_bluetooth}</button> <button @click=${this.switchPeripheralMode}>${icon_bluetooth}</button>
<div class="peripheral-mode">${this.peripheralMode()}</div> <div class="peripheral-mode">${this.peripheralMode()}</div>
${this.dialog ? this.dialog : ''}
` `
} }
@ -84,17 +89,25 @@ export class DashboardActions extends AppElement {
<button @click=${this.close}>${icon_poweroff}</button> <button @click=${this.close}>${icon_poweroff}</button>
`) `)
} }
if (this.appState.config.stravaUploadEnabled) {
buttons.push(html`
<button @click=${this.uploadTraining}>${icon_upload}</button>
`)
}
return buttons return buttons
} }
peripheralMode () { peripheralMode () {
const value = this.appState?.peripheralMode const value = this.appState?.config?.peripheralMode
if (value === 'PM5') { if (value === 'PM5') {
return 'C2 PM5' return 'C2 PM5'
} else if (value === 'FTMSBIKE') { } else if (value === 'FTMSBIKE') {
return 'FTMS Bike' return 'FTMS Bike'
} else { } else if (value === 'FTMS') {
return 'FTMS Rower' return 'FTMS Rower'
} else {
return ''
} }
} }
@ -122,6 +135,17 @@ export class DashboardActions extends AppElement {
} }
uploadTraining () { uploadTraining () {
this.dialog = html`
<app-dialog @close=${dialogClosed}>
<legend>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' }) this.sendEvent('triggerAction', { command: 'uploadTraining' })
} }
} }
}
}

View File

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

View File

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

View File

@ -70,6 +70,10 @@ export function createApp (app) {
} }
const data = message.data const data = message.data
switch (message.type) { switch (message.type) {
case 'config': {
app.updateState({ ...app.getState(), config: data })
break
}
case 'metrics': { case 'metrics': {
let activeFields = rowingMetricsFields let activeFields = rowingMetricsFields
// if we are in reset state only update heart rate // if we are in reset state only update heart rate
@ -78,11 +82,7 @@ export function createApp (app) {
} }
const filteredData = filterObjectByKeys(data, activeFields) const filteredData = filterObjectByKeys(data, activeFields)
let updatedState = { ...app.getState(), metrics: filteredData } app.updateState({ ...app.getState(), metrics: filteredData })
if (data.peripheralMode) {
updatedState = { ...app.getState(), peripheralMode: data.peripheralMode }
}
app.updateState(updatedState)
break break
} }
case 'authorizeStrava': { case 'authorizeStrava': {
@ -91,7 +91,7 @@ export function createApp (app) {
break break
} }
default: { default: {
console.error(`unknown message type: ${message.type}`) console.error(`unknown message type: ${message.type}`, message.data)
} }
} }
} catch (err) { } catch (err) {

View File

@ -8,8 +8,12 @@
export const APP_STATE = { export const APP_STATE = {
// currently can be STANDALONE (Mobile Home Screen App), KIOSK (Raspberry Pi deployment) or '' (default) // currently can be STANDALONE (Mobile Home Screen App), KIOSK (Raspberry Pi deployment) or '' (default)
appMode: '', appMode: '',
// currently can be FTMS, FTMSBIKE or PM5
peripheralMode: 'FTMS',
// contains all the rowing metrics that are delivered from the backend // 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
}
} }

View File

@ -55,6 +55,10 @@ function createWorkoutRecorder () {
async function createTcxFile () { async function createTcxFile () {
const tcxRecord = await activeWorkoutToTcx() 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 directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
const filename = `${directory}/${tcxRecord.filename}${config.gzipTcxFiles ? '.gz' : ''}` const filename = `${directory}/${tcxRecord.filename}${config.gzipTcxFiles ? '.gz' : ''}`
log.info(`saving session as tcx file ${filename}...`) log.info(`saving session as tcx file ${filename}...`)
@ -71,6 +75,8 @@ function createWorkoutRecorder () {
} }
async function activeWorkoutToTcx () { 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 stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
const filename = `${stringifiedStartTime}_rowing.tcx` const filename = `${stringifiedStartTime}_rowing.tcx`
@ -199,8 +205,8 @@ function createWorkoutRecorder () {
return return
} }
if (!canCreateRecordings()) { if (!minimumRecordingTimeHasPassed()) {
log.debug('workout is shorter than minimum workout time, skipping creation of recordings...') log.debug('workout is shorter than minimum workout time, skipping automatic creation of recordings...')
return return
} }
@ -215,7 +221,7 @@ function createWorkoutRecorder () {
await Promise.all(parallelCalls) await Promise.all(parallelCalls)
} }
function canCreateRecordings () { function minimumRecordingTimeHasPassed () {
const minimumRecordingTimeInSeconds = 10 const minimumRecordingTimeInSeconds = 10
const rotationImpulseTimeTotal = rotationImpulses.reduce((acc, impulse) => acc + impulse, 0) const rotationImpulseTimeTotal = rotationImpulses.reduce((acc, impulse) => acc + impulse, 0)
const strokeTimeTotal = strokes.reduce((acc, stroke) => acc + stroke.strokeTime, 0) const strokeTimeTotal = strokes.reduce((acc, stroke) => acc + stroke.strokeTime, 0)
@ -225,7 +231,6 @@ function createWorkoutRecorder () {
return { return {
recordStroke, recordStroke,
recordRotationImpulse, recordRotationImpulse,
canCreateRecordings,
handlePause, handlePause,
activeWorkoutToTcx, activeWorkoutToTcx,
reset reset

View File

@ -13,11 +13,11 @@ function createWorkoutUploader (workoutRecorder) {
const emitter = new EventEmitter() const emitter = new EventEmitter()
let stravaAuthorizationCodeResolver let stravaAuthorizationCodeResolver
let requestingClient
function getStravaAuthorizationCode () { function getStravaAuthorizationCode () {
return new Promise((resolve) => { 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 }, requestingClient)
emitter.emit('authorizeStrava', { stravaClientId: config.stravaClientId })
stravaAuthorizationCodeResolver = resolve stravaAuthorizationCodeResolver = resolve
}) })
} }
@ -31,12 +31,20 @@ function createWorkoutUploader (workoutRecorder) {
} }
} }
async function upload () { async function upload (client) {
if (workoutRecorder.canCreateRecordings()) {
log.debug('uploading workout to strava...') log.debug('uploading workout to strava...')
await stravaAPI.uploadActivityTcx(await workoutRecorder.activeWorkoutToTcx()) 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 { } else {
log.debug('workout is shorter than minimum workout time, skipping upload') log.error('can not upload an empty workout to strava')
}
} catch (error) {
log.error('can not upload workout to strava:', error.message)
} }
} }

View File

@ -52,7 +52,7 @@ peripheralManager.on('control', (event) => {
peripheralManager.notifyStatus({ name: 'startedOrResumedByUser' }) peripheralManager.notifyStatus({ name: 'startedOrResumedByUser' })
event.res = true event.res = true
} else if (event?.req?.name === 'peripheralMode') { } else if (event?.req?.name === 'peripheralMode') {
webServer.notifyClients('metrics', { peripheralMode: event.req.peripheralMode }) webServer.notifyClients('config', getConfig())
event.res = true event.res = true
} else { } else {
log.info('unhandled Command', event.req) log.info('unhandled Command', event.req)
@ -123,12 +123,16 @@ if (config.heartrateMonitorANT) {
}) })
} }
workoutUploader.on('authorizeStrava', (data) => { workoutUploader.on('authorizeStrava', (data, client) => {
webServer.notifyClients('authorizeStrava', data) webServer.notifyClient(client, 'authorizeStrava', data)
})
workoutUploader.on('resetWorkout', () => {
resetWorkout()
}) })
const webServer = createWebServer() const webServer = createWebServer()
webServer.on('messageReceived', (message) => { webServer.on('messageReceived', (message, client) => {
switch (message.command) { switch (message.command) {
case 'switchPeripheralMode': { case 'switchPeripheralMode': {
peripheralManager.switchPeripheralMode() peripheralManager.switchPeripheralMode()
@ -139,7 +143,7 @@ webServer.on('messageReceived', (message) => {
break break
} }
case 'uploadTraining': { case 'uploadTraining': {
workoutUploader.upload() workoutUploader.upload(client)
break break
} }
case 'stravaAuthorizationCode': { case 'stravaAuthorizationCode': {
@ -152,10 +156,18 @@ webServer.on('messageReceived', (message) => {
} }
}) })
webServer.on('clientConnected', () => { webServer.on('clientConnected', (client) => {
webServer.notifyClients('metrics', { peripheralMode: peripheralManager.getPeripheralMode() }) 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, { replayRowingSession(handleRotationImpulse, {
filename: 'recordings/WRX700_2magnets.csv', filename: 'recordings/WRX700_2magnets.csv',

View File

@ -12,45 +12,45 @@ import fs from 'fs/promises'
const clientId = config.stravaClientId const clientId = config.stravaClientId
const clientSecret = config.stravaClientSecret const clientSecret = config.stravaClientSecret
const stravaTokenFile = './config/stravatoken'
function createAuthorizedConnection (getStravaAuthorizationCode) { function createAuthorizedConnection (getStravaAuthorizationCode) {
const controller = new AbortController()
let accessToken let accessToken
let refreshToken let refreshToken
const authorizedConnection = axios.create({ const authorizedConnection = axios.create({
baseURL: 'https://www.strava.com/api/v3', baseURL: 'https://www.strava.com/api/v3'
signal: controller.signal
}) })
authorizedConnection.interceptors.request.use(async config => { authorizedConnection.interceptors.request.use(async config => {
if (!refreshToken) { if (!refreshToken) {
try { try {
refreshToken = await fs.readFile('./config/stravatoken', 'utf-8') refreshToken = await fs.readFile(stravaTokenFile, 'utf-8')
} catch (error) { } catch (error) {
log.info('no strava token available yet') log.info('no strava token available yet')
} }
} }
// if no refresh token is set, then the app has not yet been authorized with Strava // if no refresh token is set, then the app has not yet been authorized with Strava
// start oAuth authorization process
if (!refreshToken) { if (!refreshToken) {
const authorizationCode = await getStravaAuthorizationCode(); const authorizationCode = await getStravaAuthorizationCode();
({ accessToken, refreshToken } = await authorize(authorizationCode)) ({ accessToken, refreshToken } = await authorize(authorizationCode))
await writeToken('', refreshToken)
// otherwise we just need to get a valid accessToken
} else { } else {
const oldRefreshToken = refreshToken;
({ accessToken, refreshToken } = await getAccessTokens(refreshToken)) ({ accessToken, refreshToken } = await getAccessTokens(refreshToken))
if (!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 { } else {
try { await writeToken(oldRefreshToken, refreshToken)
await fs.writeFile('./config/stravatoken', refreshToken, 'utf-8')
} catch (error) {
log.info('can not persist strava token', error)
}
} }
} }
if (!accessToken) { if (!accessToken) {
log.error('strava authorization not successful') log.error('strava authorization not successful')
controller.abort()
} }
Object.assign(config.headers, { Authorization: `Bearer ${accessToken}` }) 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 return authorizedConnection
} }

View File

@ -105,6 +105,9 @@ export default {
// Note that these values are not your Strava credentials // Note that these values are not your Strava credentials
// Instead you have to create a Strava API Application as described here: // 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 // 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 // The "Client ID" of your Strava API Application
stravaClientId: '', stravaClientId: '',

View File

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