adds initial strava integration to upload activities
This commit is contained in:
parent
a4a7a1b0a0
commit
15cdf2e22f
|
|
@ -78,4 +78,5 @@ node_modules
|
|||
tmp/
|
||||
build/
|
||||
config/config.js
|
||||
config/stravatoken
|
||||
data/
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
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 { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload } from '../lib/icons.js'
|
||||
|
||||
@customElement('dashboard-actions')
|
||||
export class DashboardActions extends AppElement {
|
||||
|
|
@ -17,13 +17,18 @@ export class DashboardActions extends AppElement {
|
|||
background-color: var(--theme-button-color);
|
||||
border: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
#fullscreen-icon {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
#windowed-icon {
|
||||
|
|
@ -31,7 +36,7 @@ export class DashboardActions extends AppElement {
|
|||
}
|
||||
|
||||
.icon {
|
||||
height: 1.8em;
|
||||
height: 1.7em;
|
||||
}
|
||||
|
||||
.peripheral-mode {
|
||||
|
|
@ -43,7 +48,7 @@ export class DashboardActions extends AppElement {
|
|||
display: none;
|
||||
}
|
||||
#windowed-icon {
|
||||
display: inline;
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
@ -52,6 +57,7 @@ export class DashboardActions extends AppElement {
|
|||
return html`
|
||||
<button @click=${this.reset}>${icon_undo}</button>
|
||||
${this.renderOptionalButtons()}
|
||||
<button @click=${this.uploadTraining}>${icon_upload}</button>
|
||||
<button @click=${this.switchPeripheralMode}>${icon_bluetooth}</button>
|
||||
<div class="peripheral-mode">${this.peripheralMode()}</div>
|
||||
`
|
||||
|
|
@ -114,4 +120,8 @@ export class DashboardActions extends AppElement {
|
|||
switchPeripheralMode () {
|
||||
this.sendEvent('triggerAction', { command: 'switchPeripheralMode' })
|
||||
}
|
||||
|
||||
uploadTraining () {
|
||||
this.sendEvent('triggerAction', { command: 'uploadTraining' })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,15 +90,25 @@ export function createApp (app) {
|
|||
}
|
||||
|
||||
function handleAction (action) {
|
||||
if (action.command === 'switchPeripheralMode') {
|
||||
switch (action.command) {
|
||||
case 'switchPeripheralMode': {
|
||||
if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' }))
|
||||
} else if (action.command === 'reset') {
|
||||
break
|
||||
}
|
||||
case 'reset': {
|
||||
resetFields()
|
||||
if (socket)socket.send(JSON.stringify({ command: 'reset' }))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
case 'uploadTraining': {
|
||||
if (socket)socket.send(JSON.stringify({ command: 'uploadTraining' }))
|
||||
break
|
||||
}
|
||||
default: {
|
||||
console.error('no handler defined for action', action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleAction
|
||||
|
|
|
|||
|
|
@ -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>`
|
||||
|
|
|
|||
|
|
@ -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,34 +34,8 @@ 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
|
||||
})
|
||||
}
|
||||
|
||||
async function createRawDataFile () {
|
||||
const stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
|
||||
|
|
@ -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,40 @@ function createWorkoutRecorder () {
|
|||
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('.')
|
||||
if (versionArray.length < 3) versionArray = [0, 0, 0]
|
||||
const lastStroke = workout.strokes[strokes.length - 1]
|
||||
|
|
@ -164,7 +171,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 +184,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 +199,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 (!canCreateRecordings()) {
|
||||
log.debug('workout is shorter than minimum workout time, skipping creation of recordings...')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -211,10 +215,19 @@ function createWorkoutRecorder () {
|
|||
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 {
|
||||
recordStroke,
|
||||
recordRotationImpulse,
|
||||
canCreateRecordings,
|
||||
handlePause,
|
||||
activeWorkoutToTcx,
|
||||
reset
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -17,6 +17,7 @@ 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'
|
||||
|
||||
// set the log levels
|
||||
log.setLevel(config.loglevel.default)
|
||||
|
|
@ -77,6 +78,7 @@ 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)
|
||||
|
|
@ -123,13 +125,23 @@ if (config.heartrateMonitorANT) {
|
|||
|
||||
const webServer = createWebServer()
|
||||
webServer.on('messageReceived', (message) => {
|
||||
if (message.command === 'reset') {
|
||||
resetWorkout()
|
||||
} else if (message.command === 'switchPeripheralMode') {
|
||||
switch (message.command) {
|
||||
case 'switchPeripheralMode': {
|
||||
peripheralManager.switchPeripheralMode()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
case 'reset': {
|
||||
resetWorkout()
|
||||
break
|
||||
}
|
||||
case 'uploadTraining': {
|
||||
workoutUploader.upload()
|
||||
break
|
||||
}
|
||||
default: {
|
||||
log.warn('invalid command received:', message)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
webServer.on('clientConnected', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -99,5 +99,15 @@ 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,
|
||||
|
||||
// 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: ''
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue