diff --git a/app/WebServer.js b/app/WebServer.js index 225f8f7..e2a318e 100644 --- a/app/WebServer.js +++ b/app/WebServer.js @@ -30,6 +30,7 @@ function createWebServer () { wss.on('connection', function connection (ws) { log.debug('websocket client connected') + emitter.emit('clientConnected', ws) ws.on('message', function incoming (data) { try { const message = JSON.parse(data) diff --git a/app/ble/FtmsPeripheral.js b/app/ble/FtmsPeripheral.js index 434cca2..afabd8f 100644 --- a/app/ble/FtmsPeripheral.js +++ b/app/ble/FtmsPeripheral.js @@ -26,17 +26,7 @@ function createFtmsPeripheral (options) { const deviceInformationService = new DeviceInformationService() bleno.on('stateChange', (state) => { - if (state === 'poweredOn') { - bleno.startAdvertising( - peripheralName, - [fitnessMachineService.uuid, deviceInformationService.uuid], - (error) => { - if (error) log.error(error) - } - ) - } else { - bleno.stopAdvertising() - } + triggerAdvertising(state) }) bleno.on('advertisingStart', (error) => { @@ -70,9 +60,6 @@ function createFtmsPeripheral (options) { bleno.on('advertisingStartError', (event) => { log.debug('advertisingStartError', event) }) - bleno.on('advertisingStop', (event) => { - log.debug('advertisingStop', event) - }) bleno.on('servicesSetError', (event) => { log.debug('servicesSetError', event) }) @@ -89,6 +76,29 @@ function createFtmsPeripheral (options) { return obj.res } + function destroy () { + return new Promise((resolve) => { + bleno.disconnect() + bleno.removeAllListeners() + bleno.stopAdvertising(resolve) + }) + } + + function triggerAdvertising (eventState) { + const activeState = eventState || bleno.state + if (activeState === 'poweredOn') { + bleno.startAdvertising( + peripheralName, + [fitnessMachineService.uuid, deviceInformationService.uuid], + (error) => { + if (error) log.error(error) + } + ) + } else { + bleno.stopAdvertising() + } + } + // deliver current rowing metrics via BLE function notifyData (data) { fitnessMachineService.notifyData(data) @@ -100,8 +110,10 @@ function createFtmsPeripheral (options) { } return Object.assign(emitter, { + triggerAdvertising, notifyData, - notifyStatus + notifyStatus, + destroy }) } diff --git a/app/ble/PeripheralManager.js b/app/ble/PeripheralManager.js new file mode 100644 index 0000000..70ecaec --- /dev/null +++ b/app/ble/PeripheralManager.js @@ -0,0 +1,95 @@ +'use strict' +/* + Open Rowing Monitor, https://github.com/laberning/openrowingmonitor + + This manager creates the different Bluetooth Low Energy (BLE) Peripherals and allows + switching between them +*/ +import config from '../config.js' +import { createFtmsPeripheral } from './FtmsPeripheral.js' +import { createPm5Peripheral } from './Pm5Peripheral.js' +import log from 'loglevel' +import EventEmitter from 'node:events' + +const modes = ['FTMS', 'FTMSBIKE', 'PM5'] +function createPeripheralManager () { + const emitter = new EventEmitter() + let peripheral + let mode + + createPeripheral(config.bluetoothMode) + + function getPeripheral () { + return peripheral + } + + function getPeripheralMode () { + return mode + } + + function switchPeripheralMode (newMode) { + // if now mode was passed, select the next one from the list + if (newMode === undefined) { + newMode = modes[(modes.indexOf(mode) + 1) % modes.length] + } + createPeripheral(newMode) + } + + function notifyMetrics (type, metrics) { + peripheral.notifyData(metrics) + } + + function notifyStatus (status) { + peripheral.notifyStatus(status) + } + + async function createPeripheral (newMode) { + if (peripheral) { + await peripheral.destroy() + peripheral.off('controlPoint', emitControlPointEvent) + } + + if (newMode === 'PM5') { + log.info('bluetooth profile: Concept2 PM5') + peripheral = createPm5Peripheral() + mode = 'PM5' + } else if (newMode === 'FTMSBIKE') { + log.info('bluetooth profile: FTMS Indoor Bike') + peripheral = createFtmsPeripheral({ + simulateIndoorBike: true + }) + mode = 'FTMSBIKE' + } else { + log.info('bluetooth profile: FTMS Rower') + peripheral = createFtmsPeripheral({ + simulateIndoorBike: false + }) + mode = 'FTMS' + } + // todo: re-emitting is not that great, maybe use callbacks instead + peripheral.on('control', emitControlPointEvent) + + peripheral.triggerAdvertising() + + emitter.emit('control', { + req: { + name: 'peripheralMode', + peripheralMode: mode + } + }) + } + + function emitControlPointEvent (event) { + emitter.emit('control', event) + } + + return Object.assign(emitter, { + getPeripheral, + getPeripheralMode, + switchPeripheralMode, + notifyMetrics, + notifyStatus + }) +} + +export { createPeripheralManager } diff --git a/app/ble/Pm5Peripheral.js b/app/ble/Pm5Peripheral.js index b76986d..f080f72 100644 --- a/app/ble/Pm5Peripheral.js +++ b/app/ble/Pm5Peripheral.js @@ -26,17 +26,7 @@ function createPm5Peripheral (options) { const rowingService = new Pm5RowingService() bleno.on('stateChange', (state) => { - if (state === 'poweredOn') { - bleno.startAdvertising( - peripheralName, - [gapService.uuid], - (error) => { - if (error) log.error(error) - } - ) - } else { - bleno.stopAdvertising() - } + triggerAdvertising(state) }) bleno.on('advertisingStart', (error) => { @@ -70,12 +60,6 @@ function createPm5Peripheral (options) { bleno.on('advertisingStartError', (event) => { log.debug('advertisingStartError', event) }) - bleno.on('advertisingStop', (event) => { - log.debug('advertisingStop', event) - }) - bleno.on('servicesSet', (event) => { - log.debug('servicesSet') - }) bleno.on('servicesSetError', (event) => { log.debug('servicesSetError', event) }) @@ -83,6 +67,29 @@ function createPm5Peripheral (options) { log.debug('rssiUpdate', event) }) + function destroy () { + return new Promise((resolve) => { + bleno.disconnect() + bleno.removeAllListeners() + bleno.stopAdvertising(resolve) + }) + } + + function triggerAdvertising (eventState) { + const activeState = eventState || bleno.state + if (activeState === 'poweredOn') { + bleno.startAdvertising( + peripheralName, + [gapService.uuid], + (error) => { + if (error) log.error(error) + } + ) + } else { + bleno.stopAdvertising() + } + } + // deliver current rowing metrics via BLE function notifyData (data) { rowingService.notify(data) @@ -95,8 +102,10 @@ function createPm5Peripheral (options) { } return Object.assign(emitter, { + triggerAdvertising, notifyData, - notifyStatus + notifyStatus, + destroy }) } diff --git a/app/client/app.js b/app/client/app.js index 751d9f4..2d076d6 100644 --- a/app/client/app.js +++ b/app/client/app.js @@ -7,7 +7,19 @@ */ // eslint-disable-next-line no-unused-vars export function createApp () { - const fields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', 'power', 'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted'] + const fields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', 'power', 'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted', 'peripheralMode'] + const fieldFormatter = { + peripheralMode: (value) => { + if (value === 'PM5') { + return 'Concept2 PM5' + } else if (value === 'FTMSBIKE') { + return 'FTMS (Bike)' + } else { + return 'FTMS (Rower)' + } + }, + distanceTotal: (value) => value >= 10000 ? { value: (value / 1000).toFixed(1), unit: 'km' } : { value, unit: 'm' } + } let socket initWebsocket() @@ -41,7 +53,13 @@ export function createApp () { for (const [key, value] of Object.entries(data)) { if (fields.includes(key)) { - document.getElementById(key).innerHTML = value + const valueFormatted = fieldFormatter[key] ? fieldFormatter[key](value) : value + if (valueFormatted.value && valueFormatted.unit) { + document.getElementById(key).innerHTML = valueFormatted.value + document.getElementById(`${key}Unit`).innerHTML = valueFormatted.unit + } else { + document.getElementById(key).innerHTML = valueFormatted + } } } } catch (err) { @@ -100,8 +118,13 @@ export function createApp () { if (socket)socket.send(JSON.stringify({ command: 'reset' })) } + function switchPeripheralMode () { + if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' })) + } + return { toggleFullscreen, - reset + reset, + switchPeripheralMode } } diff --git a/app/client/index.html b/app/client/index.html index c268edb..04296d6 100644 --- a/app/client/index.html +++ b/app/client/index.html @@ -25,7 +25,7 @@
Distance
- m + m
@@ -73,6 +73,7 @@
+
diff --git a/app/server.js b/app/server.js index 6656c57..cc5ef2a 100644 --- a/app/server.js +++ b/app/server.js @@ -9,11 +9,10 @@ import { fork } from 'child_process' import log from 'loglevel' import config from './config.js' -import { createFtmsPeripheral } from './ble/FtmsPeripheral.js' -import { createPm5Peripheral } from './ble/Pm5Peripheral.js' import { createRowingEngine } from './engine/RowingEngine.js' import { createRowingStatistics } from './engine/RowingStatistics.js' import { createWebServer } from './WebServer.js' +import { createPeripheralManager } from './ble/PeripheralManager.js' // eslint-disable-next-line no-unused-vars import { recordRowingSession, replayRowingSession } from './tools/RowingRecorder.js' @@ -27,42 +26,31 @@ for (const [loggerName, logLevel] of Object.entries(config.loglevel)) { log.info(`==== Open Rowing Monitor ${process.env.npm_package_version} ====\n`) -let peripheral -if (config.bluetoothMode === 'PM5') { - log.info('bluetooth profile: Concept2 PM5') - peripheral = createPm5Peripheral() -} else if (config.bluetoothMode === 'FTMSBIKE') { - log.info('bluetooth profile: FTMS Indoor Bike') - peripheral = createFtmsPeripheral({ - simulateIndoorBike: true - }) -} else { - log.info('bluetooth profile: FTMS Rower') - peripheral = createFtmsPeripheral({ - simulateIndoorBike: false - }) -} +const peripheralManager = createPeripheralManager() -peripheral.on('controlPoint', (event) => { +peripheralManager.on('control', (event) => { if (event?.req?.name === 'requestControl') { event.res = true } else if (event?.req?.name === 'reset') { log.debug('reset requested') rowingStatistics.reset() - peripheral.notifyStatus({ name: 'reset' }) + peripheralManager.notifyStatus({ name: 'reset' }) event.res = true // todo: we could use these controls once we implement a concept of a rowing session } else if (event?.req?.name === 'stop') { log.debug('stop requested') - peripheral.notifyStatus({ name: 'stoppedOrPausedByUser' }) + peripheralManager.notifyStatus({ name: 'stoppedOrPausedByUser' }) event.res = true } else if (event?.req?.name === 'pause') { log.debug('pause requested') - peripheral.notifyStatus({ name: 'stoppedOrPausedByUser' }) + peripheralManager.notifyStatus({ name: 'stoppedOrPausedByUser' }) event.res = true } else if (event?.req?.name === 'startOrResume') { log.debug('startOrResume requested') - peripheral.notifyStatus({ name: 'startedOrResumedByUser' }) + peripheralManager.notifyStatus({ name: 'startedOrResumedByUser' }) + event.res = true + } else if (event?.req?.name === 'peripheralMode') { + webServer.notifyClients({ peripheralMode: event.req.peripheralMode }) event.res = true } else { log.info('unhandled Command', event.req) @@ -99,7 +87,7 @@ rowingStatistics.on('strokeFinished', (data) => { strokeState: data.strokeState } webServer.notifyClients(metrics) - peripheral.notifyData(metrics) + peripheralManager.notifyMetrics('strokeFinished', metrics) }) rowingStatistics.on('rowingPaused', (data) => { @@ -120,7 +108,7 @@ rowingStatistics.on('rowingPaused', (data) => { strokeState: 'RECOVERY' } webServer.notifyClients(metrics) - peripheral.notifyData(metrics) + peripheralManager.notifyMetrics('rowingPaused', metrics) }) rowingStatistics.on('durationUpdate', (data) => { @@ -133,12 +121,18 @@ const webServer = createWebServer() webServer.on('messageReceived', (message) => { if (message.command === 'reset') { rowingStatistics.reset() - peripheral.notifyStatus({ name: 'reset' }) + peripheralManager.notifyStatus({ name: 'reset' }) + } if (message.command === 'switchPeripheralMode') { + peripheralManager.switchPeripheralMode() } else { log.warn(`invalid command received: ${message}`) } }) +webServer.on('clientConnected', () => { + webServer.notifyClients({ peripheralMode: peripheralManager.getPeripheralMode() }) +}) + // recordRowingSession('recordings/wrx700_2magnets.csv') /* replayRowingSession(rowingEngine.handleRotationImpulse, { diff --git a/doc/backlog.md b/doc/backlog.md index 57e68dc..1c99c2d 100644 --- a/doc/backlog.md +++ b/doc/backlog.md @@ -5,9 +5,10 @@ This is the very minimalistic Backlog for further development of this project. ## Soon * refactor Stroke Phase Handling in RowingStatistics and pm5Peripheral -* calculate the missing energy averages for FTMS -* figure out where to set the Service Advertising Data (FTMS.pdf p 15) +* Web UI: hint, when screen is not in always on mode * Web UI: replace fullscreen button with exit Button when started from home screen +* investigate: occasionally stroke rate is too high - seems to happen after rowing pause +* figure out where to set the Service Advertising Data (FTMS.pdf p 15) * investigate bug: crash, when one unsubscribe to BLE "Generic Attribute", probably a bleno bug "handleAttribute.emit is not a function" * what value should we use for split, if we are in a rowing pause? technically should be infinity... * set up a Raspberry Pi with the installation instructions to see if they are correct