adds initial strava integration to upload activities

This commit is contained in:
Lars Berning 2022-02-05 12:55:33 +01:00
parent a4a7a1b0a0
commit 15cdf2e22f
No known key found for this signature in database
GPG Key ID: 028E73C9E1D8A0B3
12 changed files with 657 additions and 273 deletions

1
.gitignore vendored
View File

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

View File

@ -7,7 +7,7 @@
import { AppElement, html, css } from './AppElement.js' import { AppElement, html, css } from './AppElement.js'
import { customElement } from 'lit/decorators.js' import { customElement } from 'lit/decorators.js'
import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth } from '../lib/icons.js' import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload } from '../lib/icons.js'
@customElement('dashboard-actions') @customElement('dashboard-actions')
export class DashboardActions extends AppElement { export class DashboardActions extends AppElement {
@ -17,13 +17,18 @@ export class DashboardActions extends AppElement {
background-color: var(--theme-button-color); background-color: var(--theme-button-color);
border: 0; border: 0;
color: var(--theme-font-color); color: var(--theme-font-color);
padding: 0.5em 0.9em 0.3em 0.9em;
margin: 0.2em 0; margin: 0.2em 0;
font-size: 60%; font-size: 60%;
text-align: center;
text-decoration: none; text-decoration: none;
display: inline-block; display: inline-flex;
width: 3.5em; width: 3.5em;
height: 2.5em;
justify-content: center;
align-items: center;
}
#fullscreen-icon {
display: inline-flex;
} }
#windowed-icon { #windowed-icon {
@ -31,7 +36,7 @@ export class DashboardActions extends AppElement {
} }
.icon { .icon {
height: 1.8em; height: 1.7em;
} }
.peripheral-mode { .peripheral-mode {
@ -43,7 +48,7 @@ export class DashboardActions extends AppElement {
display: none; display: none;
} }
#windowed-icon { #windowed-icon {
display: inline; display: inline-flex;
} }
} }
` `
@ -52,6 +57,7 @@ export class DashboardActions extends AppElement {
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>
` `
@ -114,4 +120,8 @@ export class DashboardActions extends AppElement {
switchPeripheralMode () { switchPeripheralMode () {
this.sendEvent('triggerAction', { command: 'switchPeripheralMode' }) this.sendEvent('triggerAction', { command: 'switchPeripheralMode' })
} }
uploadTraining () {
this.sendEvent('triggerAction', { command: 'uploadTraining' })
}
} }

View File

@ -90,13 +90,23 @@ export function createApp (app) {
} }
function handleAction (action) { function handleAction (action) {
if (action.command === 'switchPeripheralMode') { switch (action.command) {
if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' })) case 'switchPeripheralMode': {
} else if (action.command === 'reset') { if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' }))
resetFields() break
if (socket)socket.send(JSON.stringify({ command: 'reset' })) }
} else { case 'reset': {
console.error('no handler defined for action', action) resetFields()
if (socket)socket.send(JSON.stringify({ command: 'reset' }))
break
}
case 'uploadTraining': {
if (socket)socket.send(JSON.stringify({ command: 'uploadTraining' }))
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_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_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_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

@ -8,7 +8,7 @@
*/ */
import log from 'loglevel' import log from 'loglevel'
import zlib from 'zlib' import zlib from 'zlib'
import { mkdir, writeFile } from 'fs/promises' import fs from 'fs/promises'
import xml2js from 'xml2js' import xml2js from 'xml2js'
import config from '../tools/ConfigManager.js' import config from '../tools/ConfigManager.js'
import { promisify } from 'util' import { promisify } from 'util'
@ -34,33 +34,7 @@ function createWorkoutRecorder () {
if (startTime === undefined) { if (startTime === undefined) {
startTime = new Date() startTime = new Date()
} }
// stroke recordings are currently only used to create tcx files, so we can skip it strokes.push(stroke)
// 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
})
} }
async function createRawDataFile () { async function createRawDataFile () {
@ -70,7 +44,7 @@ function createWorkoutRecorder () {
log.info(`saving session as raw data file ${filename}...`) log.info(`saving session as raw data file ${filename}...`)
try { try {
await mkdir(directory, { recursive: true }) await fs.mkdir(directory, { recursive: true })
} catch (error) { } catch (error) {
if (error.code !== 'EEXIST') { if (error.code !== 'EEXIST') {
log.error(`can not create directory ${directory}`, error) log.error(`can not create directory ${directory}`, error)
@ -79,7 +53,40 @@ function createWorkoutRecorder () {
await createFile(rotationImpulses.join('\n'), filename, config.gzipRawDataFiles) await createFile(rotationImpulses.join('\n'), filename, config.gzipRawDataFiles)
} }
async function buildAndSaveTcxFile (workout) { async function createTcxFile () {
const tcxRecord = await activeWorkoutToTcx()
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 () {
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('.') let versionArray = process.env.npm_package_version.split('.')
if (versionArray.length < 3) versionArray = [0, 0, 0] if (versionArray.length < 3) versionArray = [0, 0, 0]
const lastStroke = workout.strokes[strokes.length - 1] const lastStroke = workout.strokes[strokes.length - 1]
@ -164,7 +171,7 @@ function createWorkoutRecorder () {
} }
const builder = new xml2js.Builder() const builder = new xml2js.Builder()
await createFile(builder.buildObject(tcxObject), workout.filename, config.gzipTcxFiles) return builder.buildObject(tcxObject)
} }
async function reset () { async function reset () {
@ -177,9 +184,9 @@ function createWorkoutRecorder () {
async function createFile (content, filename, compress = false) { async function createFile (content, filename, compress = false) {
if (compress) { if (compress) {
const gzipContent = await gzip(content) 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 { } else {
await writeFile(filename, content, (err) => { if (err) log.error(err) }) await fs.writeFile(filename, content, (err) => { if (err) log.error(err) })
} }
} }
@ -192,11 +199,8 @@ function createWorkoutRecorder () {
return return
} }
const minimumRecordingTimeInSeconds = 10 if (!canCreateRecordings()) {
const rotationImpulseTimeTotal = rotationImpulses.reduce((acc, impulse) => acc + impulse, 0) log.debug('workout is shorter than minimum workout time, skipping creation of recordings...')
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...`)
return return
} }
@ -211,10 +215,19 @@ function createWorkoutRecorder () {
await Promise.all(parallelCalls) await Promise.all(parallelCalls)
} }
function canCreateRecordings () {
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 { return {
recordStroke, recordStroke,
recordRotationImpulse, recordRotationImpulse,
canCreateRecordings,
handlePause, handlePause,
activeWorkoutToTcx,
reset reset
} }
} }

View File

@ -0,0 +1,34 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Handles uploading workout data to different cloud providers
*/
import log from 'loglevel'
import { createStravaAPI } from '../tools/StravaAPI.js'
function createWorkoutUploader (workoutRecorder) {
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')
setTimeout(() => { resolve('') }, 10)
})
}
const stravaAPI = createStravaAPI(getStravaAuthorizationCode)
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')
}
}
return {
upload
}
}
export { createWorkoutUploader }

View File

@ -17,6 +17,7 @@ import { createAntManager } from './ant/AntManager.js'
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
import { replayRowingSession } from './tools/RowingRecorder.js' import { replayRowingSession } from './tools/RowingRecorder.js'
import { createWorkoutRecorder } from './engine/WorkoutRecorder.js' import { createWorkoutRecorder } from './engine/WorkoutRecorder.js'
import { createWorkoutUploader } from './engine/WorkoutUploader.js'
// set the log levels // set the log levels
log.setLevel(config.loglevel.default) log.setLevel(config.loglevel.default)
@ -77,6 +78,7 @@ const rowingEngine = createRowingEngine(config.rowerSettings)
const rowingStatistics = createRowingStatistics(config) const rowingStatistics = createRowingStatistics(config)
rowingEngine.notify(rowingStatistics) rowingEngine.notify(rowingStatistics)
const workoutRecorder = createWorkoutRecorder() const workoutRecorder = createWorkoutRecorder()
const workoutUploader = createWorkoutUploader(workoutRecorder)
rowingStatistics.on('driveFinished', (metrics) => { rowingStatistics.on('driveFinished', (metrics) => {
webServer.notifyClients(metrics) webServer.notifyClients(metrics)
@ -123,12 +125,22 @@ if (config.heartrateMonitorANT) {
const webServer = createWebServer() const webServer = createWebServer()
webServer.on('messageReceived', (message) => { webServer.on('messageReceived', (message) => {
if (message.command === 'reset') { switch (message.command) {
resetWorkout() case 'switchPeripheralMode': {
} else if (message.command === 'switchPeripheralMode') { peripheralManager.switchPeripheralMode()
peripheralManager.switchPeripheralMode() break
} else { }
log.warn('invalid command received:', message) case 'reset': {
resetWorkout()
break
}
case 'uploadTraining': {
workoutUploader.upload()
break
}
default: {
log.warn('invalid command received:', message)
}
} }
}) })

View File

@ -0,0 +1,120 @@
'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
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
})
authorizedConnection.interceptors.request.use(async config => {
if (!refreshToken) {
try {
refreshToken = await fs.readFile('./config/stravatoken', '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
if (!refreshToken) {
const authorizationCode = await getStravaAuthorizationCode();
({ accessToken, refreshToken } = await authorize(authorizationCode))
} else {
({ accessToken, refreshToken } = await getAccessTokens(refreshToken))
if (!refreshToken) {
log.error('strava token is invalid, delete config/stravatoken and try again')
} else {
try {
await fs.writeFile('./config/stravatoken', refreshToken, 'utf-8')
} catch (error) {
log.info('can not persist strava token', error)
}
}
}
if (!accessToken) {
log.error('strava authorization not successful')
controller.abort()
}
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
}
}
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,15 @@ export default {
// the device to the profiles. // the device to the profiles.
// !! Only change this setting in the config/config.js file, and leave this on DEFAULT as that // !! 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 // is the fallback for the default profile settings
rowerSettings: rowerProfiles.DEFAULT rowerSettings: rowerProfiles.DEFAULT,
// 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
// The "Client ID" of your Strava API Application
stravaClientId: '',
// The "Client Secret" of your Strava API Application
stravaClientSecret: ''
} }

561
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,7 @@
"@abandonware/noble": "1.9.2-15", "@abandonware/noble": "1.9.2-15",
"ant-plus": "0.1.24", "ant-plus": "0.1.24",
"finalhandler": "1.1.2", "finalhandler": "1.1.2",
"form-data": "4.0.0",
"lit": "2.1.2", "lit": "2.1.2",
"loglevel": "1.8.0", "loglevel": "1.8.0",
"nosleep.js": "0.12.0", "nosleep.js": "0.12.0",
@ -62,6 +63,7 @@
"@rollup/plugin-node-resolve": "13.1.3", "@rollup/plugin-node-resolve": "13.1.3",
"@snowpack/plugin-babel": "2.1.7", "@snowpack/plugin-babel": "2.1.7",
"@web/rollup-plugin-html": "1.10.1", "@web/rollup-plugin-html": "1.10.1",
"axios": "0.25.0",
"eslint": "8.8.0", "eslint": "8.8.0",
"eslint-config-standard": "17.0.0-0", "eslint-config-standard": "17.0.0-0",
"eslint-plugin-import": "2.25.4", "eslint-plugin-import": "2.25.4",