Merge pull request #51 from laberning/frontend_redesign
Changes Architecture of the Web Frontend to use Web Components
This commit is contained in:
commit
0c55c1ae71
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": false,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"standard",
|
||||
"plugin:wc/recommended",
|
||||
"plugin:lit/recommended"
|
||||
],
|
||||
"parser": "@babel/eslint-parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 12,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"ignorePatterns": ["**/*.min.js"],
|
||||
"rules": {
|
||||
"camelcase": 0
|
||||
}
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
'use strict'
|
||||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
This is currently a very simple Web UI that displays the training metrics.
|
||||
*/
|
||||
import NoSleep from 'nosleep.js'
|
||||
|
||||
export function createApp () {
|
||||
const fields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', 'power', 'heartrate',
|
||||
'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted', 'peripheralMode']
|
||||
const fieldFormatter = {
|
||||
peripheralMode: (value) => {
|
||||
if (value === 'PM5') {
|
||||
return 'C2 PM5'
|
||||
} else if (value === 'FTMSBIKE') {
|
||||
return 'FTMS Bike'
|
||||
} else {
|
||||
return 'FTMS Rower'
|
||||
}
|
||||
},
|
||||
distanceTotal: (value) => value >= 10000
|
||||
? { value: (value / 1000).toFixed(1), unit: 'km' }
|
||||
: { value: Math.round(value), unit: 'm' },
|
||||
caloriesTotal: (value) => Math.round(value),
|
||||
power: (value) => Math.round(value),
|
||||
strokesPerMinute: (value) => Math.round(value)
|
||||
}
|
||||
const standalone = (window.location.hash === '#:standalone:')
|
||||
|
||||
if (standalone) {
|
||||
document.getElementById('close-button').style.display = 'inline-block'
|
||||
document.getElementById('fullscreen-button').style.display = 'none'
|
||||
} else {
|
||||
document.getElementById('fullscreen-button').style.display = 'inline-block'
|
||||
document.getElementById('close-button').style.display = 'none'
|
||||
}
|
||||
|
||||
let socket
|
||||
|
||||
initWebsocket()
|
||||
resetFields()
|
||||
requestWakeLock()
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
socket.addEventListener('error', (error) => {
|
||||
console.log('websocket error', error)
|
||||
socket.close()
|
||||
})
|
||||
|
||||
socket.addEventListener('close', (event) => {
|
||||
console.log('websocket closed, attempting reconnect')
|
||||
setTimeout(() => {
|
||||
initWebsocket()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
// show heart rate, if present
|
||||
if (data.heartrate !== undefined) {
|
||||
if (data.heartrate !== 0) {
|
||||
document.getElementById('heartrate-container').style.display = 'inline-block'
|
||||
document.getElementById('strokes-total-container').style.display = 'none'
|
||||
if (data.heartrateBatteryLevel !== undefined) {
|
||||
document.getElementById('heartrate-battery-container').style.display = 'inline-block'
|
||||
setHeartrateMonitorBatteryLevel(data.heartrateBatteryLevel)
|
||||
} else {
|
||||
document.getElementById('heartrate-battery-container').style.display = 'none'
|
||||
}
|
||||
} else {
|
||||
document.getElementById('strokes-total-container').style.display = 'inline-block'
|
||||
document.getElementById('heartrate-container').style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
let activeFields = fields
|
||||
// if we are in reset state only update heart rate and peripheral mode
|
||||
if (data.strokesTotal === 0) {
|
||||
activeFields = ['heartrate', 'peripheralMode']
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (activeFields.includes(key)) {
|
||||
const valueFormatted = fieldFormatter[key] ? fieldFormatter[key](value) : value
|
||||
if (valueFormatted.value !== undefined && valueFormatted.unit !== undefined) {
|
||||
if (document.getElementById(key)) document.getElementById(key).innerHTML = valueFormatted.value
|
||||
if (document.getElementById(`${key}Unit`)) document.getElementById(`${key}Unit`).innerHTML = valueFormatted.unit
|
||||
} else {
|
||||
if (document.getElementById(key)) document.getElementById(key).innerHTML = valueFormatted
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function requestWakeLock () {
|
||||
// Chrome enables the new Wake Lock API only if the connection is secured via SSL
|
||||
// This is quite annoying for IoT use cases like this one, where the device sits on the
|
||||
// local network and is directly addressed by its IP.
|
||||
// In this case the only way of using SSL is by creating a self signed certificate, and
|
||||
// that would pop up different warnings in the browser (and also prevents fullscreen via
|
||||
// a home screen icon so it can show these warnings). Okay, enough ranting :-)
|
||||
// In this case we use the good old hacky way of keeping the screen on via a hidden video.
|
||||
// eslint-disable-next-line no-undef
|
||||
const noSleep = new NoSleep()
|
||||
document.addEventListener('click', function enableNoSleep () {
|
||||
document.removeEventListener('click', enableNoSleep, false)
|
||||
noSleep.enable()
|
||||
}, false)
|
||||
}
|
||||
|
||||
function resetFields () {
|
||||
for (const key of fields.filter((elem) => elem !== 'peripheralMode')) {
|
||||
if (document.getElementById(key)) document.getElementById(key).innerHTML = '--'
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen () {
|
||||
const fullscreenElement = document.getElementById('dashboard')
|
||||
if (!document.fullscreenElement) {
|
||||
fullscreenElement.requestFullscreen({ navigationUI: 'hide' })
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function close () {
|
||||
window.close()
|
||||
}
|
||||
|
||||
function reset () {
|
||||
resetFields()
|
||||
if (socket)socket.send(JSON.stringify({ command: 'reset' }))
|
||||
}
|
||||
|
||||
function switchPeripheralMode () {
|
||||
if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' }))
|
||||
}
|
||||
|
||||
function setHeartrateMonitorBatteryLevel (batteryLevel) {
|
||||
if (document.getElementById('battery-level') !== null) {
|
||||
// 416 is the max width value of the battery bar in the SVG graphic
|
||||
document.getElementById('battery-level').setAttribute('width', `${batteryLevel * 416 / 100}px`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toggleFullscreen,
|
||||
reset,
|
||||
close,
|
||||
switchPeripheralMode
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
'use strict'
|
||||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
Base Component for all other App Components
|
||||
*/
|
||||
|
||||
import { LitElement } from 'lit'
|
||||
import { property } from 'lit/decorators.js'
|
||||
import { APP_STATE } from '../store/appState.js'
|
||||
export * from 'lit'
|
||||
|
||||
export class AppElement extends LitElement {
|
||||
// this is how we implement a global state: a global state object is passed via properties
|
||||
// to child components
|
||||
@property({ type: Object })
|
||||
appState = APP_STATE
|
||||
|
||||
// ..and state changes are send back to the root component of the app by dispatching
|
||||
// a CustomEvent
|
||||
updateState () {
|
||||
this.sendEvent('appStateChanged', this.appState)
|
||||
}
|
||||
|
||||
// a helper to dispatch events to the parent components
|
||||
sendEvent (eventType, eventData) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(eventType, {
|
||||
detail: eventData,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// currently we do not use shadow root since there is still a global style file
|
||||
// but maybe one day we dissolve it into the components and use shadow dom instead
|
||||
createRenderRoot () {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
'use strict'
|
||||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
Component that renders the action buttons of the dashboard
|
||||
*/
|
||||
|
||||
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'
|
||||
|
||||
@customElement('dashboard-actions')
|
||||
export class DashboardActions extends AppElement {
|
||||
static get styles () {
|
||||
return css`
|
||||
`
|
||||
}
|
||||
|
||||
render () {
|
||||
return html`
|
||||
<button @click=${this.reset}>${icon_undo}</button>
|
||||
${this.renderOptionalButtons()}
|
||||
<button @click=${this.switchPeripheralMode}>${icon_bluetooth}</button>
|
||||
<div class="metric-unit">${this.peripheralMode()}</div>
|
||||
`
|
||||
}
|
||||
|
||||
renderOptionalButtons () {
|
||||
const buttons = []
|
||||
// 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 the browser
|
||||
// supports this feature
|
||||
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>
|
||||
`)
|
||||
}
|
||||
// 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') {
|
||||
buttons.push(html`
|
||||
<button @click=${this.close} id="close-button">${icon_poweroff}</button>
|
||||
`)
|
||||
}
|
||||
return buttons
|
||||
}
|
||||
|
||||
peripheralMode () {
|
||||
const value = this.appState?.peripheralMode
|
||||
if (value === 'PM5') {
|
||||
return 'C2 PM5'
|
||||
} else if (value === 'FTMSBIKE') {
|
||||
return 'FTMS Bike'
|
||||
} else {
|
||||
return 'FTMS Rower'
|
||||
}
|
||||
}
|
||||
|
||||
toggleFullscreen () {
|
||||
const fullscreenElement = document.getElementsByTagName('web-app')[0]
|
||||
if (!document.fullscreenElement) {
|
||||
fullscreenElement.requestFullscreen({ navigationUI: 'hide' })
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close () {
|
||||
window.close()
|
||||
}
|
||||
|
||||
reset () {
|
||||
this.sendEvent('triggerAction', { command: 'reset' })
|
||||
}
|
||||
|
||||
switchPeripheralMode () {
|
||||
this.sendEvent('triggerAction', { command: 'switchPeripheralMode' })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
'use strict'
|
||||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
Component that renders a metric of the dashboard
|
||||
*/
|
||||
|
||||
import { AppElement, html, svg, css } from './AppElement.js'
|
||||
import { customElement, property } from 'lit/decorators.js'
|
||||
|
||||
@customElement('dashboard-metric')
|
||||
export class DashboardMetric extends AppElement {
|
||||
static get styles () {
|
||||
return css`
|
||||
`
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
icon
|
||||
|
||||
@property({ type: String })
|
||||
unit = ''
|
||||
|
||||
@property({ type: String })
|
||||
value = ''
|
||||
|
||||
@property({ type: String })
|
||||
batteryLevel = ''
|
||||
|
||||
render () {
|
||||
return html`
|
||||
<div class="label">${this.icon}</div>
|
||||
<div class="content">
|
||||
<span class="metric-value">${this.value !== undefined ? this.value : '--'}</span>
|
||||
<span class="metric-unit">${this.unit}</span>
|
||||
</div>
|
||||
${this.batteryLevel &&
|
||||
html`<div id="heartrate-battery-container">${this.batteryIcon}</div>`
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
get batteryIcon () {
|
||||
// 416 is the max width value of the battery bar in the SVG graphic
|
||||
const batteryWidth = this.batteryLevel * 416 / 100
|
||||
|
||||
return 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="M544 160v64h32v64h-32v64H64V160h480m16-64H48c-26.51 0-48 21.49-48 48v224c0 26.51 21.49 48 48 48h512c26.51 0 48-21.49 48-48v-16h8c13.255 0 24-10.745 24-24V184c0-13.255-10.745-24-24-24h-8v-16c0-26.51-21.49-48-48-48z"></path>
|
||||
<rect fill="currentColor" id="battery-level" x="96" y="192" width=${batteryWidth} height="128"></rect>
|
||||
</svg>
|
||||
|
||||
`
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
'use strict'
|
||||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
Component that renders the dashboard
|
||||
*/
|
||||
|
||||
import { AppElement, html, css } from './AppElement.js'
|
||||
import { APP_STATE } from '../store/appState.js'
|
||||
import { customElement, property } from 'lit/decorators.js'
|
||||
import './DashboardMetric.js'
|
||||
import './DashboardActions.js'
|
||||
import { icon_route, icon_stopwatch, icon_bolt, icon_paddle, icon_heartbeat, icon_fire, icon_clock } from '../lib/icons.js'
|
||||
|
||||
@customElement('performance-dashboard')
|
||||
export class PerformanceDashboard extends AppElement {
|
||||
static get styles () {
|
||||
return css`
|
||||
`
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
metrics
|
||||
|
||||
@property({ type: Object })
|
||||
appState = APP_STATE
|
||||
|
||||
render () {
|
||||
const metrics = this.calculateFormattedMetrics(this.appState.metrics)
|
||||
return html`
|
||||
<dashboard-metric .icon=${icon_route} .unit=${metrics?.distanceTotal?.unit || 'm'} .value=${metrics?.distanceTotal?.value}></dashboard-metric>
|
||||
<dashboard-metric .icon=${icon_stopwatch} unit="/500m" .value=${metrics?.splitFormatted?.value}></dashboard-metric>
|
||||
<dashboard-metric .icon=${icon_bolt} unit="watt" .value=${metrics?.power?.value}></dashboard-metric>
|
||||
<dashboard-metric .icon=${icon_paddle} unit="/min" .value=${metrics?.strokesPerMinute?.value}></dashboard-metric>
|
||||
${metrics?.heartrate?.value
|
||||
? html`<dashboard-metric .icon=${icon_heartbeat} unit="bpm"
|
||||
.value=${metrics?.heartrate?.value}
|
||||
.batteryLevel=${metrics?.heartrateBatteryLevel?.value}></dashboard-metric>`
|
||||
: html`<dashboard-metric .icon=${icon_paddle} unit="total" .value=${metrics?.strokesTotal?.value}></dashboard-metric>`}
|
||||
<dashboard-metric .icon=${icon_fire} unit="kcal" .value=${metrics?.caloriesTotal?.value}></dashboard-metric>
|
||||
<dashboard-metric .icon=${icon_clock} .value=${metrics?.durationTotalFormatted?.value}></dashboard-metric>
|
||||
<dashboard-actions .appState=${this.appState}></dashboard-actions>
|
||||
`
|
||||
}
|
||||
|
||||
calculateFormattedMetrics (metrics) {
|
||||
const fieldFormatter = {
|
||||
distanceTotal: (value) => value >= 10000
|
||||
? { value: (value / 1000).toFixed(1), unit: 'km' }
|
||||
: { value: Math.round(value), unit: 'm' },
|
||||
caloriesTotal: (value) => Math.round(value),
|
||||
power: (value) => Math.round(value),
|
||||
strokesPerMinute: (value) => Math.round(value)
|
||||
}
|
||||
|
||||
const formattedMetrics = {}
|
||||
for (const [key, value] of Object.entries(metrics)) {
|
||||
const valueFormatted = fieldFormatter[key] ? fieldFormatter[key](value) : value
|
||||
if (valueFormatted.value !== undefined && valueFormatted.unit !== undefined) {
|
||||
formattedMetrics[key] = {
|
||||
value: valueFormatted.value,
|
||||
unit: valueFormatted.unit
|
||||
}
|
||||
} else {
|
||||
formattedMetrics[key] = {
|
||||
value: valueFormatted
|
||||
}
|
||||
}
|
||||
}
|
||||
return formattedMetrics
|
||||
}
|
||||
}
|
||||
|
|
@ -15,132 +15,8 @@
|
|||
<title>Open Rowing Monitor</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="/index.js"></script>
|
||||
<div id="dashboard" class="grid">
|
||||
<div class="col">
|
||||
<div class="label">
|
||||
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M416 320h-96c-17.6 0-32-14.4-32-32s14.4-32 32-32h96s96-107 96-160-43-96-96-96-96 43-96 96c0 25.5 22.2 63.4 45.3 96H320c-52.9 0-96 43.1-96 96s43.1 96 96 96h96c17.6 0 32 14.4 32 32s-14.4 32-32 32H185.5c-16 24.8-33.8 47.7-47.3 64H416c52.9 0 96-43.1 96-96s-43.1-96-96-96zm0-256c17.7 0 32 14.3 32 32s-14.3 32-32 32-32-14.3-32-32 14.3-32 32-32zM96 256c-53 0-96 43-96 96s96 160 96 160 96-107 96-160-43-96-96-96zm0 128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"></path></svg>
|
||||
</div>
|
||||
<div class="content">
|
||||
<span class="metric-value" id="distanceTotal"></span>
|
||||
<span class="metric-unit" id="distanceTotalUnit">m</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="label">
|
||||
<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="M432 304c0 114.9-93.1 208-208 208S16 418.9 16 304c0-104 76.3-190.2 176-205.5V64h-28c-6.6 0-12-5.4-12-12V12c0-6.6 5.4-12 12-12h120c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-28v34.5c37.5 5.8 71.7 21.6 99.7 44.6l27.5-27.5c4.7-4.7 12.3-4.7 17 0l28.3 28.3c4.7 4.7 4.7 12.3 0 17l-29.4 29.4-.6.6C419.7 223.3 432 262.2 432 304zm-176 36V188.5c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12V340c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12z"></path></svg>
|
||||
</div>
|
||||
<div class="content">
|
||||
<span class="metric-value" id="splitFormatted"></span>
|
||||
<span class="metric-unit">/500m</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="label">
|
||||
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path fill="currentColor" d="M296 160H180.6l42.6-129.8C227.2 15 215.7 0 200 0H56C44 0 33.8 8.9 32.2 20.8l-32 240C-1.7 275.2 9.5 288 24 288h118.7L96.6 482.5c-3.6 15.2 8 29.5 23.3 29.5 8.4 0 16.4-4.4 20.8-12l176-304c9.3-15.9-2.2-36-20.7-36z"></path></svg>
|
||||
</div>
|
||||
<div class="content">
|
||||
<span class="metric-value" id="power"></span>
|
||||
<span class="metric-unit">watt</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="label">
|
||||
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path fill="currentColor" d="m 420.20605,468.95387 33.11371,-33.1137 c 5.22683,-5.22684 8.77492,-17.24658 3.54807,-22.47345 l -2.40714,-2.40718 c -5.22685,-5.22685 -24.05986,3.18856 -30.46144,6.8845 L 208.43041,202.27519 C 197.48359,161.4211 171.36396,110.7189 139.6182,78.964771 L 104.97336,44.31993 C 84.074343,23.420923 78.962098,22.904081 62.197813,39.66838 L 24.014429,77.851751 C 9.0221901,92.843993 7.7868003,99.708469 28.685812,120.60748 l 34.653205,34.65321 c 30.664056,30.66402 82.456343,57.8654 123.310393,68.8122 l 215.56885,215.56886 c -3.69593,6.40155 -12.11135,25.23463 -6.88448,30.46145 l 2.40718,2.40715 c 5.2185,5.21846 17.23824,1.67039 22.46509,-3.55648 z"/>
|
||||
<path fill="currentColor" d="M 93.111861,469.41843 59.998156,436.30471 c -5.226842,-5.22684 -8.774914,-17.24659 -3.548088,-22.47344 l 2.407166,-2.40717 c 5.226836,-5.22685 24.059868,3.18854 30.46142,6.88449 L 304.88751,202.73974 c 10.94682,-40.85409 37.06645,-91.55629 68.81223,-123.310429 L 408.34455,44.78448 c 20.89903,-20.899007 26.01127,-21.415839 42.77554,-4.65156 l 38.18338,38.183384 c 14.99224,14.992241 16.22764,21.856706 -4.67138,42.755726 l -34.65319,34.6532 c -30.66405,30.66404 -82.45634,57.8654 -123.31038,68.8122 L 111.09965,440.10631 c 3.69593,6.40153 12.11135,25.23461 6.88448,30.46143 l -2.40718,2.40716 c -5.21849,5.21846 -17.23824,1.67038 -22.465089,-3.55647 z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="content">
|
||||
<span class="metric-value" id="strokesPerMinute"></span>
|
||||
<span class="metric-unit">/min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div id="strokes-total-container">
|
||||
<div class="label">
|
||||
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path fill="currentColor" d="m 420.20605,468.95387 33.11371,-33.1137 c 5.22683,-5.22684 8.77492,-17.24658 3.54807,-22.47345 l -2.40714,-2.40718 c -5.22685,-5.22685 -24.05986,3.18856 -30.46144,6.8845 L 208.43041,202.27519 C 197.48359,161.4211 171.36396,110.7189 139.6182,78.964771 L 104.97336,44.31993 C 84.074343,23.420923 78.962098,22.904081 62.197813,39.66838 L 24.014429,77.851751 C 9.0221901,92.843993 7.7868003,99.708469 28.685812,120.60748 l 34.653205,34.65321 c 30.664056,30.66402 82.456343,57.8654 123.310393,68.8122 l 215.56885,215.56886 c -3.69593,6.40155 -12.11135,25.23463 -6.88448,30.46145 l 2.40718,2.40715 c 5.2185,5.21846 17.23824,1.67039 22.46509,-3.55648 z"/>
|
||||
<path fill="currentColor" d="M 93.111861,469.41843 59.998156,436.30471 c -5.226842,-5.22684 -8.774914,-17.24659 -3.548088,-22.47344 l 2.407166,-2.40717 c 5.226836,-5.22685 24.059868,3.18854 30.46142,6.88449 L 304.88751,202.73974 c 10.94682,-40.85409 37.06645,-91.55629 68.81223,-123.310429 L 408.34455,44.78448 c 20.89903,-20.899007 26.01127,-21.415839 42.77554,-4.65156 l 38.18338,38.183384 c 14.99224,14.992241 16.22764,21.856706 -4.67138,42.755726 l -34.65319,34.6532 c -30.66405,30.66404 -82.45634,57.8654 -123.31038,68.8122 L 111.09965,440.10631 c 3.69593,6.40153 12.11135,25.23461 6.88448,30.46143 l -2.40718,2.40716 c -5.21849,5.21846 -17.23824,1.67038 -22.465089,-3.55647 z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="content">
|
||||
<span class="metric-value" id="strokesTotal"></span>
|
||||
<span class="metric-unit">total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="heartrate-container">
|
||||
<div class="label">
|
||||
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path fill="currentColor" d="M320.2 243.8l-49.7 99.4c-6 12.1-23.4 11.7-28.9-.6l-56.9-126.3-30 71.7H60.6l182.5 186.5c7.1 7.3 18.6 7.3 25.7 0L451.4 288H342.3l-22.1-44.2zM473.7 73.9l-2.4-2.5c-51.5-52.6-135.8-52.6-187.4 0L256 100l-27.9-28.5c-51.5-52.7-135.9-52.7-187.4 0l-2.4 2.4C-10.4 123.7-12.5 203 31 256h102.4l35.9-86.2c5.4-12.9 23.6-13.2 29.4-.4l58.2 129.3 49-97.9c5.9-11.8 22.7-11.8 28.6 0l27.6 55.2H481c43.5-53 41.4-132.3-7.3-182.1z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="content">
|
||||
<span class="metric-value" id="heartrate"></span>
|
||||
<span class="metric-unit">bpm</span>
|
||||
</div>
|
||||
<div id="heartrate-battery-container">
|
||||
<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="M544 160v64h32v64h-32v64H64V160h480m16-64H48c-26.51 0-48 21.49-48 48v224c0 26.51 21.49 48 48 48h512c26.51 0 48-21.49 48-48v-16h8c13.255 0 24-10.745 24-24V184c0-13.255-10.745-24-24-24h-8v-16c0-26.51-21.49-48-48-48z"></path>
|
||||
<rect fill="currentColor" id="battery-level" x="96" y="192" width="416" height="128"></rect>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="label">
|
||||
<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="M323.56 51.2c-20.8 19.3-39.58 39.59-56.22 59.97C240.08 73.62 206.28 35.53 168 0 69.74 91.17 0 209.96 0 281.6 0 408.85 100.29 512 224 512s224-103.15 224-230.4c0-53.27-51.98-163.14-124.44-230.4zm-19.47 340.65C282.43 407.01 255.72 416 226.86 416 154.71 416 96 368.26 96 290.75c0-38.61 24.31-72.63 72.79-130.75 6.93 7.98 98.83 125.34 98.83 125.34l58.63-66.88c4.14 6.85 7.91 13.55 11.27 19.97 27.35 52.19 15.81 118.97-33.43 153.42z"></path></svg>
|
||||
</div>
|
||||
<div class="content">
|
||||
<span class="metric-value" id="caloriesTotal"></span>
|
||||
<span class="metric-unit">kcal</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="label">
|
||||
|
||||
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256,8C119,8,8,119,8,256S119,504,256,504,504,393,504,256,393,8,256,8Zm92.49,313h0l-20,25a16,16,0,0,1-22.49,2.5h0l-67-49.72a40,40,0,0,1-15-31.23V112a16,16,0,0,1,16-16h32a16,16,0,0,1,16,16V256l58,42.5A16,16,0,0,1,348.49,321Z"></path></svg>
|
||||
</div>
|
||||
<div class="content">
|
||||
<span class="metric-value" id="durationTotalFormatted"></span>
|
||||
<span class="metric-unit"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col actions">
|
||||
<div class="content">
|
||||
<button onclick="app.reset()">
|
||||
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path fill="currentColor" d="M212.333 224.333H12c-6.627 0-12-5.373-12-12V12C0 5.373 5.373 0 12 0h48c6.627 0 12 5.373 12 12v78.112C117.773 39.279 184.26 7.47 258.175 8.007c136.906.994 246.448 111.623 246.157 248.532C504.041 393.258 393.12 504 256.333 504c-64.089 0-122.496-24.313-166.51-64.215-5.099-4.622-5.334-12.554-.467-17.42l33.967-33.967c4.474-4.474 11.662-4.717 16.401-.525C170.76 415.336 211.58 432 256.333 432c97.268 0 176-78.716 176-176 0-97.267-78.716-176-176-176-58.496 0-110.28 28.476-142.274 72.333h98.274c6.627 0 12 5.373 12 12v48c0 6.627-5.373 12-12 12z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="app.toggleFullscreen()" id="fullscreen-button">
|
||||
<div id="fullscreen-icon">
|
||||
<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>
|
||||
</div>
|
||||
<div id="windowed-icon">
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
<button onclick="app.close()" id="close-button">
|
||||
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path fill="currentColor" d="M400 54.1c63 45 104 118.6 104 201.9 0 136.8-110.8 247.7-247.5 248C120 504.3 8.2 393 8 256.4 7.9 173.1 48.9 99.3 111.8 54.2c11.7-8.3 28-4.8 35 7.7L162.6 90c5.9 10.5 3.1 23.8-6.6 31-41.5 30.8-68 79.6-68 134.9-.1 92.3 74.5 168.1 168 168.1 91.6 0 168.6-74.2 168-169.1-.3-51.8-24.7-101.8-68.1-134-9.7-7.2-12.4-20.5-6.5-30.9l15.8-28.1c7-12.4 23.2-16.1 34.8-7.8zM296 264V24c0-13.3-10.7-24-24-24h-32c-13.3 0-24 10.7-24 24v240c0 13.3 10.7 24 24 24h32c13.3 0 24-10.7 24-24z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="app.switchPeripheralMode()">
|
||||
<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>
|
||||
</button>
|
||||
<div class="metric-unit" id="peripheralMode"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<web-app></web-app>
|
||||
<script type="module" src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,72 @@
|
|||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
Init file for the web frontend
|
||||
Main Initialization Component of the Web Component App
|
||||
*/
|
||||
import { createApp } from './app.js'
|
||||
|
||||
window.app = createApp()
|
||||
import { LitElement, html } from 'lit'
|
||||
import { customElement, state } from 'lit/decorators.js'
|
||||
import { APP_STATE } from './store/appState.js'
|
||||
import { createApp } from './lib/app.js'
|
||||
import './components/PerformanceDashboard.js'
|
||||
|
||||
@customElement('web-app')
|
||||
export class App extends LitElement {
|
||||
@state()
|
||||
appState = APP_STATE
|
||||
|
||||
@state()
|
||||
metrics
|
||||
|
||||
constructor () {
|
||||
super()
|
||||
|
||||
this.app = createApp({
|
||||
updateState: this.updateState,
|
||||
getState: this.getState
|
||||
// todo: we also want a mechanism here to get notified of state changes
|
||||
})
|
||||
|
||||
// this is how we implement changes to the global state:
|
||||
// once any child component sends this CustomEvent we update the global state according
|
||||
// to the changes that were passed to us
|
||||
this.addEventListener('appStateChanged', (event) => {
|
||||
this.updateState(event.detail)
|
||||
})
|
||||
|
||||
// notify the app about the triggered action
|
||||
this.addEventListener('triggerAction', (event) => {
|
||||
this.app.handleAction(event.detail)
|
||||
})
|
||||
}
|
||||
|
||||
// the global state is updated by replacing the appState with a copy of the new state
|
||||
// todo: maybe it is more convenient to just pass the state elements that should be changed?
|
||||
// i.e. do something like this.appState = { ..this.appState, ...newState }
|
||||
updateState = (newState) => {
|
||||
this.appState = { ...newState }
|
||||
}
|
||||
|
||||
// return a deep copy of the state to other components to minimize risk of side effects
|
||||
getState = () => {
|
||||
// could use structuredClone once the browser support is wider
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/structuredClone
|
||||
return JSON.parse(JSON.stringify(this.appState))
|
||||
}
|
||||
|
||||
// once we have multiple views, then we would rather reference some kind of router here
|
||||
// instead of embedding the performance-dashboard directly
|
||||
render () {
|
||||
return html`
|
||||
<performance-dashboard
|
||||
.appState=${this.appState}
|
||||
.metrics=${this.metrics}
|
||||
></performance-dashboard>
|
||||
`
|
||||
}
|
||||
|
||||
// there is no need to put this initialization component into a shadow root
|
||||
createRenderRoot () {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
'use strict'
|
||||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
Initialization file of the Open Rowing Monitor App
|
||||
*/
|
||||
|
||||
import NoSleep from 'nosleep.js'
|
||||
import { filterObjectByKeys } from './helper.js'
|
||||
|
||||
const rowingMetricsFields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', 'power', 'heartrate',
|
||||
'heartrateBatteryLevel', 'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted']
|
||||
|
||||
export function createApp (app) {
|
||||
const mode = window.location.hash
|
||||
const appMode = mode === '#:standalone:' ? 'STANDALONE' : mode === '#:kiosk:' ? 'KIOSK' : 'BROWSER'
|
||||
app.updateState({ ...app.getState(), appMode })
|
||||
|
||||
let socket
|
||||
|
||||
initWebsocket()
|
||||
resetFields()
|
||||
requestWakeLock()
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
socket.addEventListener('error', (error) => {
|
||||
console.log('websocket error', error)
|
||||
socket.close()
|
||||
})
|
||||
|
||||
socket.addEventListener('close', (event) => {
|
||||
console.log('websocket closed, attempting reconnect')
|
||||
setTimeout(() => {
|
||||
initWebsocket()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
// 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 filteredData = filterObjectByKeys(data, activeFields)
|
||||
|
||||
let updatedState = { ...app.getState(), metrics: filteredData }
|
||||
if (data.peripheralMode) {
|
||||
updatedState = { ...app.getState(), peripheralMode: data.peripheralMode }
|
||||
}
|
||||
app.updateState(updatedState)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function requestWakeLock () {
|
||||
// Chrome enables the new Wake Lock API only if the connection is secured via SSL
|
||||
// This is quite annoying for IoT use cases like this one, where the device sits on the
|
||||
// local network and is directly addressed by its IP.
|
||||
// In this case the only way of using SSL is by creating a self signed certificate, and
|
||||
// that would pop up different warnings in the browser (and also prevents fullscreen via
|
||||
// a home screen icon so it can show these warnings). Okay, enough ranting :-)
|
||||
// In this case we use the good old hacky way of keeping the screen on via a hidden video.
|
||||
const noSleep = new NoSleep()
|
||||
document.addEventListener('click', function enableNoSleep () {
|
||||
document.removeEventListener('click', enableNoSleep, false)
|
||||
noSleep.enable()
|
||||
}, false)
|
||||
}
|
||||
|
||||
function resetFields () {
|
||||
const appState = app.getState()
|
||||
// drop all metrics except heartrate
|
||||
appState.metrics = filterObjectByKeys(appState.metrics, ['heartrate', 'heartrateBatteryLevel'])
|
||||
app.updateState(appState)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleAction
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
'use strict'
|
||||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
Helper functions
|
||||
*/
|
||||
|
||||
// Filters an object so that it only contains the attributes that are defined in a list
|
||||
export function filterObjectByKeys (object, keys) {
|
||||
return Object.keys(object)
|
||||
.filter(key => keys.includes(key))
|
||||
.reduce((obj, key) => {
|
||||
obj[key] = object[key]
|
||||
return obj
|
||||
}, {})
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
'use strict'
|
||||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
*/
|
||||
import { test } from 'uvu'
|
||||
import * as assert from 'uvu/assert'
|
||||
|
||||
import { filterObjectByKeys } from './helper.js'
|
||||
|
||||
test('filterd list should only contain the elements specified', () => {
|
||||
const object1 = {
|
||||
a: ['a1', 'a2'],
|
||||
b: 'b'
|
||||
}
|
||||
|
||||
const object2 = {
|
||||
a: ['a1', 'a2']
|
||||
}
|
||||
|
||||
const filteredObject = filterObjectByKeys(object1, ['a'])
|
||||
assert.equal(filterObjectByKeys(filteredObject, ['a']), object2)
|
||||
})
|
||||
|
||||
test.run()
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
'use strict'
|
||||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
SVG Icons that are used by the Application
|
||||
*/
|
||||
|
||||
import { svg } from 'lit'
|
||||
|
||||
export const icon_route = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M416 320h-96c-17.6 0-32-14.4-32-32s14.4-32 32-32h96s96-107 96-160-43-96-96-96-96 43-96 96c0 25.5 22.2 63.4 45.3 96H320c-52.9 0-96 43.1-96 96s43.1 96 96 96h96c17.6 0 32 14.4 32 32s-14.4 32-32 32H185.5c-16 24.8-33.8 47.7-47.3 64H416c52.9 0 96-43.1 96-96s-43.1-96-96-96zm0-256c17.7 0 32 14.3 32 32s-14.3 32-32 32-32-14.3-32-32 14.3-32 32-32zM96 256c-53 0-96 43-96 96s96 160 96 160 96-107 96-160-43-96-96-96zm0 128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"></path></svg>`
|
||||
export const icon_stopwatch = 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="M432 304c0 114.9-93.1 208-208 208S16 418.9 16 304c0-104 76.3-190.2 176-205.5V64h-28c-6.6 0-12-5.4-12-12V12c0-6.6 5.4-12 12-12h120c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-28v34.5c37.5 5.8 71.7 21.6 99.7 44.6l27.5-27.5c4.7-4.7 12.3-4.7 17 0l28.3 28.3c4.7 4.7 4.7 12.3 0 17l-29.4 29.4-.6.6C419.7 223.3 432 262.2 432 304zm-176 36V188.5c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12V340c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12z"></path></svg>`
|
||||
export const icon_bolt = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path fill="currentColor" d="M296 160H180.6l42.6-129.8C227.2 15 215.7 0 200 0H56C44 0 33.8 8.9 32.2 20.8l-32 240C-1.7 275.2 9.5 288 24 288h118.7L96.6 482.5c-3.6 15.2 8 29.5 23.3 29.5 8.4 0 16.4-4.4 20.8-12l176-304c9.3-15.9-2.2-36-20.7-36z"></path></svg>`
|
||||
export const icon_paddle = svg`
|
||||
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path fill="currentColor" d="m 420.20605,468.95387 33.11371,-33.1137 c 5.22683,-5.22684 8.77492,-17.24658 3.54807,-22.47345 l -2.40714,-2.40718 c -5.22685,-5.22685 -24.05986,3.18856 -30.46144,6.8845 L 208.43041,202.27519 C 197.48359,161.4211 171.36396,110.7189 139.6182,78.964771 L 104.97336,44.31993 C 84.074343,23.420923 78.962098,22.904081 62.197813,39.66838 L 24.014429,77.851751 C 9.0221901,92.843993 7.7868003,99.708469 28.685812,120.60748 l 34.653205,34.65321 c 30.664056,30.66402 82.456343,57.8654 123.310393,68.8122 l 215.56885,215.56886 c -3.69593,6.40155 -12.11135,25.23463 -6.88448,30.46145 l 2.40718,2.40715 c 5.2185,5.21846 17.23824,1.67039 22.46509,-3.55648 z"/>
|
||||
<path fill="currentColor" d="M 93.111861,469.41843 59.998156,436.30471 c -5.226842,-5.22684 -8.774914,-17.24659 -3.548088,-22.47344 l 2.407166,-2.40717 c 5.226836,-5.22685 24.059868,3.18854 30.46142,6.88449 L 304.88751,202.73974 c 10.94682,-40.85409 37.06645,-91.55629 68.81223,-123.310429 L 408.34455,44.78448 c 20.89903,-20.899007 26.01127,-21.415839 42.77554,-4.65156 l 38.18338,38.183384 c 14.99224,14.992241 16.22764,21.856706 -4.67138,42.755726 l -34.65319,34.6532 c -30.66405,30.66404 -82.45634,57.8654 -123.31038,68.8122 L 111.09965,440.10631 c 3.69593,6.40153 12.11135,25.23461 6.88448,30.46143 l -2.40718,2.40716 c -5.21849,5.21846 -17.23824,1.67038 -22.465089,-3.55647 z"/>
|
||||
</svg>
|
||||
`
|
||||
export const icon_heartbeat = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M320.2 243.8l-49.7 99.4c-6 12.1-23.4 11.7-28.9-.6l-56.9-126.3-30 71.7H60.6l182.5 186.5c7.1 7.3 18.6 7.3 25.7 0L451.4 288H342.3l-22.1-44.2zM473.7 73.9l-2.4-2.5c-51.5-52.6-135.8-52.6-187.4 0L256 100l-27.9-28.5c-51.5-52.7-135.9-52.7-187.4 0l-2.4 2.4C-10.4 123.7-12.5 203 31 256h102.4l35.9-86.2c5.4-12.9 23.6-13.2 29.4-.4l58.2 129.3 49-97.9c5.9-11.8 22.7-11.8 28.6 0l27.6 55.2H481c43.5-53 41.4-132.3-7.3-182.1z"></path></svg>`
|
||||
export const icon_fire = 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="M323.56 51.2c-20.8 19.3-39.58 39.59-56.22 59.97C240.08 73.62 206.28 35.53 168 0 69.74 91.17 0 209.96 0 281.6 0 408.85 100.29 512 224 512s224-103.15 224-230.4c0-53.27-51.98-163.14-124.44-230.4zm-19.47 340.65C282.43 407.01 255.72 416 226.86 416 154.71 416 96 368.26 96 290.75c0-38.61 24.31-72.63 72.79-130.75 6.93 7.98 98.83 125.34 98.83 125.34l58.63-66.88c4.14 6.85 7.91 13.55 11.27 19.97 27.35 52.19 15.81 118.97-33.43 153.42z"></path></svg>`
|
||||
export const icon_clock = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256,8C119,8,8,119,8,256S119,504,256,504,504,393,504,256,393,8,256,8Zm92.49,313h0l-20,25a16,16,0,0,1-22.49,2.5h0l-67-49.72a40,40,0,0,1-15-31.23V112a16,16,0,0,1,16-16h32a16,16,0,0,1,16,16V256l58,42.5A16,16,0,0,1,348.49,321Z"></path></svg>`
|
||||
export const icon_undo = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M212.333 224.333H12c-6.627 0-12-5.373-12-12V12C0 5.373 5.373 0 12 0h48c6.627 0 12 5.373 12 12v78.112C117.773 39.279 184.26 7.47 258.175 8.007c136.906.994 246.448 111.623 246.157 248.532C504.041 393.258 393.12 504 256.333 504c-64.089 0-122.496-24.313-166.51-64.215-5.099-4.622-5.334-12.554-.467-17.42l33.967-33.967c4.474-4.474 11.662-4.717 16.401-.525C170.76 415.336 211.58 432 256.333 432c97.268 0 176-78.716 176-176 0-97.267-78.716-176-176-176-58.496 0-110.28 28.476-142.274 72.333h98.274c6.627 0 12 5.373 12 12v48c0 6.627-5.373 12-12 12z"></path></svg>`
|
||||
export const icon_poweroff = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M400 54.1c63 45 104 118.6 104 201.9 0 136.8-110.8 247.7-247.5 248C120 504.3 8.2 393 8 256.4 7.9 173.1 48.9 99.3 111.8 54.2c11.7-8.3 28-4.8 35 7.7L162.6 90c5.9 10.5 3.1 23.8-6.6 31-41.5 30.8-68 79.6-68 134.9-.1 92.3 74.5 168.1 168 168.1 91.6 0 168.6-74.2 168-169.1-.3-51.8-24.7-101.8-68.1-134-9.7-7.2-12.4-20.5-6.5-30.9l15.8-28.1c7-12.4 23.2-16.1 34.8-7.8zM296 264V24c0-13.3-10.7-24-24-24h-32c-13.3 0-24 10.7-24 24v240c0 13.3 10.7 24 24 24h32c13.3 0 24-10.7 24-24z"></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_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>`
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
'use strict'
|
||||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
Defines the global state of the app
|
||||
*/
|
||||
|
||||
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: {}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ body {
|
|||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.grid {
|
||||
performance-dashboard {
|
||||
display: grid;
|
||||
height: calc(100vh - 2vw);
|
||||
padding: 1vw;
|
||||
|
|
@ -20,18 +20,19 @@ body {
|
|||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.col {
|
||||
dashboard-metric, dashboard-actions {
|
||||
background: #002b57;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
padding: 0.5em 0.2em 0 0.2em;
|
||||
}
|
||||
|
||||
.col.actions {
|
||||
dashboard-actions {
|
||||
padding: 0.5em 0 0 0;
|
||||
}
|
||||
|
||||
@media (orientation: portrait) {
|
||||
.grid {
|
||||
performance-dashboard {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
|
@ -70,7 +71,6 @@ div.label, div.content {
|
|||
right: 0.2em;
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 150%;
|
||||
|
|
@ -93,13 +93,3 @@ button {
|
|||
display: inline-block;
|
||||
width: 3.5em;
|
||||
}
|
||||
|
||||
#close-button, #heartrate-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#heartrate-container, #strokes-total-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ for (const [loggerName, logLevel] of Object.entries(config.loglevel)) {
|
|||
}
|
||||
}
|
||||
|
||||
log.info(`==== Open Rowing Monitor ${process.env.npm_package_version} ====\n`)
|
||||
log.info(`==== Open Rowing Monitor ${process.env.npm_package_version || ''} ====\n`)
|
||||
|
||||
const peripheralManager = createPeripheralManager()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"targets": {
|
||||
"esmodules": true
|
||||
},
|
||||
"shippedProposals": true,
|
||||
"bugfixes": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
["@babel/plugin-proposal-decorators", { "decoratorsBeforeExport": true }]
|
||||
]
|
||||
}
|
||||
|
|
@ -98,14 +98,15 @@ CURRENT_DIR=$(pwd)
|
|||
SCRIPT_DIR="$( cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd )"
|
||||
INSTALL_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
GIT_REMOTE="https://github.com/laberning/openrowingmonitor.git"
|
||||
|
||||
cd $INSTALL_DIR
|
||||
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
LOCAL_VERSION=$(git rev-parse HEAD)
|
||||
|
||||
print "Update script for Open Rowing Monitor"
|
||||
print
|
||||
|
||||
cd $INSTALL_DIR
|
||||
|
||||
if getopts "b:" arg; then
|
||||
if [ $CURRENT_BRANCH = $OPTARG ]; then
|
||||
cancel "No need to switch to branch \"$OPTARG\", it is already the active branch"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "openrowingmonitor",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"description": "A rowing monitor for rowing exercise machines",
|
||||
"main": "app/server.js",
|
||||
"author": "Lars Berning",
|
||||
|
|
@ -16,9 +16,11 @@
|
|||
"scripts": {
|
||||
"lint": "eslint ./app ./config && markdownlint '**/*.md' --ignore node_modules",
|
||||
"start": "node app/server.js",
|
||||
"dev": "npm-run-all --parallel start build:watch",
|
||||
"build": "snowpack build",
|
||||
"build:watch": "snowpack build --watch",
|
||||
"dev": "npm-run-all --parallel dev:backend dev:frontend",
|
||||
"dev:backend": "nodemon app/server.js",
|
||||
"dev:frontend": "snowpack dev",
|
||||
"build": "rollup -c",
|
||||
"build:watch": "rollup -cw",
|
||||
"test": "uvu"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
|
|
@ -29,6 +31,7 @@
|
|||
"@abandonware/noble": "1.9.2-15",
|
||||
"ant-plus": "0.1.24",
|
||||
"finalhandler": "1.1.2",
|
||||
"lit": "2.1.1",
|
||||
"loglevel": "1.8.0",
|
||||
"nosleep.js": "0.12.0",
|
||||
"onoff": "6.0.3",
|
||||
|
|
@ -40,15 +43,31 @@
|
|||
"@abandonware/bluetooth-hci-socket": "0.5.3-7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "7.16.5",
|
||||
"@babel/plugin-proposal-decorators": "7.16.7",
|
||||
"@babel/preset-env": "7.16.7",
|
||||
"@rollup/plugin-babel": "5.3.0",
|
||||
"@rollup/plugin-commonjs": "21.0.1",
|
||||
"@rollup/plugin-node-resolve": "13.1.3",
|
||||
"@snowpack/plugin-babel": "2.1.7",
|
||||
"@web/rollup-plugin-html": "1.10.1",
|
||||
"eslint": "7.32.0",
|
||||
"eslint-config-standard": "16.0.3",
|
||||
"eslint-plugin-import": "2.25.4",
|
||||
"eslint-plugin-lit": "1.6.1",
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
"eslint-plugin-promise": "5.2.0",
|
||||
"eslint-plugin-wc": "1.3.2",
|
||||
"http2-proxy": "5.0.53",
|
||||
"markdownlint-cli": "0.30.0",
|
||||
"nodemon": "2.0.15",
|
||||
"npm-run-all": "4.1.5",
|
||||
"rollup": "2.63.0",
|
||||
"rollup-plugin-summary": "1.3.0",
|
||||
"rollup-plugin-terser": "7.0.2",
|
||||
"simple-git-hooks": "2.7.0",
|
||||
"snowpack": "3.8.8",
|
||||
"tar": "6.1.11",
|
||||
"uvu": "0.5.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
// Import rollup plugins
|
||||
import html from '@web/rollup-plugin-html'
|
||||
import resolve from '@rollup/plugin-node-resolve'
|
||||
import commonjs from '@rollup/plugin-commonjs'
|
||||
import { babel } from '@rollup/plugin-babel'
|
||||
import { terser } from 'rollup-plugin-terser'
|
||||
import summary from 'rollup-plugin-summary'
|
||||
|
||||
// Configure an instance of @web/rollup-plugin-html
|
||||
const htmlPlugin = html({
|
||||
rootDir: './app/client',
|
||||
flattenOutput: false
|
||||
})
|
||||
|
||||
export default {
|
||||
// Entry point for application build; can specify a glob to build multiple
|
||||
// HTML files for non-SPA app
|
||||
input: 'index.html',
|
||||
plugins: [
|
||||
htmlPlugin,
|
||||
// transpile decorators so we can use the upcoming ES decorator syntax
|
||||
babel({
|
||||
babelrc: true,
|
||||
babelHelpers: 'bundled'
|
||||
}),
|
||||
// convert modules with commonJS syntax to ESM
|
||||
commonjs(),
|
||||
// resolve bare module specifiers to relative paths
|
||||
resolve(),
|
||||
// minify JS
|
||||
terser({
|
||||
ecma: 2020,
|
||||
module: true,
|
||||
warnings: true,
|
||||
mangle: {
|
||||
properties: {
|
||||
regex: /^__/
|
||||
}
|
||||
}
|
||||
}),
|
||||
summary()
|
||||
],
|
||||
output:
|
||||
{
|
||||
format: 'es',
|
||||
chunkFileNames: '[name]-[hash].js',
|
||||
entryFileNames: '[name]-[hash].js',
|
||||
dir: 'build'
|
||||
},
|
||||
preserveEntrySignatures: false
|
||||
}
|
||||
|
|
@ -1,19 +1,26 @@
|
|||
// Snowpack Configuration File
|
||||
// See all supported options: https://www.snowpack.dev/reference/configuration
|
||||
import proxy from 'http2-proxy'
|
||||
import { nodeResolve } from '@rollup/plugin-node-resolve'
|
||||
|
||||
// todo: might add a proxy for websockets here so we can use snowpack dev server with HMR
|
||||
export default {
|
||||
mount: {
|
||||
// the web frontend is located in this directory
|
||||
'./app/client': { url: '/' }
|
||||
// mount "public" to the root URL path ("/*") and serve files with zero transformations:
|
||||
// './public': { url: '/', static: true, resolve: false }
|
||||
},
|
||||
plugins: [
|
||||
/* ... */
|
||||
],
|
||||
plugins: ['@snowpack/plugin-babel'],
|
||||
mode: 'development',
|
||||
packageOptions: {
|
||||
/* ... */
|
||||
rollup: {
|
||||
plugins: [
|
||||
// todo: related to the lit documentation this should enable development mode
|
||||
// unfortunately this currently does not seem to work
|
||||
nodeResolve({
|
||||
exportConditions: ['development'],
|
||||
dedupe: true
|
||||
})
|
||||
]
|
||||
}
|
||||
},
|
||||
devOptions: {
|
||||
open: 'none',
|
||||
|
|
@ -21,5 +28,37 @@ export default {
|
|||
},
|
||||
buildOptions: {
|
||||
out: 'build'
|
||||
}
|
||||
},
|
||||
optimize: {
|
||||
bundle: true,
|
||||
treeshake: true,
|
||||
minify: false,
|
||||
target: 'es2020',
|
||||
sourcemap: false
|
||||
},
|
||||
// add a proxy for websocket requests for the dev setting
|
||||
routes: [
|
||||
{
|
||||
src: '/websocket',
|
||||
upgrade: (req, socket, head) => {
|
||||
const defaultWSHandler = (err, req, socket, head) => {
|
||||
if (err) {
|
||||
console.error('proxy error', err)
|
||||
socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
proxy.ws(
|
||||
req,
|
||||
socket,
|
||||
head,
|
||||
{
|
||||
hostname: 'localhost',
|
||||
port: 80
|
||||
},
|
||||
defaultWSHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue