implements global state, restructures code
This commit is contained in:
parent
c753cb4cf4
commit
424ba431c7
|
|
@ -1,63 +0,0 @@
|
|||
'use strict'
|
||||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
Main Component of the Open Rowing Monitor App
|
||||
*/
|
||||
|
||||
import { AppElement, html, css } from './AppElement'
|
||||
import { APP_STATE } from '../store/appState'
|
||||
import { customElement, state } from 'lit/decorators.js'
|
||||
import { createApp } from '../lib/network'
|
||||
import './PerformanceDashboard'
|
||||
|
||||
@customElement('web-app')
|
||||
export class App extends AppElement {
|
||||
static get styles () {
|
||||
return css`
|
||||
`
|
||||
}
|
||||
|
||||
@state()
|
||||
globalAppState = APP_STATE
|
||||
|
||||
constructor () {
|
||||
super()
|
||||
this.app = createApp(this.globalAppState)
|
||||
window.app = this.app
|
||||
this.app.setMetricsCallback(metrics => this.metricsUpdated(metrics))
|
||||
|
||||
// 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) => {
|
||||
const newState = event.detail
|
||||
this.globalAppState = newState
|
||||
})
|
||||
|
||||
this.addEventListener('triggerAction', (event) => {
|
||||
this.app.handleAction(event.detail)
|
||||
})
|
||||
}
|
||||
|
||||
static properties = {
|
||||
metrics: { state: true }
|
||||
};
|
||||
|
||||
render () {
|
||||
return html`
|
||||
<performance-dashboard
|
||||
.appState=${this.globalAppState}
|
||||
.metrics=${this.metrics}
|
||||
></performance-dashboard>
|
||||
`
|
||||
}
|
||||
|
||||
metricsUpdated (metrics) {
|
||||
this.metrics = Object.assign({}, metrics)
|
||||
}
|
||||
|
||||
createRenderRoot () {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ export class AppElement extends LitElement {
|
|||
// ..and state changes are send back to the root component of the app by dispatching
|
||||
// a CustomEvent
|
||||
updateState () {
|
||||
this.sendEvent('appStateChanged', { ...this.appState })
|
||||
this.sendEvent('appStateChanged', this.appState)
|
||||
}
|
||||
|
||||
// a helper to dispatch events to the parent components
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { AppElement, html, css } from './AppElement'
|
||||
import { customElement, property } from 'lit/decorators.js'
|
||||
import { customElement } from 'lit/decorators.js'
|
||||
import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth } from '../lib/icons'
|
||||
|
||||
@customElement('dashboard-actions')
|
||||
|
|
@ -16,23 +16,37 @@ export class DashboardActions extends AppElement {
|
|||
`
|
||||
}
|
||||
|
||||
@property({ type: String })
|
||||
peripheralMode = ''
|
||||
|
||||
render () {
|
||||
return html`
|
||||
<button @click=${this.reset}>${icon_undo}</button>
|
||||
<!-- todo: hide in standalone mode -->
|
||||
<button @click=${this.toggleFullscreen}>
|
||||
<div id="fullscreen-icon">${icon_expand}</div>
|
||||
<div id="windowed-icon">${icon_compress}</div>
|
||||
</button>
|
||||
<button @click=${this.close} id="close-button">${icon_poweroff}</button>
|
||||
${this.renderOptionalButtons()}
|
||||
<button @click=${this.switchPeripheralMode}>${icon_bluetooth}</button>
|
||||
<div class="metric-unit">${this.appState.peripheralMode}</div>
|
||||
<div class="metric-unit">${this.appState?.metrics?.peripheralMode?.value}</div>
|
||||
`
|
||||
}
|
||||
// <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)
|
||||
if (this.appState.appMode === '') {
|
||||
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
|
||||
}
|
||||
|
||||
toggleFullscreen () {
|
||||
const fullscreenElement = document.getElementsByTagName('web-app')[0]
|
||||
|
|
@ -54,9 +68,6 @@ export class DashboardActions extends AppElement {
|
|||
}
|
||||
|
||||
switchPeripheralMode () {
|
||||
// todo: this is just a test property to see if the concept works...
|
||||
// this.appState.peripheralMode = 'PM5'
|
||||
// this.updateState()
|
||||
this.sendEvent('triggerAction', { command: 'switchPeripheralMode' })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,19 +26,20 @@ export class PerformanceDashboard extends AppElement {
|
|||
appState = APP_STATE
|
||||
|
||||
render () {
|
||||
const metrics = this.appState.metrics
|
||||
return html`
|
||||
<dashboard-metric .icon=${icon_route} .unit=${this.metrics?.distanceTotal?.unit} .value=${this.metrics?.distanceTotal?.value}></dashboard-metric>
|
||||
<dashboard-metric .icon=${icon_stopwatch} unit="/500m" .value=${this.metrics?.splitFormatted?.value}></dashboard-metric>
|
||||
<dashboard-metric .icon=${icon_bolt} unit="watt" .value=${this.metrics?.power?.value}></dashboard-metric>
|
||||
<dashboard-metric .icon=${icon_paddle} unit="/min" .value=${this.metrics?.strokesPerMinute?.value}></dashboard-metric>
|
||||
${this.metrics?.heartrate?.value
|
||||
<dashboard-metric .icon=${icon_route} .unit=${metrics?.distanceTotal?.unit} .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=${this.metrics?.heartrate?.value}
|
||||
.batteryLevel=${this.metrics?.heartrateBatteryLevel?.value}></dashboard-metric>`
|
||||
: html`<dashboard-metric .icon=${icon_paddle} unit="total" .value=${this.metrics?.strokesTotal?.value}></dashboard-metric>`}
|
||||
<dashboard-metric .icon=${icon_fire} unit="kcal" .value=${this.metrics?.caloriesTotal?.value}></dashboard-metric>
|
||||
<dashboard-metric .icon=${icon_clock} .value=${this.metrics?.durationTotalFormatted?.value}></dashboard-metric>
|
||||
<dashboard-actions .appState=${this.appState} .peripheralMode=${this.metrics?.peripheralMode?.value}></dashboard-actions>
|
||||
.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>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,72 @@
|
|||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
Initialization file for the web application
|
||||
Main Initialization Component of the Web Component App
|
||||
*/
|
||||
|
||||
import './components/App'
|
||||
import { LitElement, html } from 'lit'
|
||||
import { customElement, state } from 'lit/decorators.js'
|
||||
import { APP_STATE } from './store/appState'
|
||||
import { createApp } from './lib/app.js'
|
||||
import './components/PerformanceDashboard'
|
||||
|
||||
@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
|
||||
// todo: lets see if this solution sticks...
|
||||
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 }
|
||||
// console.table(this.appState.metrics)
|
||||
}
|
||||
|
||||
// return a copy of the state to other components to minimize risk of side effects
|
||||
getState = () => {
|
||||
return { ...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,134 @@
|
|||
'use strict'
|
||||
/*
|
||||
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
||||
|
||||
Initialization file of the Open Rowing Monitor App
|
||||
*/
|
||||
|
||||
import NoSleep from 'nosleep.js'
|
||||
|
||||
export function createApp (app) {
|
||||
const fields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', 'power', 'heartrate',
|
||||
'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted', 'peripheralMode']
|
||||
// todo: formatting should happen in the related components (i.e.) PerformanceDashboard
|
||||
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 mode = window.location.hash
|
||||
const appMode = mode === '#:standalone:' ? 'STANDALONE' : mode === '#:kiosk:' ? 'KIOSK' : ''
|
||||
app.updateState({ ...app.getState(), appMode })
|
||||
|
||||
const metrics = {}
|
||||
|
||||
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)
|
||||
|
||||
let activeFields = fields
|
||||
// if we are in reset state only update heart rate and peripheral mode
|
||||
if (data.strokesTotal === 0) {
|
||||
activeFields = ['heartrate', 'peripheralMode']
|
||||
}
|
||||
|
||||
// todo: formatting should happen in the related components (i.e.) PerformanceDashboard
|
||||
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) {
|
||||
metrics[key] = {
|
||||
value: valueFormatted.value,
|
||||
unit: valueFormatted.unit
|
||||
}
|
||||
} else {
|
||||
metrics[key] = {
|
||||
value: valueFormatted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.updateState({ ...app.getState(), metrics })
|
||||
} 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 () {
|
||||
for (const key of fields.filter((elem) => elem !== 'peripheralMode' && elem !== 'heartrate')) {
|
||||
metrics[key] = { value: '--' }
|
||||
}
|
||||
app.updateState({ ...app.getState(), metrics })
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -2,150 +2,4 @@
|
|||
/*
|
||||
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:')
|
||||
let metricsCallback
|
||||
const metrics = {
|
||||
}
|
||||
/*
|
||||
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)
|
||||
|
||||
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) {
|
||||
metrics[key] = {
|
||||
value: valueFormatted.value,
|
||||
unit: valueFormatted.unit
|
||||
}
|
||||
} else {
|
||||
metrics[key] = {
|
||||
value: valueFormatted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (metricsCallback) {
|
||||
metricsCallback(metrics)
|
||||
}
|
||||
} 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 () {
|
||||
for (const key of fields.filter((elem) => elem !== 'peripheralMode' && elem !== 'heartrate')) {
|
||||
metrics[key] = { value: '--' }
|
||||
if (metricsCallback) {
|
||||
metricsCallback(metrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function setMetricsCallback (callback) {
|
||||
metricsCallback = callback
|
||||
if (metricsCallback) {
|
||||
metricsCallback(metrics)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleAction,
|
||||
metrics,
|
||||
setMetricsCallback
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
*/
|
||||
|
||||
export const APP_STATE = {
|
||||
// todo: this is just a test property to see if the concept works...
|
||||
peripheralMode: 'FTMSROWER'
|
||||
// currently can be STANDALONE (Mobile Home Screen App), KIOSK (Raspberry Pi deployment) or '' (default)
|
||||
appMode: '',
|
||||
// todo: this is currently embedded into the metrics object, but should probably be extracted
|
||||
peripheralMode: 'FTMSROWER',
|
||||
// contains all the rowing metrics that are delivered from the backend
|
||||
metrics: {}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue