implements global state, restructures code

This commit is contained in:
Lars Berning 2022-01-14 19:26:47 +01:00
parent c753cb4cf4
commit 424ba431c7
No known key found for this signature in database
GPG Key ID: 028E73C9E1D8A0B3
8 changed files with 246 additions and 240 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

134
app/client/lib/app.js Normal file
View File

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

View File

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

View File

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