Merge pull request #66 from laberning/strava_upload
adds new features Strava upload and Device Shutdown
This commit is contained in:
commit
c8c0719ec0
|
|
@ -78,4 +78,5 @@ node_modules
|
|||
tmp/
|
||||
build/
|
||||
config/config.js
|
||||
config/stravatoken
|
||||
data/
|
||||
|
|
|
|||
|
|
@ -28,14 +28,14 @@ function createWebServer () {
|
|||
|
||||
const wss = new WebSocketServer({ server })
|
||||
|
||||
wss.on('connection', function connection (ws) {
|
||||
wss.on('connection', function connection (client) {
|
||||
log.debug('websocket client connected')
|
||||
emitter.emit('clientConnected', ws)
|
||||
ws.on('message', function incoming (data) {
|
||||
emitter.emit('clientConnected', client)
|
||||
client.on('message', function incoming (data) {
|
||||
try {
|
||||
const message = JSON.parse(data)
|
||||
if (message) {
|
||||
emitter.emit('messageReceived', message)
|
||||
emitter.emit('messageReceived', message, client)
|
||||
} else {
|
||||
log.warn(`invalid message received: ${data}`)
|
||||
}
|
||||
|
|
@ -43,13 +43,24 @@ function createWebServer () {
|
|||
log.error(err)
|
||||
}
|
||||
})
|
||||
ws.on('close', function () {
|
||||
client.on('close', function () {
|
||||
log.debug('websocket client disconnected')
|
||||
})
|
||||
})
|
||||
|
||||
function notifyClients (message) {
|
||||
const messageString = JSON.stringify(message)
|
||||
function notifyClient (client, type, data) {
|
||||
const messageString = JSON.stringify({ type, data })
|
||||
if (wss.clients.has(client)) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(messageString)
|
||||
}
|
||||
} else {
|
||||
log.error('trying to send message to a client that does not exist')
|
||||
}
|
||||
}
|
||||
|
||||
function notifyClients (type, data) {
|
||||
const messageString = JSON.stringify({ type, data })
|
||||
wss.clients.forEach(function each (client) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(messageString)
|
||||
|
|
@ -58,6 +69,7 @@ function createWebServer () {
|
|||
}
|
||||
|
||||
return Object.assign(emitter, {
|
||||
notifyClient,
|
||||
notifyClients
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
'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 {
|
||||
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%;
|
||||
}
|
||||
dialog::backdrop {
|
||||
background: none;
|
||||
backdrop-filter: contrast(15%) blur(2px);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
button:hover {
|
||||
filter: brightness(150%);
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,9 @@
|
|||
*/
|
||||
|
||||
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 { customElement, state } from 'lit/decorators.js'
|
||||
import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload } from '../lib/icons.js'
|
||||
import './AppDialog.js'
|
||||
|
||||
@customElement('dashboard-actions')
|
||||
export class DashboardActions extends AppElement {
|
||||
|
|
@ -16,14 +17,23 @@ export class DashboardActions extends AppElement {
|
|||
outline:none;
|
||||
background-color: var(--theme-button-color);
|
||||
border: 0;
|
||||
border-radius: var(--theme-border-radius);
|
||||
color: var(--theme-font-color);
|
||||
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;
|
||||
}
|
||||
button:hover {
|
||||
filter: brightness(150%);
|
||||
}
|
||||
|
||||
#fullscreen-icon {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
#windowed-icon {
|
||||
|
|
@ -31,7 +41,7 @@ export class DashboardActions extends AppElement {
|
|||
}
|
||||
|
||||
.icon {
|
||||
height: 1.8em;
|
||||
height: 1.7em;
|
||||
}
|
||||
|
||||
.peripheral-mode {
|
||||
|
|
@ -43,18 +53,22 @@ export class DashboardActions extends AppElement {
|
|||
display: none;
|
||||
}
|
||||
#windowed-icon {
|
||||
display: inline;
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@state({ type: Object })
|
||||
dialog
|
||||
|
||||
render () {
|
||||
return html`
|
||||
<button @click=${this.reset}>${icon_undo}</button>
|
||||
${this.renderOptionalButtons()}
|
||||
<button @click=${this.switchPeripheralMode}>${icon_bluetooth}</button>
|
||||
<div class="peripheral-mode">${this.peripheralMode()}</div>
|
||||
`
|
||||
<button @click=${this.reset}>${icon_undo}</button>
|
||||
${this.renderOptionalButtons()}
|
||||
<button @click=${this.switchPeripheralMode}>${icon_bluetooth}</button>
|
||||
<div class="peripheral-mode">${this.peripheralMode()}</div>
|
||||
${this.dialog ? this.dialog : ''}
|
||||
`
|
||||
}
|
||||
|
||||
renderOptionalButtons () {
|
||||
|
|
@ -62,33 +76,41 @@ export class DashboardActions extends AppElement {
|
|||
// changing to fullscreen mode only makes sence when the app is openend in a regular
|
||||
// webbrowser (kiosk and standalone mode are always in fullscreen view) and if the
|
||||
// browser supports this feature
|
||||
if (this.appState.appMode === 'BROWSER' && document.documentElement.requestFullscreen) {
|
||||
if (this.appState?.appMode === 'BROWSER' && document.documentElement.requestFullscreen) {
|
||||
buttons.push(html`
|
||||
<button @click=${this.toggleFullscreen}>
|
||||
<div id="fullscreen-icon">${icon_expand}</div>
|
||||
<div id="windowed-icon">${icon_compress}</div>
|
||||
</button>
|
||||
`)
|
||||
<button @click=${this.toggleFullscreen}>
|
||||
<div id="fullscreen-icon">${icon_expand}</div>
|
||||
<div id="windowed-icon">${icon_compress}</div>
|
||||
</button>
|
||||
`)
|
||||
}
|
||||
// a shutdown button only makes sence when the app is openend as app on a mobile
|
||||
// device. at some point we might also think of using this to power down the raspi
|
||||
// when we are running in kiosk mode
|
||||
if (this.appState.appMode === 'STANDALONE') {
|
||||
// add a button to power down the device, if browser is running on the device in kiosk mode
|
||||
// and the shutdown feature is enabled
|
||||
// (might also make sence to enable this for all clients but then we would need visual feedback)
|
||||
if (this.appState?.appMode === 'KIOSK' && this.appState?.config?.shutdownEnabled) {
|
||||
buttons.push(html`
|
||||
<button @click=${this.close}>${icon_poweroff}</button>
|
||||
`)
|
||||
<button @click=${this.shutdown}>${icon_poweroff}</button>
|
||||
`)
|
||||
}
|
||||
|
||||
if (this.appState?.config?.stravaUploadEnabled) {
|
||||
buttons.push(html`
|
||||
<button @click=${this.uploadTraining}>${icon_upload}</button>
|
||||
`)
|
||||
}
|
||||
return buttons
|
||||
}
|
||||
|
||||
peripheralMode () {
|
||||
const value = this.appState?.peripheralMode
|
||||
const value = this.appState?.config?.peripheralMode
|
||||
if (value === 'PM5') {
|
||||
return 'C2 PM5'
|
||||
} else if (value === 'FTMSBIKE') {
|
||||
return 'FTMS Bike'
|
||||
} else {
|
||||
} else if (value === 'FTMS') {
|
||||
return 'FTMS Rower'
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -103,10 +125,6 @@ export class DashboardActions extends AppElement {
|
|||
}
|
||||
}
|
||||
|
||||
close () {
|
||||
window.close()
|
||||
}
|
||||
|
||||
reset () {
|
||||
this.sendEvent('triggerAction', { command: 'reset' })
|
||||
}
|
||||
|
|
@ -114,4 +132,34 @@ export class DashboardActions extends AppElement {
|
|||
switchPeripheralMode () {
|
||||
this.sendEvent('triggerAction', { command: 'switchPeripheralMode' })
|
||||
}
|
||||
|
||||
uploadTraining () {
|
||||
this.dialog = html`
|
||||
<app-dialog @close=${dialogClosed}>
|
||||
<legend>${icon_upload}<br/>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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shutdown () {
|
||||
this.dialog = html`
|
||||
<app-dialog @close=${dialogClosed}>
|
||||
<legend>${icon_poweroff}<br/>Shutdown Open Rowing Monitor?</legend>
|
||||
<p>Do you want to shutdown the device?</p>
|
||||
</app-dialog>
|
||||
`
|
||||
function dialogClosed (event) {
|
||||
this.dialog = undefined
|
||||
if (event.detail === 'confirm') {
|
||||
this.sendEvent('triggerAction', { command: 'shutdown' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export class PerformanceDashboard extends AppElement {
|
|||
text-align: center;
|
||||
position: relative;
|
||||
padding: 0.5em 0.2em 0 0.2em;
|
||||
border-radius: var(--theme-border-radius);
|
||||
}
|
||||
|
||||
dashboard-actions {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
--theme-font-family: Verdana, "Lucida Sans Unicode", sans-serif;
|
||||
--theme-font-color: #f5f5f5;
|
||||
--theme-warning-color: #ff0000;
|
||||
--theme-border-radius: 3px;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
|
|||
|
|
@ -12,23 +12,40 @@ const rowingMetricsFields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', '
|
|||
'heartrateBatteryLevel', 'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted']
|
||||
|
||||
export function createApp (app) {
|
||||
const mode = window.location.hash
|
||||
const appMode = mode === '#:standalone:' ? 'STANDALONE' : mode === '#:kiosk:' ? 'KIOSK' : 'BROWSER'
|
||||
const urlParameters = new URLSearchParams(window.location.search)
|
||||
const mode = urlParameters.get('mode')
|
||||
const appMode = mode === 'standalone' ? 'STANDALONE' : mode === 'kiosk' ? 'KIOSK' : 'BROWSER'
|
||||
app.updateState({ ...app.getState(), appMode })
|
||||
|
||||
const stravaAuthorizationCode = urlParameters.get('code')
|
||||
|
||||
let socket
|
||||
|
||||
initWebsocket()
|
||||
resetFields()
|
||||
requestWakeLock()
|
||||
|
||||
function websocketOpened () {
|
||||
if (stravaAuthorizationCode) {
|
||||
handleStravaAuthorization(stravaAuthorizationCode)
|
||||
}
|
||||
}
|
||||
|
||||
function handleStravaAuthorization (stravaAuthorizationCode) {
|
||||
if (socket)socket.send(JSON.stringify({ command: 'stravaAuthorizationCode', data: stravaAuthorizationCode }))
|
||||
}
|
||||
|
||||
let initialWebsocketOpenend = true
|
||||
function initWebsocket () {
|
||||
// use the native websocket implementation of browser to communicate with backend
|
||||
// eslint-disable-next-line no-undef
|
||||
socket = new WebSocket(`ws://${location.host}/websocket`)
|
||||
|
||||
socket.addEventListener('open', (event) => {
|
||||
console.log('websocket opened')
|
||||
if (initialWebsocketOpenend) {
|
||||
websocketOpened()
|
||||
initialWebsocketOpenend = false
|
||||
}
|
||||
})
|
||||
|
||||
socket.addEventListener('error', (error) => {
|
||||
|
|
@ -46,21 +63,37 @@ export function createApp (app) {
|
|||
// todo: we should use different types of messages to make processing easier
|
||||
socket.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
let activeFields = rowingMetricsFields
|
||||
// if we are in reset state only update heart rate
|
||||
if (data.strokesTotal === 0) {
|
||||
activeFields = ['heartrate', 'heartrateBatteryLevel']
|
||||
const message = JSON.parse(event.data)
|
||||
if (!message.type) {
|
||||
console.error('message does not contain messageType specifier', message)
|
||||
return
|
||||
}
|
||||
const data = message.data
|
||||
switch (message.type) {
|
||||
case 'config': {
|
||||
app.updateState({ ...app.getState(), config: data })
|
||||
break
|
||||
}
|
||||
case 'metrics': {
|
||||
let activeFields = rowingMetricsFields
|
||||
// if we are in reset state only update heart rate
|
||||
if (data.strokesTotal === 0) {
|
||||
activeFields = ['heartrate', 'heartrateBatteryLevel']
|
||||
}
|
||||
|
||||
const filteredData = filterObjectByKeys(data, activeFields)
|
||||
|
||||
let updatedState = { ...app.getState(), metrics: filteredData }
|
||||
if (data.peripheralMode) {
|
||||
updatedState = { ...app.getState(), peripheralMode: data.peripheralMode }
|
||||
const filteredData = filterObjectByKeys(data, activeFields)
|
||||
app.updateState({ ...app.getState(), metrics: filteredData })
|
||||
break
|
||||
}
|
||||
case 'authorizeStrava': {
|
||||
const currentUrl = encodeURIComponent(window.location.href)
|
||||
window.location.href = `https://www.strava.com/oauth/authorize?client_id=${data.stravaClientId}&response_type=code&redirect_uri=${currentUrl}&approval_prompt=force&scope=activity:write`
|
||||
break
|
||||
}
|
||||
default: {
|
||||
console.error(`unknown message type: ${message.type}`, message.data)
|
||||
}
|
||||
}
|
||||
app.updateState(updatedState)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
|
|
@ -90,13 +123,27 @@ export function createApp (app) {
|
|||
}
|
||||
|
||||
function handleAction (action) {
|
||||
if (action.command === 'switchPeripheralMode') {
|
||||
if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' }))
|
||||
} else if (action.command === 'reset') {
|
||||
resetFields()
|
||||
if (socket)socket.send(JSON.stringify({ command: 'reset' }))
|
||||
} else {
|
||||
console.error('no handler defined for action', action)
|
||||
switch (action.command) {
|
||||
case 'switchPeripheralMode': {
|
||||
if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' }))
|
||||
break
|
||||
}
|
||||
case 'reset': {
|
||||
resetFields()
|
||||
if (socket)socket.send(JSON.stringify({ command: 'reset' }))
|
||||
break
|
||||
}
|
||||
case 'uploadTraining': {
|
||||
if (socket)socket.send(JSON.stringify({ command: 'uploadTraining' }))
|
||||
break
|
||||
}
|
||||
case 'shutdown': {
|
||||
if (socket)socket.send(JSON.stringify({ command: 'shutdown' }))
|
||||
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_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>`
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"background_color": "#0059B3",
|
||||
"background_color": "#002b57",
|
||||
"display": "fullscreen",
|
||||
"orientation": "any",
|
||||
"start_url": "/#:standalone:"
|
||||
"start_url": "/?mode=standalone"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,14 @@
|
|||
export const APP_STATE = {
|
||||
// currently can be STANDALONE (Mobile Home Screen App), KIOSK (Raspberry Pi deployment) or '' (default)
|
||||
appMode: '',
|
||||
// currently can be FTMS, FTMSBIKE or PM5
|
||||
peripheralMode: 'FTMS',
|
||||
// contains all the rowing metrics that are delivered from the backend
|
||||
metrics: {}
|
||||
metrics: {},
|
||||
config: {
|
||||
// currently can be FTMS, FTMSBIKE or PM5
|
||||
peripheralMode: '',
|
||||
// true if upload to strava is enabled
|
||||
stravaUploadEnabled: false,
|
||||
// true if remote device shutdown is enabled
|
||||
shutdownEnabled: false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,33 +34,7 @@ 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
|
||||
})
|
||||
strokes.push(stroke)
|
||||
}
|
||||
|
||||
async function createRawDataFile () {
|
||||
|
|
@ -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,46 @@ function createWorkoutRecorder () {
|
|||
await createFile(rotationImpulses.join('\n'), filename, config.gzipRawDataFiles)
|
||||
}
|
||||
|
||||
async function buildAndSaveTcxFile (workout) {
|
||||
async function createTcxFile () {
|
||||
const tcxRecord = await activeWorkoutToTcx()
|
||||
if (tcxRecord === undefined) {
|
||||
log.error('error creating tcx file')
|
||||
return
|
||||
}
|
||||
const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
|
||||
const filename = `${directory}/${tcxRecord.filename}${config.gzipTcxFiles ? '.gz' : ''}`
|
||||
log.info(`saving session as tcx file ${filename}...`)
|
||||
|
||||
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 () {
|
||||
// we need at least two strokes to generate a valid tcx file
|
||||
if (strokes.length < 2) return
|
||||
const stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
|
||||
const filename = `${stringifiedStartTime}_rowing.tcx`
|
||||
|
||||
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 +177,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 +190,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 +205,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 (!minimumRecordingTimeHasPassed()) {
|
||||
log.debug('workout is shorter than minimum workout time, skipping automatic creation of recordings...')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -211,10 +221,18 @@ function createWorkoutRecorder () {
|
|||
await Promise.all(parallelCalls)
|
||||
}
|
||||
|
||||
function minimumRecordingTimeHasPassed () {
|
||||
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,
|
||||
handlePause,
|
||||
activeWorkoutToTcx,
|
||||
reset
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
'use strict'
|
||||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
Handles uploading workout data to different cloud providers
|
||||
*/
|
||||
import log from 'loglevel'
|
||||
import EventEmitter from 'events'
|
||||
import { createStravaAPI } from '../tools/StravaAPI.js'
|
||||
import config from '../tools/ConfigManager.js'
|
||||
|
||||
function createWorkoutUploader (workoutRecorder) {
|
||||
const emitter = new EventEmitter()
|
||||
|
||||
let stravaAuthorizationCodeResolver
|
||||
let requestingClient
|
||||
|
||||
function getStravaAuthorizationCode () {
|
||||
return new Promise((resolve) => {
|
||||
emitter.emit('authorizeStrava', { stravaClientId: config.stravaClientId }, requestingClient)
|
||||
stravaAuthorizationCodeResolver = resolve
|
||||
})
|
||||
}
|
||||
|
||||
const stravaAPI = createStravaAPI(getStravaAuthorizationCode)
|
||||
|
||||
function stravaAuthorizationCode (stravaAuthorizationCode) {
|
||||
if (stravaAuthorizationCodeResolver) {
|
||||
stravaAuthorizationCodeResolver(stravaAuthorizationCode)
|
||||
stravaAuthorizationCodeResolver = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function upload (client) {
|
||||
log.debug('uploading workout to strava...')
|
||||
try {
|
||||
requestingClient = client
|
||||
// todo: we might signal back to the client whether we had success or not
|
||||
const tcxActivity = await workoutRecorder.activeWorkoutToTcx()
|
||||
if (tcxActivity !== undefined) {
|
||||
await stravaAPI.uploadActivityTcx(tcxActivity)
|
||||
emitter.emit('resetWorkout')
|
||||
} else {
|
||||
log.error('can not upload an empty workout to strava')
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('can not upload workout to strava:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
return Object.assign(emitter, {
|
||||
upload,
|
||||
stravaAuthorizationCode
|
||||
})
|
||||
}
|
||||
|
||||
export { createWorkoutUploader }
|
||||
|
|
@ -6,7 +6,8 @@
|
|||
everything together while figuring out the physics and model of the application.
|
||||
todo: refactor this as we progress
|
||||
*/
|
||||
import { fork } from 'child_process'
|
||||
import child_process from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import log from 'loglevel'
|
||||
import config from './tools/ConfigManager.js'
|
||||
import { createRowingEngine } from './engine/RowingEngine.js'
|
||||
|
|
@ -17,6 +18,8 @@ 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'
|
||||
const exec = promisify(child_process.exec)
|
||||
|
||||
// set the log levels
|
||||
log.setLevel(config.loglevel.default)
|
||||
|
|
@ -51,7 +54,7 @@ peripheralManager.on('control', (event) => {
|
|||
peripheralManager.notifyStatus({ name: 'startedOrResumedByUser' })
|
||||
event.res = true
|
||||
} else if (event?.req?.name === 'peripheralMode') {
|
||||
webServer.notifyClients({ peripheralMode: event.req.peripheralMode })
|
||||
webServer.notifyClients('config', getConfig())
|
||||
event.res = true
|
||||
} else {
|
||||
log.info('unhandled Command', event.req)
|
||||
|
|
@ -65,7 +68,7 @@ function resetWorkout () {
|
|||
peripheralManager.notifyStatus({ name: 'reset' })
|
||||
}
|
||||
|
||||
const gpioTimerService = fork('./app/gpio/GpioTimerService.js')
|
||||
const gpioTimerService = child_process.fork('./app/gpio/GpioTimerService.js')
|
||||
gpioTimerService.on('message', handleRotationImpulse)
|
||||
|
||||
function handleRotationImpulse (dataPoint) {
|
||||
|
|
@ -77,9 +80,10 @@ 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)
|
||||
webServer.notifyClients('metrics', metrics)
|
||||
peripheralManager.notifyMetrics('strokeStateChanged', metrics)
|
||||
})
|
||||
|
||||
|
|
@ -88,7 +92,7 @@ rowingStatistics.on('recoveryFinished', (metrics) => {
|
|||
`, split: ${metrics.splitFormatted}, ratio: ${metrics.powerRatio.toFixed(2)}, dist: ${metrics.distanceTotal.toFixed(1)}m` +
|
||||
`, cal: ${metrics.caloriesTotal.toFixed(1)}kcal, SPM: ${metrics.strokesPerMinute.toFixed(1)}, speed: ${metrics.speed.toFixed(2)}km/h` +
|
||||
`, cal/hour: ${metrics.caloriesPerHour.toFixed(1)}kcal, cal/minute: ${metrics.caloriesPerMinute.toFixed(1)}kcal`)
|
||||
webServer.notifyClients(metrics)
|
||||
webServer.notifyClients('metrics', metrics)
|
||||
peripheralManager.notifyMetrics('strokeFinished', metrics)
|
||||
if (metrics.sessionState === 'rowing') {
|
||||
workoutRecorder.recordStroke(metrics)
|
||||
|
|
@ -96,7 +100,7 @@ rowingStatistics.on('recoveryFinished', (metrics) => {
|
|||
})
|
||||
|
||||
rowingStatistics.on('webMetricsUpdate', (metrics) => {
|
||||
webServer.notifyClients(metrics)
|
||||
webServer.notifyClients('metrics', metrics)
|
||||
})
|
||||
|
||||
rowingStatistics.on('peripheralMetricsUpdate', (metrics) => {
|
||||
|
|
@ -108,7 +112,7 @@ rowingStatistics.on('rowingPaused', () => {
|
|||
})
|
||||
|
||||
if (config.heartrateMonitorBLE) {
|
||||
const bleCentralService = fork('./app/ble/CentralService.js')
|
||||
const bleCentralService = child_process.fork('./app/ble/CentralService.js')
|
||||
bleCentralService.on('message', (heartrateMeasurement) => {
|
||||
rowingStatistics.handleHeartrateMeasurement(heartrateMeasurement)
|
||||
})
|
||||
|
|
@ -121,21 +125,67 @@ if (config.heartrateMonitorANT) {
|
|||
})
|
||||
}
|
||||
|
||||
workoutUploader.on('authorizeStrava', (data, client) => {
|
||||
webServer.notifyClient(client, 'authorizeStrava', data)
|
||||
})
|
||||
|
||||
workoutUploader.on('resetWorkout', () => {
|
||||
resetWorkout()
|
||||
})
|
||||
|
||||
const webServer = createWebServer()
|
||||
webServer.on('messageReceived', (message) => {
|
||||
if (message.command === 'reset') {
|
||||
resetWorkout()
|
||||
} else if (message.command === 'switchPeripheralMode') {
|
||||
peripheralManager.switchPeripheralMode()
|
||||
} else {
|
||||
log.warn('invalid command received:', message)
|
||||
webServer.on('messageReceived', async (message, client) => {
|
||||
switch (message.command) {
|
||||
case 'switchPeripheralMode': {
|
||||
peripheralManager.switchPeripheralMode()
|
||||
break
|
||||
}
|
||||
case 'reset': {
|
||||
resetWorkout()
|
||||
break
|
||||
}
|
||||
case 'uploadTraining': {
|
||||
workoutUploader.upload(client)
|
||||
break
|
||||
}
|
||||
case 'shutdown': {
|
||||
if (getConfig().shutdownEnabled) {
|
||||
console.info('shutting down device...')
|
||||
try {
|
||||
const { stdout, stderr } = await exec(config.shutdownCommand)
|
||||
if (stderr) {
|
||||
log.error('can not shutdown: ', stderr)
|
||||
}
|
||||
log.info(stdout)
|
||||
} catch (error) {
|
||||
log.error('can not shutdown: ', error)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'stravaAuthorizationCode': {
|
||||
workoutUploader.stravaAuthorizationCode(message.data)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
log.warn('invalid command received:', message)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
webServer.on('clientConnected', () => {
|
||||
webServer.notifyClients({ peripheralMode: peripheralManager.getPeripheralMode() })
|
||||
webServer.on('clientConnected', (client) => {
|
||||
webServer.notifyClient(client, 'config', getConfig())
|
||||
})
|
||||
|
||||
// todo: extract this into some kind of state manager
|
||||
function getConfig () {
|
||||
return {
|
||||
peripheralMode: peripheralManager.getPeripheralMode(),
|
||||
stravaUploadEnabled: !!config.stravaClientId && !!config.stravaClientSecret,
|
||||
shutdownEnabled: !!config.shutdownCommand
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
replayRowingSession(handleRotationImpulse, {
|
||||
filename: 'recordings/WRX700_2magnets.csv',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
'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
|
||||
const stravaTokenFile = './config/stravatoken'
|
||||
|
||||
function createAuthorizedConnection (getStravaAuthorizationCode) {
|
||||
let accessToken
|
||||
let refreshToken
|
||||
|
||||
const authorizedConnection = axios.create({
|
||||
baseURL: 'https://www.strava.com/api/v3'
|
||||
})
|
||||
|
||||
authorizedConnection.interceptors.request.use(async config => {
|
||||
if (!refreshToken) {
|
||||
try {
|
||||
refreshToken = await fs.readFile(stravaTokenFile, 'utf-8')
|
||||
} catch (error) {
|
||||
log.info('no strava token available yet')
|
||||
}
|
||||
}
|
||||
// if no refresh token is set, then the app has not yet been authorized with Strava
|
||||
// start oAuth authorization process
|
||||
if (!refreshToken) {
|
||||
const authorizationCode = await getStravaAuthorizationCode();
|
||||
({ accessToken, refreshToken } = await authorize(authorizationCode))
|
||||
await writeToken('', refreshToken)
|
||||
// otherwise we just need to get a valid accessToken
|
||||
} else {
|
||||
const oldRefreshToken = refreshToken;
|
||||
({ accessToken, refreshToken } = await getAccessTokens(refreshToken))
|
||||
if (!refreshToken) {
|
||||
log.error(`strava token is invalid, deleting ${stravaTokenFile}...`)
|
||||
await fs.unlink(stravaTokenFile)
|
||||
// if the refreshToken has changed, persist it
|
||||
} else {
|
||||
await writeToken(oldRefreshToken, refreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
log.error('strava authorization not successful')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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,23 @@ 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,
|
||||
|
||||
// command to shutdown the device via the user interface, leave empty to disable this feature
|
||||
shutdownCommand: 'halt',
|
||||
|
||||
// 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
|
||||
// When creating your Strava API application, set the "Authorization Callback Domain" to the IP address
|
||||
// of your Raspberry Pi
|
||||
// WARNING: if you enabled the network share via the installer script, then this config file will be
|
||||
// exposed via network share on your local network. You might consider disabling (or password protect)
|
||||
// the Configuration share in smb.conf
|
||||
// The "Client ID" of your Strava API Application
|
||||
stravaClientId: '',
|
||||
|
||||
// The "Client Secret" of your Strava API Application
|
||||
stravaClientSecret: ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ Fitness Machine Service (FTMS) is a standardized GATT protocol for different typ
|
|||
|
||||
Open Rowing Monitor can create Training Center XML files (TCX). You can upload these files to training platforms like [Strava](https://www.strava.com), [Garmin Connect](https://connect.garmin.com) or [Trainingpeaks](https://trainingpeaks.com) to track your training sessions.
|
||||
|
||||
Currently this is a manual step. The installer can set up a network share that contains all training data so it is easy to grab the files from there and upload them to the training platform of your choice.
|
||||
Uploading your sessions to Strava is an integrated feature, for all other platforms this is currently a manual step. The installer can set up a network share that contains all training data so it is easy to grab the files from there and upload them to the training platform of your choice.
|
||||
|
||||
Open Rowing Monitor can also store the raw measurements of the flywheel into CSV files. These files are great to start your own exploration of your rowing style and also to learn about the specifics of your rowing machine (some Excel files that can help with this are included in the `docs` folder).
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ If you would like to contribute to this project, please read the [Contributing G
|
|||
|
||||
## Later
|
||||
|
||||
* automatically upload recorded rowing sessions to training platforms (i.e. Strava)
|
||||
* figure out where to set the Service Advertising Data (FTMS.pdf p 15)
|
||||
* add some attributes to BLE DeviceInformationService
|
||||
* record the workout and show a visual graph of metrics
|
||||
|
|
|
|||
|
|
@ -12,4 +12,4 @@ openbox-session &
|
|||
# Start Chromium in kiosk mode
|
||||
sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' ~/.config/chromium/'Local State'
|
||||
sed -i 's/"exited_cleanly":false/"exited_cleanly":true/; s/"exit_type":"[^"]\+"/"exit_type":"Normal"/' ~/.config/chromium/Default/Preferences
|
||||
chromium-browser --disable-infobars --kiosk --noerrdialogs --disable-session-crashed-bubble --disable-pinch --check-for-update-interval=604800 --app="http://127.0.0.1/#:kiosk:"
|
||||
chromium-browser --disable-infobars --disable-features=AudioServiceSandbox --kiosk --noerrdialogs --disable-session-crashed-bubble --disable-pinch --check-for-update-interval=604800 --app="http://127.0.0.1/?mode=kiosk"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
"moduleResolution": "node",
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "openrowingmonitor",
|
||||
"version": "0.8.1",
|
||||
"description": "A rowing monitor for rowing exercise machines",
|
||||
"version": "0.8.2",
|
||||
"description": "A free and open source performance monitor for rowing machines",
|
||||
"main": "app/server.js",
|
||||
"author": "Lars Berning",
|
||||
"license": "GPL-3.0",
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
"lint": "eslint ./app ./config && markdownlint-cli2 '**/*.md' '#node_modules'",
|
||||
"start": "node app/server.js",
|
||||
"dev": "npm-run-all --parallel dev:backend dev:frontend",
|
||||
"dev:backend": "nodemon app/server.js",
|
||||
"dev:backend": "nodemon --ignore 'app/client/**/*' app/server.js",
|
||||
"dev:frontend": "snowpack dev",
|
||||
"build": "rollup -c",
|
||||
"build:watch": "rollup -cw",
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ export default {
|
|||
upgrade: (req, socket, head) => {
|
||||
const defaultWSHandler = (err, req, socket, head) => {
|
||||
if (err) {
|
||||
console.error('proxy error', err)
|
||||
socket.destroy()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue