adds initial strava integration to upload activities
This commit is contained in:
parent
a4a7a1b0a0
commit
15cdf2e22f
|
|
@ -78,4 +78,5 @@ node_modules
|
||||||
tmp/
|
tmp/
|
||||||
build/
|
build/
|
||||||
config/config.js
|
config/config.js
|
||||||
|
config/stravatoken
|
||||||
data/
|
data/
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// 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: ''
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue