Merge pull request #66 from laberning/strava_upload

adds new features Strava upload and Device Shutdown
This commit is contained in:
Lars Berning 2022-02-12 20:50:34 +01:00 committed by GitHub
commit c8c0719ec0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1017 additions and 341 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@ -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()
}
}
}
}

View File

@ -6,8 +6,9 @@
*/ */
import { AppElement, html, css } from './AppElement.js' import { AppElement, html, css } from './AppElement.js'
import { customElement } from 'lit/decorators.js' import { customElement, state } from 'lit/decorators.js'
import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth } from '../lib/icons.js' import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload } from '../lib/icons.js'
import './AppDialog.js'
@customElement('dashboard-actions') @customElement('dashboard-actions')
export class DashboardActions extends AppElement { export class DashboardActions extends AppElement {
@ -16,14 +17,23 @@ export class DashboardActions extends AppElement {
outline:none; outline:none;
background-color: var(--theme-button-color); background-color: var(--theme-button-color);
border: 0; border: 0;
border-radius: var(--theme-border-radius);
color: var(--theme-font-color); color: var(--theme-font-color);
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;
}
button:hover {
filter: brightness(150%);
}
#fullscreen-icon {
display: inline-flex;
} }
#windowed-icon { #windowed-icon {
@ -31,7 +41,7 @@ export class DashboardActions extends AppElement {
} }
.icon { .icon {
height: 1.8em; height: 1.7em;
} }
.peripheral-mode { .peripheral-mode {
@ -43,18 +53,22 @@ export class DashboardActions extends AppElement {
display: none; display: none;
} }
#windowed-icon { #windowed-icon {
display: inline; display: inline-flex;
} }
} }
` `
@state({ type: Object })
dialog
render () { render () {
return html` return html`
<button @click=${this.reset}>${icon_undo}</button> <button @click=${this.reset}>${icon_undo}</button>
${this.renderOptionalButtons()} ${this.renderOptionalButtons()}
<button @click=${this.switchPeripheralMode}>${icon_bluetooth}</button> <button @click=${this.switchPeripheralMode}>${icon_bluetooth}</button>
<div class="peripheral-mode">${this.peripheralMode()}</div> <div class="peripheral-mode">${this.peripheralMode()}</div>
` ${this.dialog ? this.dialog : ''}
`
} }
renderOptionalButtons () { 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 // 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 // webbrowser (kiosk and standalone mode are always in fullscreen view) and if the
// browser supports this feature // browser supports this feature
if (this.appState.appMode === 'BROWSER' && document.documentElement.requestFullscreen) { if (this.appState?.appMode === 'BROWSER' && document.documentElement.requestFullscreen) {
buttons.push(html` buttons.push(html`
<button @click=${this.toggleFullscreen}> <button @click=${this.toggleFullscreen}>
<div id="fullscreen-icon">${icon_expand}</div> <div id="fullscreen-icon">${icon_expand}</div>
<div id="windowed-icon">${icon_compress}</div> <div id="windowed-icon">${icon_compress}</div>
</button> </button>
`) `)
} }
// a shutdown button only makes sence when the app is openend as app on a mobile // add a button to power down the device, if browser is running on the device in kiosk mode
// device. at some point we might also think of using this to power down the raspi // and the shutdown feature is enabled
// when we are running in kiosk mode // (might also make sence to enable this for all clients but then we would need visual feedback)
if (this.appState.appMode === 'STANDALONE') { if (this.appState?.appMode === 'KIOSK' && this.appState?.config?.shutdownEnabled) {
buttons.push(html` 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 return buttons
} }
peripheralMode () { peripheralMode () {
const value = this.appState?.peripheralMode const value = this.appState?.config?.peripheralMode
if (value === 'PM5') { if (value === 'PM5') {
return 'C2 PM5' return 'C2 PM5'
} else if (value === 'FTMSBIKE') { } else if (value === 'FTMSBIKE') {
return 'FTMS Bike' return 'FTMS Bike'
} else { } else if (value === 'FTMS') {
return 'FTMS Rower' return 'FTMS Rower'
} else {
return ''
} }
} }
@ -103,10 +125,6 @@ export class DashboardActions extends AppElement {
} }
} }
close () {
window.close()
}
reset () { reset () {
this.sendEvent('triggerAction', { command: 'reset' }) this.sendEvent('triggerAction', { command: 'reset' })
} }
@ -114,4 +132,34 @@ export class DashboardActions extends AppElement {
switchPeripheralMode () { switchPeripheralMode () {
this.sendEvent('triggerAction', { command: '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' })
}
}
}
} }

View File

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

View File

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

View File

@ -12,23 +12,40 @@ const rowingMetricsFields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', '
'heartrateBatteryLevel', 'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted'] 'heartrateBatteryLevel', 'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted']
export function createApp (app) { export function createApp (app) {
const mode = window.location.hash const urlParameters = new URLSearchParams(window.location.search)
const appMode = mode === '#:standalone:' ? 'STANDALONE' : mode === '#:kiosk:' ? 'KIOSK' : 'BROWSER' const mode = urlParameters.get('mode')
const appMode = mode === 'standalone' ? 'STANDALONE' : mode === 'kiosk' ? 'KIOSK' : 'BROWSER'
app.updateState({ ...app.getState(), appMode }) app.updateState({ ...app.getState(), appMode })
const stravaAuthorizationCode = urlParameters.get('code')
let socket let socket
initWebsocket() initWebsocket()
resetFields() resetFields()
requestWakeLock() 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 () { function initWebsocket () {
// use the native websocket implementation of browser to communicate with backend // 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 = new WebSocket(`ws://${location.host}/websocket`)
socket.addEventListener('open', (event) => { socket.addEventListener('open', (event) => {
console.log('websocket opened') console.log('websocket opened')
if (initialWebsocketOpenend) {
websocketOpened()
initialWebsocketOpenend = false
}
}) })
socket.addEventListener('error', (error) => { socket.addEventListener('error', (error) => {
@ -46,21 +63,37 @@ export function createApp (app) {
// todo: we should use different types of messages to make processing easier // todo: we should use different types of messages to make processing easier
socket.addEventListener('message', (event) => { socket.addEventListener('message', (event) => {
try { try {
const data = JSON.parse(event.data) const message = JSON.parse(event.data)
if (!message.type) {
let activeFields = rowingMetricsFields console.error('message does not contain messageType specifier', message)
// if we are in reset state only update heart rate return
if (data.strokesTotal === 0) {
activeFields = ['heartrate', 'heartrateBatteryLevel']
} }
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) const filteredData = filterObjectByKeys(data, activeFields)
app.updateState({ ...app.getState(), metrics: filteredData })
let updatedState = { ...app.getState(), metrics: filteredData } break
if (data.peripheralMode) { }
updatedState = { ...app.getState(), peripheralMode: data.peripheralMode } 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) { } catch (err) {
console.log(err) console.log(err)
} }
@ -90,13 +123,27 @@ 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
}
case 'shutdown': {
if (socket)socket.send(JSON.stringify({ command: 'shutdown' }))
break
}
default: {
console.error('no handler defined for action', action)
}
} }
} }

View File

@ -24,3 +24,4 @@ export const icon_poweroff = svg`<svg aria-hidden="true" focusable="false" class
export const icon_expand = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M0 180V56c0-13.3 10.7-24 24-24h124c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H64v84c0 6.6-5.4 12-12 12H12c-6.6 0-12-5.4-12-12zM288 44v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12V56c0-13.3-10.7-24-24-24H300c-6.6 0-12 5.4-12 12zm148 276h-40c-6.6 0-12 5.4-12 12v84h-84c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24V332c0-6.6-5.4-12-12-12zM160 468v-40c0-6.6-5.4-12-12-12H64v-84c0-6.6-5.4-12-12-12H12c-6.6 0-12 5.4-12 12v124c0 13.3 10.7 24 24 24h124c6.6 0 12-5.4 12-12z"></path></svg>` export const icon_expand = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M0 180V56c0-13.3 10.7-24 24-24h124c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H64v84c0 6.6-5.4 12-12 12H12c-6.6 0-12-5.4-12-12zM288 44v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12V56c0-13.3-10.7-24-24-24H300c-6.6 0-12 5.4-12 12zm148 276h-40c-6.6 0-12 5.4-12 12v84h-84c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24V332c0-6.6-5.4-12-12-12zM160 468v-40c0-6.6-5.4-12-12-12H64v-84c0-6.6-5.4-12-12-12H12c-6.6 0-12 5.4-12 12v124c0 13.3 10.7 24 24 24h124c6.6 0 12-5.4 12-12z"></path></svg>`
export const icon_compress = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M436 192H312c-13.3 0-24-10.7-24-24V44c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v84h84c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm-276-24V44c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v84H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24zm0 300V344c0-13.3-10.7-24-24-24H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm192 0v-84h84c6.6 0 12-5.4 12-12v-40c0-6.6-5.4-12-12-12H312c-13.3 0-24 10.7-24 24v124c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12z"></path></svg>` export const icon_compress = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M436 192H312c-13.3 0-24-10.7-24-24V44c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v84h84c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm-276-24V44c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v84H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24zm0 300V344c0-13.3-10.7-24-24-24H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm192 0v-84h84c6.6 0 12-5.4 12-12v-40c0-6.6-5.4-12-12-12H312c-13.3 0-24 10.7-24 24v124c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12z"></path></svg>`
export const icon_bluetooth = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M292.6 171.1L249.7 214l-.3-86 43.2 43.1m-43.2 219.8l43.1-43.1-42.9-42.9-.2 86zM416 259.4C416 465 344.1 512 230.9 512S32 465 32 259.4 115.4 0 228.6 0 416 53.9 416 259.4zm-158.5 0l79.4-88.6L211.8 36.5v176.9L138 139.6l-27 26.9 92.7 93-92.7 93 26.9 26.9 73.8-73.8 2.3 170 127.4-127.5-83.9-88.7z"></path></svg>` export const icon_bluetooth = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M292.6 171.1L249.7 214l-.3-86 43.2 43.1m-43.2 219.8l43.1-43.1-42.9-42.9-.2 86zM416 259.4C416 465 344.1 512 230.9 512S32 465 32 259.4 115.4 0 228.6 0 416 53.9 416 259.4zm-158.5 0l79.4-88.6L211.8 36.5v176.9L138 139.6l-27 26.9 92.7 93-92.7 93 26.9 26.9 73.8-73.8 2.3 170 127.4-127.5-83.9-88.7z"></path></svg>`
export const icon_upload = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"></path></svg>`

View File

@ -9,8 +9,8 @@
"type": "image/png" "type": "image/png"
} }
], ],
"background_color": "#0059B3", "background_color": "#002b57",
"display": "fullscreen", "display": "fullscreen",
"orientation": "any", "orientation": "any",
"start_url": "/#:standalone:" "start_url": "/?mode=standalone"
} }

View File

@ -8,8 +8,14 @@
export const APP_STATE = { export const APP_STATE = {
// currently can be STANDALONE (Mobile Home Screen App), KIOSK (Raspberry Pi deployment) or '' (default) // currently can be STANDALONE (Mobile Home Screen App), KIOSK (Raspberry Pi deployment) or '' (default)
appMode: '', appMode: '',
// currently can be FTMS, FTMSBIKE or PM5
peripheralMode: 'FTMS',
// contains all the rowing metrics that are delivered from the backend // contains all the rowing metrics that are delivered from the backend
metrics: {} metrics: {},
config: {
// currently can be FTMS, FTMSBIKE or PM5
peripheralMode: '',
// true if upload to strava is enabled
stravaUploadEnabled: false,
// true if remote device shutdown is enabled
shutdownEnabled: false
}
} }

View File

@ -8,7 +8,7 @@
*/ */
import log from 'loglevel' import log from 'loglevel'
import zlib from 'zlib' import zlib from 'zlib'
import { mkdir, writeFile } from 'fs/promises' import fs from 'fs/promises'
import xml2js from 'xml2js' import xml2js from 'xml2js'
import config from '../tools/ConfigManager.js' import config from '../tools/ConfigManager.js'
import { promisify } from 'util' import { promisify } from 'util'
@ -34,33 +34,7 @@ function createWorkoutRecorder () {
if (startTime === undefined) { if (startTime === undefined) {
startTime = new Date() startTime = new Date()
} }
// stroke recordings are currently only used to create tcx files, so we can skip it strokes.push(stroke)
// if tcx file creation is disabled
if (config.createTcxFiles) {
strokes.push(stroke)
}
}
async function createTcxFile () {
const stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
const filename = `${directory}/${stringifiedStartTime}_rowing.tcx${config.gzipTcxFiles ? '.gz' : ''}`
log.info(`saving session as tcx file ${filename}...`)
try {
await mkdir(directory, { recursive: true })
} catch (error) {
if (error.code !== 'EEXIST') {
log.error(`can not create directory ${directory}`, error)
}
}
await buildAndSaveTcxFile({
id: startTime.toISOString(),
filename,
startTime,
strokes
})
} }
async function createRawDataFile () { async function createRawDataFile () {
@ -70,7 +44,7 @@ function createWorkoutRecorder () {
log.info(`saving session as raw data file ${filename}...`) log.info(`saving session as raw data file ${filename}...`)
try { try {
await mkdir(directory, { recursive: true }) await fs.mkdir(directory, { recursive: true })
} catch (error) { } catch (error) {
if (error.code !== 'EEXIST') { if (error.code !== 'EEXIST') {
log.error(`can not create directory ${directory}`, error) log.error(`can not create directory ${directory}`, error)
@ -79,7 +53,46 @@ 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()
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('.') 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 +177,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 +190,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 +205,8 @@ function createWorkoutRecorder () {
return return
} }
const minimumRecordingTimeInSeconds = 10 if (!minimumRecordingTimeHasPassed()) {
const rotationImpulseTimeTotal = rotationImpulses.reduce((acc, impulse) => acc + impulse, 0) log.debug('workout is shorter than minimum workout time, skipping automatic 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 +221,18 @@ function createWorkoutRecorder () {
await Promise.all(parallelCalls) 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 { return {
recordStroke, recordStroke,
recordRotationImpulse, recordRotationImpulse,
handlePause, handlePause,
activeWorkoutToTcx,
reset reset
} }
} }

View File

@ -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 }

View File

@ -6,7 +6,8 @@
everything together while figuring out the physics and model of the application. everything together while figuring out the physics and model of the application.
todo: refactor this as we progress 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 log from 'loglevel'
import config from './tools/ConfigManager.js' import config from './tools/ConfigManager.js'
import { createRowingEngine } from './engine/RowingEngine.js' import { createRowingEngine } from './engine/RowingEngine.js'
@ -17,6 +18,8 @@ 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'
const exec = promisify(child_process.exec)
// set the log levels // set the log levels
log.setLevel(config.loglevel.default) log.setLevel(config.loglevel.default)
@ -51,7 +54,7 @@ peripheralManager.on('control', (event) => {
peripheralManager.notifyStatus({ name: 'startedOrResumedByUser' }) peripheralManager.notifyStatus({ name: 'startedOrResumedByUser' })
event.res = true event.res = true
} else if (event?.req?.name === 'peripheralMode') { } else if (event?.req?.name === 'peripheralMode') {
webServer.notifyClients({ peripheralMode: event.req.peripheralMode }) webServer.notifyClients('config', getConfig())
event.res = true event.res = true
} else { } else {
log.info('unhandled Command', event.req) log.info('unhandled Command', event.req)
@ -65,7 +68,7 @@ function resetWorkout () {
peripheralManager.notifyStatus({ name: 'reset' }) peripheralManager.notifyStatus({ name: 'reset' })
} }
const gpioTimerService = fork('./app/gpio/GpioTimerService.js') const gpioTimerService = child_process.fork('./app/gpio/GpioTimerService.js')
gpioTimerService.on('message', handleRotationImpulse) gpioTimerService.on('message', handleRotationImpulse)
function handleRotationImpulse (dataPoint) { function handleRotationImpulse (dataPoint) {
@ -77,9 +80,10 @@ 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', metrics)
peripheralManager.notifyMetrics('strokeStateChanged', 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` + `, 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: ${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`) `, 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) peripheralManager.notifyMetrics('strokeFinished', metrics)
if (metrics.sessionState === 'rowing') { if (metrics.sessionState === 'rowing') {
workoutRecorder.recordStroke(metrics) workoutRecorder.recordStroke(metrics)
@ -96,7 +100,7 @@ rowingStatistics.on('recoveryFinished', (metrics) => {
}) })
rowingStatistics.on('webMetricsUpdate', (metrics) => { rowingStatistics.on('webMetricsUpdate', (metrics) => {
webServer.notifyClients(metrics) webServer.notifyClients('metrics', metrics)
}) })
rowingStatistics.on('peripheralMetricsUpdate', (metrics) => { rowingStatistics.on('peripheralMetricsUpdate', (metrics) => {
@ -108,7 +112,7 @@ rowingStatistics.on('rowingPaused', () => {
}) })
if (config.heartrateMonitorBLE) { if (config.heartrateMonitorBLE) {
const bleCentralService = fork('./app/ble/CentralService.js') const bleCentralService = child_process.fork('./app/ble/CentralService.js')
bleCentralService.on('message', (heartrateMeasurement) => { bleCentralService.on('message', (heartrateMeasurement) => {
rowingStatistics.handleHeartrateMeasurement(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() const webServer = createWebServer()
webServer.on('messageReceived', (message) => { webServer.on('messageReceived', async (message, client) => {
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(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.on('clientConnected', (client) => {
webServer.notifyClients({ peripheralMode: peripheralManager.getPeripheralMode() }) webServer.notifyClient(client, 'config', getConfig())
}) })
// todo: extract this into some kind of state manager
function getConfig () {
return {
peripheralMode: peripheralManager.getPeripheralMode(),
stravaUploadEnabled: !!config.stravaClientId && !!config.stravaClientSecret,
shutdownEnabled: !!config.shutdownCommand
}
}
/* /*
replayRowingSession(handleRotationImpulse, { replayRowingSession(handleRotationImpulse, {
filename: 'recordings/WRX700_2magnets.csv', filename: 'recordings/WRX700_2magnets.csv',

View File

@ -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
}

40
app/tools/StravaAPI.js Normal file
View File

@ -0,0 +1,40 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Implements required parts of the Strava API (https://developers.strava.com/)
*/
import zlib from 'zlib'
import FormData from 'form-data'
import { promisify } from 'util'
import { createAuthorizedConnection } from './AuthorizedStravaConnection.js'
const gzip = promisify(zlib.gzip)
function createStravaAPI (getStravaAuthorizationCode) {
const authorizedStravaConnection = createAuthorizedConnection(getStravaAuthorizationCode)
async function uploadActivityTcx (tcxRecord) {
const form = new FormData()
form.append('file', await gzip(tcxRecord.tcx), tcxRecord.filename)
form.append('data_type', 'tcx.gz')
form.append('name', 'Indoor Rowing Session')
form.append('description', 'Uploaded from Open Rowing Monitor')
form.append('trainer', 'true')
form.append('activity_type', 'Rowing')
return await authorizedStravaConnection.post('/uploads', form)
}
async function getAthlete () {
return (await authorizedStravaConnection.get('/athlete')).data
}
return {
uploadActivityTcx,
getAthlete
}
}
export {
createStravaAPI
}

View File

@ -99,5 +99,23 @@ 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,
// 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: ''
} }

View File

@ -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. 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). 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).

View File

@ -12,7 +12,6 @@ If you would like to contribute to this project, please read the [Contributing G
## Later ## 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) * figure out where to set the Service Advertising Data (FTMS.pdf p 15)
* add some attributes to BLE DeviceInformationService * add some attributes to BLE DeviceInformationService
* record the workout and show a visual graph of metrics * record the workout and show a visual graph of metrics

View File

@ -12,4 +12,4 @@ openbox-session &
# Start Chromium in kiosk mode # 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/' ~/.config/chromium/'Local State'
sed -i 's/"exited_cleanly":false/"exited_cleanly":true/; s/"exit_type":"[^"]\+"/"exit_type":"Normal"/' ~/.config/chromium/Default/Preferences 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"

View File

@ -1,5 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es2021",
"moduleResolution": "node",
"checkJs": true,
"esModuleInterop": true,
"experimentalDecorators": true "experimentalDecorators": true
} }
} }

561
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{ {
"name": "openrowingmonitor", "name": "openrowingmonitor",
"version": "0.8.1", "version": "0.8.2",
"description": "A rowing monitor for rowing exercise machines", "description": "A free and open source performance monitor for rowing machines",
"main": "app/server.js", "main": "app/server.js",
"author": "Lars Berning", "author": "Lars Berning",
"license": "GPL-3.0", "license": "GPL-3.0",
@ -21,7 +21,7 @@
"lint": "eslint ./app ./config && markdownlint-cli2 '**/*.md' '#node_modules'", "lint": "eslint ./app ./config && markdownlint-cli2 '**/*.md' '#node_modules'",
"start": "node app/server.js", "start": "node app/server.js",
"dev": "npm-run-all --parallel dev:backend dev:frontend", "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", "dev:frontend": "snowpack dev",
"build": "rollup -c", "build": "rollup -c",
"build:watch": "rollup -cw", "build:watch": "rollup -cw",
@ -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",

View File

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