diff --git a/app/client/components/App.js b/app/client/components/App.js deleted file mode 100644 index b42c0e5..0000000 --- a/app/client/components/App.js +++ /dev/null @@ -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` - - ` - } - - metricsUpdated (metrics) { - this.metrics = Object.assign({}, metrics) - } - - createRenderRoot () { - return this - } -} diff --git a/app/client/components/AppElement.js b/app/client/components/AppElement.js index 3f29159..181dce5 100644 --- a/app/client/components/AppElement.js +++ b/app/client/components/AppElement.js @@ -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 diff --git a/app/client/components/DashboardActions.js b/app/client/components/DashboardActions.js index dffa946..a295961 100644 --- a/app/client/components/DashboardActions.js +++ b/app/client/components/DashboardActions.js @@ -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` - - - + ${this.renderOptionalButtons()} -
${this.appState.peripheralMode}
+
${this.appState?.metrics?.peripheralMode?.value}
` } - //
${this.peripheralMode}
+ + 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` + + `) + } + // 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` + + `) + } + 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' }) } } diff --git a/app/client/components/PerformanceDashboard.js b/app/client/components/PerformanceDashboard.js index fffb925..29c6ac8 100644 --- a/app/client/components/PerformanceDashboard.js +++ b/app/client/components/PerformanceDashboard.js @@ -26,19 +26,20 @@ export class PerformanceDashboard extends AppElement { appState = APP_STATE render () { + const metrics = this.appState.metrics return html` - - - - - ${this.metrics?.heartrate?.value + + + + + ${metrics?.heartrate?.value ? html`` - : html``} - - - + .value=${metrics?.heartrate?.value} + .batteryLevel=${metrics?.heartrateBatteryLevel?.value}>` + : html``} + + + ` } } diff --git a/app/client/index.js b/app/client/index.js index 60cb120..adf7d9b 100644 --- a/app/client/index.js +++ b/app/client/index.js @@ -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` + + ` + } + + // there is no need to put this initialization component into a shadow root + createRenderRoot () { + return this + } +} diff --git a/app/client/lib/app.js b/app/client/lib/app.js new file mode 100644 index 0000000..6d95404 --- /dev/null +++ b/app/client/lib/app.js @@ -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 + } +} diff --git a/app/client/lib/network.js b/app/client/lib/network.js index 0d1b1fc..bcca50f 100644 --- a/app/client/lib/network.js +++ b/app/client/lib/network.js @@ -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 - } -} diff --git a/app/client/store/appState.js b/app/client/store/appState.js index 525adc2..7e497a0 100644 --- a/app/client/store/appState.js +++ b/app/client/store/appState.js @@ -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: {} }