diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index d2547a9de8..0000000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "env": {
- "browser": false,
- "node": true,
- "es2021": true
- },
- "extends": [
- "standard"
- ],
- "parserOptions": {
- "ecmaVersion": 13,
- "sourceType": "module"
- },
- "ignorePatterns": ["**/*.min.js"],
- "rules": {
- "camelcase": 0
- }
-}
diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml
index 80f5ee0b7e..836d74aba2 100644
--- a/.github/workflows/node.js.yml
+++ b/.github/workflows/node.js.yml
@@ -19,7 +19,7 @@ jobs:
strategy:
matrix:
- node-version: [14.x, 16.x]
+ node-version: [22.x]
steps:
- name: Checkout repository
diff --git a/.markdownlint.json b/.markdownlint.json
index 75d13e4028..a978447aed 100644
--- a/.markdownlint.json
+++ b/.markdownlint.json
@@ -1,5 +1,6 @@
{
"default": true,
"MD013": false,
- "MD049": { "style": "asterisk" }
+ "MD049": { "style": "asterisk" },
+ "MD059": false
}
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000000..2640e1df91
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+unsafe-perm=true
+user=0
diff --git a/app/WebServer.js b/app/WebServer.js
index 6eb1eec028..dba87d7ab8 100644
--- a/app/WebServer.js
+++ b/app/WebServer.js
@@ -1,10 +1,10 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Creates the WebServer which serves the static assets and communicates with the clients
- via WebSockets
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
+/**
+ * Creates the WebServer which serves the static assets and communicates with the clients via WebSockets
+ */
import { WebSocket, WebSocketServer } from 'ws'
import finalhandler from 'finalhandler'
import http from 'http'
@@ -12,17 +12,22 @@ import serveStatic from 'serve-static'
import log from 'loglevel'
import EventEmitter from 'events'
-function createWebServer () {
+export function createWebServer (config) {
const emitter = new EventEmitter()
const port = process.env.PORT || 80
const serve = serveStatic('./build', { index: ['index.html'] })
+ let timer = setTimeout(timeBasedPresenter, config.webUpdateInterval)
+ let lastKnownMetrics
+ let heartRate
+ let heartRateBatteryLevel
+ resetLastKnownMetrics()
const server = http.createServer((req, res) => {
serve(req, res, finalhandler(req, res))
})
server.listen(port, (err) => {
- if (err) throw err
+ if (err) { throw err }
log.info(`webserver running on port ${port}`)
})
@@ -30,12 +35,13 @@ function createWebServer () {
wss.on('connection', function connection (client) {
log.debug('websocket client connected')
- emitter.emit('clientConnected', client)
+ notifyClient(client, 'config', getConfig())
+ notifyClient(client, 'metrics', lastKnownMetrics)
client.on('message', function incoming (data) {
try {
const message = JSON.parse(data)
if (message) {
- emitter.emit('messageReceived', message, client)
+ emitter.emit('messageReceived', message)
} else {
log.warn(`invalid message received: ${data}`)
}
@@ -43,11 +49,105 @@ function createWebServer () {
log.error(err)
}
})
- client.on('close', function () {
+ client.on('close', function close () {
log.debug('websocket client disconnected')
})
})
+ // This function handles all incomming commands. As all commands are broadasted to all application parts,
+ // we need to filter here what the webserver will react to and what it will ignore
+ // The start...reset commands are handled by the RowingEngine and the result will be reported by the metrics update, so we ignore them here
+ /* eslint-disable-next-line no-unused-vars -- this is part of the standardised handleCommand interface */
+ function handleCommand (commandName, data) {
+ switch (commandName) {
+ case ('updateIntervalSettings'):
+ break
+ case ('start'):
+ break
+ case ('startOrResume'):
+ break
+ case ('pause'):
+ break
+ case ('stop'):
+ break
+ case ('reset'):
+ resetLastKnownMetrics()
+ notifyClients('metrics', lastKnownMetrics)
+ break
+ case 'switchBlePeripheralMode':
+ break
+ case 'switchAntPeripheralMode':
+ break
+ case 'switchHrmMode':
+ break
+ case 'refreshPeripheralConfig':
+ notifyClients('config', getConfig())
+ break
+ case 'upload':
+ break
+ case 'shutdown':
+ break
+ default:
+ log.error(`WebServer: Recieved unknown command: ${commandName}`)
+ }
+ }
+
+ function presentRowingMetrics (metrics) {
+ if (metrics.metricsContext === undefined) { return }
+ switch (true) {
+ case (metrics.metricsContext.isSessionStart):
+ notifyClients('metrics', metrics)
+ break
+ case (metrics.metricsContext.isSessionStop):
+ notifyClients('metrics', metrics)
+ break
+ case (metrics.metricsContext.isIntervalEnd):
+ notifyClients('metrics', metrics)
+ break
+ case (metrics.metricsContext.isPauseStart):
+ notifyClients('metrics', metrics)
+ break
+ case (metrics.metricsContext.isPauseEnd):
+ notifyClients('metrics', metrics)
+ break
+ case (metrics.metricsContext.isDriveStart):
+ notifyClients('metrics', metrics)
+ break
+ case (metrics.metricsContext.isRecoveryStart):
+ notifyClients('metrics', metrics)
+ break
+ // no default
+ }
+ lastKnownMetrics = metrics
+ }
+
+ // initiated when a new heart rate value is received from heart rate sensor
+ async function presentHeartRate (value) {
+ heartRate = value.heartrate
+ heartRateBatteryLevel = value.batteryLevel
+ }
+
+ // Make sure that the GUI is updated with the latest metrics even when no fresh data arrives
+ function timeBasedPresenter () {
+ notifyClients('metrics', lastKnownMetrics)
+ }
+
+ /**
+ * @param {Metrics} metrics
+ */
+ function addHeartRateToMetrics (metrics) {
+ if (heartRate !== undefined) {
+ metrics.heartrate = heartRate
+ } else {
+ metrics.heartrate = undefined
+ }
+ if (heartRateBatteryLevel !== undefined) {
+ metrics.heartRateBatteryLevel = heartRateBatteryLevel
+ } else {
+ metrics.heartRateBatteryLevel = undefined
+ }
+ }
+
function notifyClient (client, type, data) {
const messageString = JSON.stringify({ type, data })
if (wss.clients.has(client)) {
@@ -60,18 +160,61 @@ function createWebServer () {
}
function notifyClients (type, data) {
+ clearTimeout(timer)
+ if (type === 'metrics') { addHeartRateToMetrics(data) }
const messageString = JSON.stringify({ type, data })
wss.clients.forEach(function each (client) {
if (client.readyState === WebSocket.OPEN) {
client.send(messageString)
}
})
+ timer = setTimeout(timeBasedPresenter, config.webUpdateInterval)
+ }
+
+ function getConfig () {
+ return {
+ blePeripheralMode: config.bluetoothMode,
+ antPeripheralMode: config.antPlusMode,
+ hrmPeripheralMode: config.heartRateMode,
+ uploadEnabled: ((config.userSettings.strava.allowUpload && !config.userSettings.strava.autoUpload) || (config.userSettings.intervals.allowUpload && !config.userSettings.intervals.autoUpload) || (config.userSettings.rowsAndAll.allowUpload && !config.userSettings.rowsAndAll.autoUpload)),
+ shutdownEnabled: !!config.shutdownCommand
+ }
+ }
+
+ function resetLastKnownMetrics () {
+ lastKnownMetrics = {
+ strokeState: 'WaitingForDrive',
+ sessionState: 'WaitingForStart',
+ totalMovingTime: 0,
+ pauseCountdownTime: 0,
+ totalNumberOfStrokes: 0,
+ totalLinearDistance: 0,
+ cyclePace: Infinity,
+ cyclePower: 0,
+ cycleStrokeRate: 0,
+ driveLength: 0,
+ driveDuration: 0,
+ driveHandleForceCurve: [],
+ driveDistance: 0,
+ recoveryDuration: 0,
+ dragFactor: undefined,
+ interval: {
+ type: 'justrow',
+ movingTime: {
+ sinceStart: 0,
+ toEnd: 0
+ },
+ distance: {
+ fromStart: 0,
+ toEnd: 0
+ }
+ }
+ }
}
return Object.assign(emitter, {
- notifyClient,
- notifyClients
+ presentRowingMetrics,
+ presentHeartRate,
+ handleCommand
})
}
-
-export { createWebServer }
diff --git a/app/ant/AntManager.js b/app/ant/AntManager.js
deleted file mode 100644
index 8a6bcec4d5..0000000000
--- a/app/ant/AntManager.js
+++ /dev/null
@@ -1,63 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This manager creates a module to listen to ANT+ devices.
- This currently can be used to get the heart rate from ANT+ heart rate sensors.
-
- Requires an ANT+ USB stick, the following models might work:
- - Garmin USB or USB2 ANT+ or an off-brand clone of it (ID 0x1008)
- - Garmin mini ANT+ (ID 0x1009)
-*/
-import log from 'loglevel'
-import Ant from 'ant-plus'
-import EventEmitter from 'node:events'
-
-function createAntManager () {
- const emitter = new EventEmitter()
- const antStick = new Ant.GarminStick2()
- const antStick3 = new Ant.GarminStick3()
- // it seems that we have to use two separate heart rate sensors to support both old and new
- // ant sticks, since the library requires them to be bound before open is called
- const heartrateSensor = new Ant.HeartRateSensor(antStick)
- const heartrateSensor3 = new Ant.HeartRateSensor(antStick3)
-
- heartrateSensor.on('hbData', (data) => {
- emitter.emit('heartrateMeasurement', { heartrate: data.ComputedHeartRate, batteryLevel: data.BatteryLevel })
- })
-
- heartrateSensor3.on('hbData', (data) => {
- emitter.emit('heartrateMeasurement', { heartrate: data.ComputedHeartRate, batteryLevel: data.BatteryLevel })
- })
-
- antStick.on('startup', () => {
- log.info('classic ANT+ stick found')
- heartrateSensor.attach(0, 0)
- })
-
- antStick3.on('startup', () => {
- log.info('mini ANT+ stick found')
- heartrateSensor3.attach(0, 0)
- })
-
- antStick.on('shutdown', () => {
- log.info('classic ANT+ stick lost')
- })
-
- antStick3.on('shutdown', () => {
- log.info('mini ANT+ stick lost')
- })
-
- if (!antStick.open()) {
- log.debug('classic ANT+ stick NOT found')
- }
-
- if (!antStick3.open()) {
- log.debug('mini ANT+ stick NOT found')
- }
-
- return Object.assign(emitter, {
- })
-}
-
-export { createAntManager }
diff --git a/app/ble/CentralManager.js b/app/ble/CentralManager.js
deleted file mode 100644
index c21c340447..0000000000
--- a/app/ble/CentralManager.js
+++ /dev/null
@@ -1,158 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This manager creates a Bluetooth Low Energy (BLE) Central that listens
- and subscribes to heart rate services
-*/
-import log from 'loglevel'
-import EventEmitter from 'node:events'
-import Noble from '@abandonware/noble/lib/noble.js'
-import NobleBindings from '@abandonware/noble/lib/hci-socket/bindings.js'
-
-// We are using peripherals and centrals at the same time (with bleno and noble).
-// The libraries do not play nice together in this scenario when they see peripherals
-// from each other via the HCI-Socket.
-// This is a quick patch for two handlers in noble that would otherwise throw warnings
-// when they see a peripheral or handle that is managed by bleno
-
-// START of noble patch
-Noble.prototype.onRssiUpdate = function (peripheralUuid, rssi) {
- const peripheral = this._peripherals[peripheralUuid]
-
- if (peripheral) {
- peripheral.rssi = rssi
- peripheral.emit('rssiUpdate', rssi)
- }
-}
-
-NobleBindings.prototype.onDisconnComplete = function (handle, reason) {
- const uuid = this._handles[handle]
-
- if (uuid) {
- this._aclStreams[handle].push(null, null)
- this._gatts[handle].removeAllListeners()
- this._signalings[handle].removeAllListeners()
-
- delete this._gatts[uuid]
- delete this._gatts[handle]
- delete this._signalings[uuid]
- delete this._signalings[handle]
- delete this._aclStreams[handle]
- delete this._handles[uuid]
- delete this._handles[handle]
-
- this.emit('disconnect', uuid)
- }
-}
-
-const noble = new Noble(new NobleBindings())
-// END of noble patch
-
-function createCentralManager () {
- const emitter = new EventEmitter()
- let batteryLevel
-
- noble.on('stateChange', (state) => {
- if (state === 'poweredOn') {
- // search for heart rate service
- noble.startScanning(['180d'], false)
- } else {
- noble.stopScanning()
- }
- })
-
- noble.on('discover', (peripheral) => {
- noble.stopScanning()
- connectHeartratePeripheral(peripheral)
- })
-
- function connectHeartratePeripheral (peripheral) {
- // connect to the heart rate sensor
- peripheral.connect((error) => {
- if (error) {
- log.error(error)
- return
- }
- log.info(`heart rate peripheral connected, name: '${peripheral.advertisement?.localName}', id: ${peripheral.id}`)
- subscribeToHeartrateMeasurement(peripheral)
- })
-
- peripheral.once('disconnect', () => {
- // todo: figure out if we have to dispose the peripheral somehow to prevent memory leaks
- log.info('heart rate peripheral disconnected, searching new one')
- batteryLevel = undefined
- noble.startScanning(['180d'], false)
- })
- }
-
- // see https://www.bluetooth.com/specifications/specs/heart-rate-service-1-0/
- function subscribeToHeartrateMeasurement (peripheral) {
- const heartrateMeasurementUUID = '2a37'
- const batteryLevelUUID = '2a19'
-
- peripheral.discoverSomeServicesAndCharacteristics([], [heartrateMeasurementUUID, batteryLevelUUID],
- (error, services, characteristics) => {
- if (error) {
- log.error(error)
- return
- }
-
- const heartrateMeasurementCharacteristic = characteristics.find(
- characteristic => characteristic.uuid === heartrateMeasurementUUID
- )
-
- const batteryLevelCharacteristic = characteristics.find(
- characteristic => characteristic.uuid === batteryLevelUUID
- )
-
- if (heartrateMeasurementCharacteristic !== undefined) {
- heartrateMeasurementCharacteristic.notify(true, (error) => {
- if (error) {
- log.error(error)
- return
- }
-
- heartrateMeasurementCharacteristic.on('data', (data, isNotification) => {
- const buffer = Buffer.from(data)
- const flags = buffer.readUInt8(0)
- // bits of the feature flag:
- // 0: Heart Rate Value Format
- // 1 + 2: Sensor Contact Status
- // 3: Energy Expended Status
- // 4: RR-Interval
- const heartrateUint16LE = flags & 0b1
-
- // from the specs:
- // While most human applications require support for only 255 bpm or less, special
- // applications (e.g. animals) may require support for higher bpm values.
- // If the Heart Rate Measurement Value is less than or equal to 255 bpm a UINT8 format
- // should be used for power savings.
- // If the Heart Rate Measurement Value exceeds 255 bpm a UINT16 format shall be used.
- const heartrate = heartrateUint16LE ? buffer.readUInt16LE(1) : buffer.readUInt8(1)
- emitter.emit('heartrateMeasurement', { heartrate, batteryLevel })
- })
- })
- }
-
- if (batteryLevelCharacteristic !== undefined) {
- batteryLevelCharacteristic.notify(true, (error) => {
- if (error) {
- log.error(error)
- return
- }
-
- batteryLevelCharacteristic.on('data', (data, isNotification) => {
- const buffer = Buffer.from(data)
- batteryLevel = buffer.readUInt8(0)
- })
- })
- }
- })
- }
-
- return Object.assign(emitter, {
- })
-}
-
-export { createCentralManager }
diff --git a/app/ble/CentralService.js b/app/ble/CentralService.js
deleted file mode 100644
index f8b28a51ea..0000000000
--- a/app/ble/CentralService.js
+++ /dev/null
@@ -1,18 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Starts the central manager in a forked thread since noble does not like
- to run in the same thread as bleno
-*/
-import { createCentralManager } from './CentralManager.js'
-import process from 'process'
-import config from '../tools/ConfigManager.js'
-import log from 'loglevel'
-
-log.setLevel(config.loglevel.default)
-const centralManager = createCentralManager()
-
-centralManager.on('heartrateMeasurement', (heartrateMeasurement) => {
- process.send(heartrateMeasurement)
-})
diff --git a/app/ble/CpsPeripheral.js b/app/ble/CpsPeripheral.js
deleted file mode 100644
index 5d24e47784..0000000000
--- a/app/ble/CpsPeripheral.js
+++ /dev/null
@@ -1,108 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are required for
- a Cycling Power Profile
-*/
-import bleno from '@abandonware/bleno'
-import config from '../tools/ConfigManager.js'
-import log from 'loglevel'
-import CyclingPowerService from './cps/CyclingPowerMeterService.js'
-import DeviceInformationService from './common/DeviceInformationService.js'
-import AdvertisingDataBuilder from './common/AdvertisingDataBuilder.js'
-
-function createCpsPeripheral () {
- const peripheralName = `${config.ftmsRowerPeripheralName} (CPS)`
- const cyclingPowerService = new CyclingPowerService((event) => log.debug('CPS Control Point', event))
-
- bleno.on('stateChange', (state) => {
- triggerAdvertising(state)
- })
-
- bleno.on('advertisingStart', (error) => {
- if (!error) {
- bleno.setServices(
- [
- cyclingPowerService,
- new DeviceInformationService()
- ],
- (error) => {
- if (error) log.error(error)
- })
- }
- })
-
- bleno.on('accept', (clientAddress) => {
- log.debug(`ble central connected: ${clientAddress}`)
- bleno.updateRssi()
- })
-
- bleno.on('disconnect', (clientAddress) => {
- log.debug(`ble central disconnected: ${clientAddress}`)
- })
-
- bleno.on('platform', (event) => {
- log.debug('platform', event)
- })
- bleno.on('addressChange', (event) => {
- log.debug('addressChange', event)
- })
- bleno.on('mtuChange', (event) => {
- log.debug('mtuChange', event)
- })
- bleno.on('advertisingStartError', (event) => {
- log.debug('advertisingStartError', event)
- })
- bleno.on('servicesSetError', (event) => {
- log.debug('servicesSetError', event)
- })
- bleno.on('rssiUpdate', (event) => {
- 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') {
- const cpsAppearance = 1156
- const advertisingData = new AdvertisingDataBuilder([cyclingPowerService.uuid], cpsAppearance, peripheralName)
-
- bleno.startAdvertisingWithEIRData(
- advertisingData.buildAppearanceData(),
- advertisingData.buildScanData(),
- (error) => {
- if (error) log.error(error)
- }
- )
- } else {
- bleno.stopAdvertising()
- }
- }
-
- function notifyData (type, data) {
- if (type === 'strokeFinished' || type === 'metricsUpdate') {
- cyclingPowerService.notifyData(data)
- }
- }
-
- // CPS does not have status characteristic
- function notifyStatus (status) {
- }
-
- return {
- triggerAdvertising,
- notifyData,
- notifyStatus,
- destroy
- }
-}
-
-export { createCpsPeripheral }
diff --git a/app/ble/CscPeripheral.js b/app/ble/CscPeripheral.js
deleted file mode 100644
index 3c8e99cc79..0000000000
--- a/app/ble/CscPeripheral.js
+++ /dev/null
@@ -1,108 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are required for
- a Cycling Speed and Cadence Profile
-*/
-import bleno from '@abandonware/bleno'
-import config from '../tools/ConfigManager.js'
-import log from 'loglevel'
-import DeviceInformationService from './common/DeviceInformationService.js'
-import CyclingSpeedCadenceService from './csc/CyclingSpeedCadenceService.js'
-import AdvertisingDataBuilder from './common/AdvertisingDataBuilder.js'
-
-function createCscPeripheral () {
- const peripheralName = `${config.ftmsRowerPeripheralName} (CSC)`
- const cyclingSpeedCadenceService = new CyclingSpeedCadenceService((event) => log.debug('CSC Control Point', event))
-
- bleno.on('stateChange', (state) => {
- triggerAdvertising(state)
- })
-
- bleno.on('advertisingStart', (error) => {
- if (!error) {
- bleno.setServices(
- [
- cyclingSpeedCadenceService,
- new DeviceInformationService()
- ],
- (error) => {
- if (error) log.error(error)
- })
- }
- })
-
- bleno.on('accept', (clientAddress) => {
- log.debug(`ble central connected: ${clientAddress}`)
- bleno.updateRssi()
- })
-
- bleno.on('disconnect', (clientAddress) => {
- log.debug(`ble central disconnected: ${clientAddress}`)
- })
-
- bleno.on('platform', (event) => {
- log.debug('platform', event)
- })
- bleno.on('addressChange', (event) => {
- log.debug('addressChange', event)
- })
- bleno.on('mtuChange', (event) => {
- log.debug('mtuChange', event)
- })
- bleno.on('advertisingStartError', (event) => {
- log.debug('advertisingStartError', event)
- })
- bleno.on('servicesSetError', (event) => {
- log.debug('servicesSetError', event)
- })
- bleno.on('rssiUpdate', (event) => {
- 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') {
- const cscAppearance = 1157
- const advertisingData = new AdvertisingDataBuilder([cyclingSpeedCadenceService.uuid], cscAppearance, peripheralName)
-
- bleno.startAdvertisingWithEIRData(
- advertisingData.buildAppearanceData(),
- advertisingData.buildScanData(),
- (error) => {
- if (error) log.error(error)
- }
- )
- } else {
- bleno.stopAdvertising()
- }
- }
-
- function notifyData (type, data) {
- if (type === 'strokeFinished' || type === 'metricsUpdate') {
- cyclingSpeedCadenceService.notifyData(data)
- }
- }
-
- // CSC does not have status characteristic
- function notifyStatus (status) {
- }
-
- return {
- triggerAdvertising,
- notifyData,
- notifyStatus,
- destroy
- }
-}
-
-export { createCscPeripheral }
diff --git a/app/ble/FtmsPeripheral.js b/app/ble/FtmsPeripheral.js
deleted file mode 100644
index 7a54392f9f..0000000000
--- a/app/ble/FtmsPeripheral.js
+++ /dev/null
@@ -1,124 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are required for
- a Fitness Machine Device
-
- Relevant parts from https://www.bluetooth.com/specifications/specs/fitness-machine-profile-1-0/
- The Fitness Machine shall instantiate one and only one Fitness Machine Service as Primary Service
- The User Data Service, if supported, shall be instantiated as a Primary Service.
- The Fitness Machine may instantiate the Device Information Service
- (Manufacturer Name String, Model Number String)
-*/
-import bleno from '@abandonware/bleno'
-import FitnessMachineService from './ftms/FitnessMachineService.js'
-import config from '../tools/ConfigManager.js'
-import log from 'loglevel'
-import DeviceInformationService from './common/DeviceInformationService.js'
-import AdvertisingDataBuilder from './common/AdvertisingDataBuilder.js'
-
-function createFtmsPeripheral (controlCallback, options) {
- const peripheralName = options?.simulateIndoorBike ? config.ftmsBikePeripheralName : config.ftmsRowerPeripheralName
- const fitnessMachineService = new FitnessMachineService(options, controlPointCallback)
- const deviceInformationService = new DeviceInformationService()
-
- bleno.on('stateChange', (state) => {
- triggerAdvertising(state)
- })
-
- bleno.on('advertisingStart', (error) => {
- if (!error) {
- bleno.setServices(
- [fitnessMachineService, deviceInformationService],
- (error) => {
- if (error) log.error(error)
- })
- }
- })
-
- bleno.on('accept', (clientAddress) => {
- log.debug(`ble central connected: ${clientAddress}`)
- bleno.updateRssi()
- })
-
- bleno.on('disconnect', (clientAddress) => {
- log.debug(`ble central disconnected: ${clientAddress}`)
- })
-
- bleno.on('platform', (event) => {
- log.debug('platform', event)
- })
- bleno.on('addressChange', (event) => {
- log.debug('addressChange', event)
- })
- bleno.on('mtuChange', (event) => {
- log.debug('mtuChange', event)
- })
- bleno.on('advertisingStartError', (event) => {
- log.debug('advertisingStartError', event)
- })
- bleno.on('servicesSetError', (event) => {
- log.debug('servicesSetError', event)
- })
- bleno.on('rssiUpdate', (event) => {
- log.debug('rssiUpdate', event)
- })
-
- function controlPointCallback (event) {
- const obj = {
- req: event,
- res: {}
- }
- if (controlCallback) controlCallback(obj)
- 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') {
- const advertisingBuilder = new AdvertisingDataBuilder([fitnessMachineService.uuid])
- advertisingBuilder.setShortName(peripheralName)
- advertisingBuilder.setLongName(peripheralName)
-
- bleno.startAdvertisingWithEIRData(
- advertisingBuilder.buildAppearanceData(),
- advertisingBuilder.buildScanData(),
- (error) => {
- if (error) log.error(error)
- }
- )
- } else {
- bleno.stopAdvertising()
- }
- }
-
- // present current rowing metrics to FTMS central
- function notifyData (type, data) {
- if (type === 'strokeFinished' || type === 'metricsUpdate') {
- fitnessMachineService.notifyData(data)
- }
- }
-
- // present current rowing status to FTMS central
- function notifyStatus (status) {
- fitnessMachineService.notifyStatus(status)
- }
-
- return {
- triggerAdvertising,
- notifyData,
- notifyStatus,
- destroy
- }
-}
-
-export { createFtmsPeripheral }
diff --git a/app/ble/PeripheralManager.js b/app/ble/PeripheralManager.js
deleted file mode 100644
index c75861fa24..0000000000
--- a/app/ble/PeripheralManager.js
+++ /dev/null
@@ -1,110 +0,0 @@
-'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 '../tools/ConfigManager.js'
-import { createFtmsPeripheral } from './FtmsPeripheral.js'
-import { createPm5Peripheral } from './Pm5Peripheral.js'
-import log from 'loglevel'
-import EventEmitter from 'node:events'
-import { createCpsPeripheral } from './CpsPeripheral.js'
-import { createCscPeripheral } from './CscPeripheral.js'
-
-const modes = ['FTMS', 'FTMSBIKE', 'PM5', 'CSC', 'CPS']
-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(type, metrics)
- }
-
- function notifyStatus (status) {
- peripheral.notifyStatus(status)
- }
-
- async function createPeripheral (newMode) {
- if (peripheral) {
- await peripheral.destroy()
- }
-
- switch (newMode) {
- case 'PM5':
- log.info('bluetooth profile: Concept2 PM5')
- peripheral = createPm5Peripheral(controlCallback)
- mode = 'PM5'
- break
-
- case 'FTMSBIKE':
- log.info('bluetooth profile: FTMS Indoor Bike')
- peripheral = createFtmsPeripheral(controlCallback, {
- simulateIndoorBike: true
- })
- mode = 'FTMSBIKE'
- break
- case 'CSC':
- log.info('bluetooth profile: Cycling Speed and Cadence')
- peripheral = createCscPeripheral()
- mode = 'CSC'
- break
- case 'CPS':
- log.info('bluetooth profile: Cycling Power Meter')
- peripheral = createCpsPeripheral()
- mode = 'CPS'
- break
-
- case 'FTMS':
- default:
- log.info('bluetooth profile: FTMS Rower')
- peripheral = createFtmsPeripheral(controlCallback, {
- simulateIndoorBike: false
- })
- mode = 'FTMS'
- break
- }
- peripheral.triggerAdvertising()
-
- emitter.emit('control', {
- req: {
- name: 'peripheralMode',
- peripheralMode: mode
- }
- })
- }
-
- function controlCallback (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
deleted file mode 100644
index 4e905198a6..0000000000
--- a/app/ble/Pm5Peripheral.js
+++ /dev/null
@@ -1,107 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are used by the
- Concept2 PM5 rowing machine.
-
- see: https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
-*/
-import bleno from '@abandonware/bleno'
-import { constants } from './pm5/Pm5Constants.js'
-import DeviceInformationService from './pm5/DeviceInformationService.js'
-import GapService from './pm5/GapService.js'
-import log from 'loglevel'
-import Pm5ControlService from './pm5/Pm5ControlService.js'
-import Pm5RowingService from './pm5/Pm5RowingService.js'
-
-function createPm5Peripheral (controlCallback, options) {
- const peripheralName = constants.name
- const deviceInformationService = new DeviceInformationService()
- const gapService = new GapService()
- const controlService = new Pm5ControlService()
- const rowingService = new Pm5RowingService()
-
- bleno.on('stateChange', (state) => {
- triggerAdvertising(state)
- })
-
- bleno.on('advertisingStart', (error) => {
- if (!error) {
- bleno.setServices(
- [gapService, deviceInformationService, controlService, rowingService],
- (error) => {
- if (error) log.error(error)
- })
- }
- })
-
- bleno.on('accept', (clientAddress) => {
- log.debug(`ble central connected: ${clientAddress}`)
- bleno.updateRssi()
- })
-
- bleno.on('disconnect', (clientAddress) => {
- log.debug(`ble central disconnected: ${clientAddress}`)
- })
-
- bleno.on('platform', (event) => {
- log.debug('platform', event)
- })
- bleno.on('addressChange', (event) => {
- log.debug('addressChange', event)
- })
- bleno.on('mtuChange', (event) => {
- log.debug('mtuChange', event)
- })
- bleno.on('advertisingStartError', (event) => {
- log.debug('advertisingStartError', event)
- })
- bleno.on('servicesSetError', (event) => {
- log.debug('servicesSetError', event)
- })
- bleno.on('rssiUpdate', (event) => {
- 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()
- }
- }
-
- // present current rowing metrics to C2-PM5 central
- function notifyData (type, data) {
- rowingService.notifyData(type, data)
- }
-
- // present current rowing status to C2-PM5 central
- function notifyStatus (status) {
- }
-
- return {
- triggerAdvertising,
- notifyData,
- notifyStatus,
- destroy
- }
-}
-
-export { createPm5Peripheral }
diff --git a/app/ble/common/AdvertisingDataBuilder.js b/app/ble/common/AdvertisingDataBuilder.js
deleted file mode 100644
index ba3dabf412..0000000000
--- a/app/ble/common/AdvertisingDataBuilder.js
+++ /dev/null
@@ -1,133 +0,0 @@
-'use strict'
-
-export default class AdvertisingDataBuilder {
- constructor (serviceUuids, appearance, longName, shortName) {
- this.shortName = shortName || longName || 'ORM'
- this.longName = longName || 'OpenRowingMonitor'
- this.serviceUuids = serviceUuids || []
- this.appearance = appearance
- }
-
- setLongName (name) {
- this.longName = name
- }
-
- setShortName (name) {
- this.shortName = name
- }
-
- addServiceUuid (serviceUuid) {
- this.serviceUuids.push(serviceUuid)
- }
-
- setAppearance (appearance) {
- this.appearance = appearance
- }
-
- buildScanData () {
- let scanDataLength = 0
- scanDataLength += 2 + this.longName.length
- const scanData = Buffer.alloc(scanDataLength)
-
- const nameBuffer = Buffer.from(this.longName)
-
- scanData.writeUInt8(1 + nameBuffer.length, 0)
- scanData.writeUInt8(0x08, 1)
- nameBuffer.copy(scanData, 2)
-
- return scanData
- }
-
- buildAppearanceData () {
- let advertisementDataLength = 3
-
- const serviceUuids16bit = []
- const serviceUuids128bit = []
- let i = 0
-
- if (this.serviceUuids.length) {
- for (i = 0; i < this.serviceUuids.length; i++) {
- const serviceUuid = Buffer.from(this.serviceUuids[i].match(/.{1,2}/g).reverse().join(''), 'hex')
-
- if (serviceUuid.length === 2) {
- serviceUuids16bit.push(serviceUuid)
- } else if (serviceUuid.length === 16) {
- serviceUuids128bit.push(serviceUuid)
- }
- }
- }
-
- if (serviceUuids16bit.length) {
- advertisementDataLength += 2 + 2 * serviceUuids16bit.length
- }
-
- if (serviceUuids128bit.length) {
- advertisementDataLength += 2 + 16 * serviceUuids128bit.length
- }
-
- if (this.appearance) {
- advertisementDataLength += 4
- }
-
- let name = this.shortName
-
- if (advertisementDataLength + 2 + name.length > 31) {
- const remainingDataLength = 31 - advertisementDataLength - 2
- name = name.substring(0, remainingDataLength)
- }
- advertisementDataLength += 2 + name.length
-
- const advertisementData = Buffer.alloc(advertisementDataLength)
-
- // flags
- advertisementData.writeUInt8(2, 0)
- advertisementData.writeUInt8(0x01, 1)
- advertisementData.writeUInt8(0x06, 2)
-
- let advertisementDataOffset = 3
-
- if (this.appearance) {
- advertisementData.writeUInt8(3, advertisementDataOffset)
- advertisementDataOffset++
- advertisementData.writeUInt8(0x19, advertisementDataOffset)
- advertisementDataOffset++
- advertisementData.writeUInt16LE(this.appearance, advertisementDataOffset)
- advertisementDataOffset += 2
- }
-
- advertisementData.writeUInt8(name.length + 1, advertisementDataOffset)
- advertisementDataOffset++
- advertisementData.writeUInt8(0x08, advertisementDataOffset)
- advertisementDataOffset++
- Buffer.from(name).copy(advertisementData, advertisementDataOffset)
- advertisementDataOffset += name.length
-
- if (serviceUuids16bit.length) {
- advertisementData.writeUInt8(1 + 2 * serviceUuids16bit.length, advertisementDataOffset)
- advertisementDataOffset++
-
- advertisementData.writeUInt8(0x03, advertisementDataOffset)
- advertisementDataOffset++
-
- for (i = 0; i < serviceUuids16bit.length; i++) {
- serviceUuids16bit[i].copy(advertisementData, advertisementDataOffset)
- advertisementDataOffset += serviceUuids16bit[i].length
- }
- }
-
- if (serviceUuids128bit.length) {
- advertisementData.writeUInt8(1 + 16 * serviceUuids128bit.length, advertisementDataOffset)
- advertisementDataOffset++
-
- advertisementData.writeUInt8(0x06, advertisementDataOffset)
- advertisementDataOffset++
-
- for (i = 0; i < serviceUuids128bit.length; i++) {
- serviceUuids128bit[i].copy(advertisementData, advertisementDataOffset)
- advertisementDataOffset += serviceUuids128bit[i].length
- }
- }
-
- return advertisementData
- }
-}
diff --git a/app/ble/common/AdvertisingDataBuilder.test.js b/app/ble/common/AdvertisingDataBuilder.test.js
deleted file mode 100644
index 8fbc991f75..0000000000
--- a/app/ble/common/AdvertisingDataBuilder.test.js
+++ /dev/null
@@ -1,117 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-*/
-import { test } from 'uvu'
-import * as assert from 'uvu/assert'
-import log from 'loglevel'
-import AdvertisingDataBuilder from './AdvertisingDataBuilder.js'
-log.setLevel(log.levels.SILENT)
-
-test('empty constructor should create default values', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder()
-
- // act
-
- // assert
- assert.type(advertisementDataBuilder.appearance, 'undefined')
- assert.equal(advertisementDataBuilder.longName, 'OpenRowingMonitor')
- assert.equal(advertisementDataBuilder.shortName, 'ORM', 'if longName is not defined short name should be ORM')
- assert.equal(advertisementDataBuilder.serviceUuids.length, 0)
-})
-
-test('should use long name as short name if latter is not set', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder([], undefined, 'testLongName')
-
- // act
-
- // assert
- assert.equal(advertisementDataBuilder.shortName, advertisementDataBuilder.longName)
-})
-
-test('should be able to set long name', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder()
- const name = 'longNameTest'
- // act
- advertisementDataBuilder.setLongName(name)
-
- // assert
- assert.equal(advertisementDataBuilder.longName, name)
-})
-
-test('should be able to set short name', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder()
-
- const name = 'shortNameTest'
- // act
- advertisementDataBuilder.setShortName(name)
-
- // assert
- assert.equal(advertisementDataBuilder.shortName, name)
-})
-
-test('should be able to set appearance field', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder()
-
- const appearance = 1157
- // act
- advertisementDataBuilder.setAppearance(appearance)
-
- // assert
- assert.equal(advertisementDataBuilder.appearance, appearance)
-})
-
-test('should be able to add service UUID', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder()
-
- // act
- advertisementDataBuilder.addServiceUuid('1800')
- advertisementDataBuilder.addServiceUuid('1801')
-
- // assert
- assert.equal(advertisementDataBuilder.serviceUuids.length, 2)
-})
-
-test('should add long name to scan data', () => {
- // arrange
- const name = 'testLongName'
- const advertisementDataBuilder = new AdvertisingDataBuilder(['1800'], undefined, name, 'short')
-
- // act
- const scanData = advertisementDataBuilder.buildScanData()
-
- // assert
- assert.equal(scanData.length, name.length + 2)
-})
-
-test('should produce correct byte array for advertising data', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder(['1816'], 1156, 'ORM')
-
- // act
- const advertisementData = advertisementDataBuilder.buildAppearanceData()
- // assert
- assert.equal([...advertisementData], [2, 1, 6, 3, 25, 132, 4, 4, 8, 79, 82, 77, 3, 3, 22, 24]
- )
-})
-
-test('should trim short name if advertising data is longer than 31 byte', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder(['1816'], 1156, 'OpenRowingMonitor CSC')
-
- // act
- const advertisementData = advertisementDataBuilder.buildAppearanceData()
-
- // assert
- assert.equal(advertisementData.length, 31)
- assert.equal([...advertisementData], [2, 1, 6, 3, 25, 132, 4, 19, 8, 79, 112, 101, 110, 82, 111, 119, 105, 110, 103, 77, 111, 110, 105, 116, 111, 114, 32, 3, 3, 22, 24])
- assert.match(advertisementData.toString(), /OpenRowingMonitor/)
-})
-
-test.run()
diff --git a/app/ble/common/DeviceInformationService.js b/app/ble/common/DeviceInformationService.js
deleted file mode 100644
index 100f5c4766..0000000000
--- a/app/ble/common/DeviceInformationService.js
+++ /dev/null
@@ -1,23 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- todo: Could provide some info on the device here, maybe OS, Node version etc...
-*/
-import bleno from '@abandonware/bleno'
-import StaticReadCharacteristic from './StaticReadCharacteristic.js'
-
-export default class DeviceInformationService extends bleno.PrimaryService {
- constructor () {
- super({
- // uuid of 'Device Information Service'
- uuid: '180a',
- characteristics: [
- new StaticReadCharacteristic('2A24', 'Model Number', 'ORM2'),
- new StaticReadCharacteristic('2A25', 'Serial Number', '1234'),
- new StaticReadCharacteristic('2A28', 'Software Revision', '2'),
- new StaticReadCharacteristic('2A29', 'Manufacturer Name', 'OpenRowingMonitor')
- ]
- })
- }
-}
diff --git a/app/ble/common/StaticReadCharacteristic.js b/app/ble/common/StaticReadCharacteristic.js
deleted file mode 100644
index ef2248fef6..0000000000
--- a/app/ble/common/StaticReadCharacteristic.js
+++ /dev/null
@@ -1,22 +0,0 @@
-'use strict'
-
-import bleno from '@abandonware/bleno'
-
-export default class StaticReadCharacteristic extends bleno.Characteristic {
- constructor (uuid, description, value) {
- super({
- uuid,
- properties: ['read'],
- value: Buffer.isBuffer(value) ? value : Buffer.from(value),
- descriptors: [
- new bleno.Descriptor({
- uuid: '2901',
- value: description
- })
- ]
- })
- this.uuid = uuid
- this.description = description
- this.value = Buffer.isBuffer(value) ? value : Buffer.from(value)
- }
-}
diff --git a/app/ble/cps/CpsControlPointCharacteristic.js b/app/ble/cps/CpsControlPointCharacteristic.js
deleted file mode 100644
index 6b1283d625..0000000000
--- a/app/ble/cps/CpsControlPointCharacteristic.js
+++ /dev/null
@@ -1,29 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- The connected Central can remotely control some parameters or our rowing monitor via this Control Point
-
- But for our use case proper implementation is not necessary (its mere existence with an empty handler suffice)
-*/
-import bleno from '@abandonware/bleno'
-
-export default class CyclingPowerControlPointCharacteristic extends bleno.Characteristic {
- constructor (controlPointCallback) {
- super({
- // Cycling Power Meter Control Point
- uuid: '2A66',
- value: null,
- properties: ['indicate', 'write']
- })
-
- this.controlled = false
- if (!controlPointCallback) { throw new Error('controlPointCallback required') }
- this.controlPointCallback = controlPointCallback
- }
-
- // Central sends a command to the Control Point
- // No need to handle any request to have this working
- onWriteRequest (data, offset, withoutResponse, callback) {
- }
-}
diff --git a/app/ble/cps/CpsMeasurementCharacteristic.js b/app/ble/cps/CpsMeasurementCharacteristic.js
deleted file mode 100644
index c87fcece50..0000000000
--- a/app/ble/cps/CpsMeasurementCharacteristic.js
+++ /dev/null
@@ -1,95 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-import BufferBuilder from '../BufferBuilder.js'
-
-export const cpsMeasurementFeaturesFlags = {
- pedalPowerBalancePresent: (0x01 << 0),
- pedalPowerBalanceReference: (0x01 << 1),
- accumulatedTorquePresent: (0x01 << 2),
- accumulatedTorqueSource: (0x01 << 3),
- accumulatedTorqueSourceWheel: (0x00 << 3),
- accumulatedTorqueSourceCrank: (0x01 << 3),
- wheelRevolutionDataPresent: (0x01 << 4),
- crankRevolutionDataPresent: (0x01 << 5),
- extremeForceMagnitudesPresent: (0x01 << 6),
- extremeTorqueMagnitudesPresent: (0x01 << 7),
- extremeAnglesPresent: (0x01 << 8),
- topDeadSpotAnglePresent: (0x01 << 9),
- bottomDeadSpotAnglePresent: (0x01 << 10),
- accumulatedEnergyPresent: (0x01 << 11),
- offsetCompensationIndicator: (0x01 << 12)
-}
-
-export default class CyclingPowerMeasurementCharacteristic extends bleno.Characteristic {
- constructor () {
- super({
- // Cycling Power Meter Measurement
- uuid: '2A63',
- value: null,
- properties: ['notify'],
- descriptors: [
- new bleno.Descriptor({
- uuid: '2901',
- value: 'Cycling Power Measurement'
- })
- ]
- })
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`CyclingPowerMeasurementCharacteristic - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- this._subscriberMaxValueSize = maxValueSize
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('CyclingPowerMeasurementCharacteristic - central unsubscribed')
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- // ignore events without the mandatory fields
- if (!('cyclePower' in data)) {
- log.error('can not deliver bike data without mandatory fields')
- return this.RESULT_SUCCESS
- }
-
- if (this._updateValueCallback) {
- const bufferBuilder = new BufferBuilder()
-
- // Features flag
- bufferBuilder.writeUInt16LE(cpsMeasurementFeaturesFlags.wheelRevolutionDataPresent | cpsMeasurementFeaturesFlags.crankRevolutionDataPresent)
-
- // Instantaneous Power
- bufferBuilder.writeUInt16LE(Math.round(data.cyclePower))
-
- // Wheel revolution count (basically the distance in cm)
- bufferBuilder.writeUInt32LE(Math.round(Math.round(data.totalLinearDistance * 100)))
-
- // Wheel revolution time (ushort with 2048 resolution, resetting in every 32sec)
- bufferBuilder.writeUInt16LE(Math.round(data.totalMovingTime * 2048) % Math.pow(2, 16))
-
- // Total stroke count
- bufferBuilder.writeUInt16LE(Math.round(data.totalNumberOfStrokes))
-
- // last stroke time time (ushort with 1024 resolution, resetting in every 64sec)
- bufferBuilder.writeUInt16LE(Math.round(data.driveLastStartTime * 1024) % Math.pow(2, 16))
-
- const buffer = bufferBuilder.getBuffer()
- if (buffer.length > this._subscriberMaxValueSize) {
- log.warn(`CyclingPowerMeasurementCharacteristic - notification of ${buffer.length} bytes is too large for the subscriber`)
- }
- this._updateValueCallback(bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
-}
diff --git a/app/ble/csc/CscControlPointCharacteristic.js b/app/ble/csc/CscControlPointCharacteristic.js
deleted file mode 100644
index 1f9a110b95..0000000000
--- a/app/ble/csc/CscControlPointCharacteristic.js
+++ /dev/null
@@ -1,29 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- The connected Central can remotely control some parameters or our rowing monitor via this Control Point
-
- But for our use case proper implementation is not necessary (its mere existence with an empty handler suffice)
-*/
-import bleno from '@abandonware/bleno'
-
-export default class CyclingSpeedCadenceControlPointCharacteristic extends bleno.Characteristic {
- constructor (controlPointCallback) {
- super({
- // Cycling Speed and Cadence Control Point
- uuid: '2A55',
- value: null,
- properties: ['indicate', 'write']
- })
-
- this.controlled = false
- if (!controlPointCallback) { throw new Error('controlPointCallback required') }
- this.controlPointCallback = controlPointCallback
- }
-
- // Central sends a command to the Control Point
- // No need to handle any request to have this working
- onWriteRequest (data, offset, withoutResponse, callback) {
- }
-}
diff --git a/app/ble/csc/CscMeasurementCharacteristic.js b/app/ble/csc/CscMeasurementCharacteristic.js
deleted file mode 100644
index 60461588c2..0000000000
--- a/app/ble/csc/CscMeasurementCharacteristic.js
+++ /dev/null
@@ -1,81 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-import BufferBuilder from '../BufferBuilder.js'
-
-export default class CyclingSpeedCadenceMeasurementCharacteristic extends bleno.Characteristic {
- constructor () {
- super({
- // Cycling Speed and Cadence Measurement
- uuid: '2A5B',
- value: null,
- properties: ['notify'],
- descriptors: [
- new bleno.Descriptor({
- uuid: '2901',
- value: 'Cycling Speed and Cadence Measurement'
- })
- ]
- })
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`CyclingSpeedCadenceMeasurementCharacteristic - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- this._subscriberMaxValueSize = maxValueSize
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('CyclingSpeedCadenceMeasurementCharacteristic - central unsubscribed')
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- // ignore events without the mandatory fields
- if (!('cyclePower' in data)) {
- log.error('can not deliver bike data without mandatory fields')
- return this.RESULT_SUCCESS
- }
-
- if (this._updateValueCallback) {
- const bufferBuilder = new BufferBuilder()
-
- // Features flag
- bufferBuilder.writeUInt8(cscFeaturesFlags.crankRevolutionDataSupported | cscFeaturesFlags.wheelRevolutionDataSupported)
-
- // Wheel revolution count (basically the distance in cm)
- bufferBuilder.writeUInt32LE(Math.round(Math.round(data.totalLinearDistance * 100)))
-
- // Wheel revolution time (ushort with 1024 resolution, resetting in every 64sec)
- bufferBuilder.writeUInt16LE(Math.round(data.totalMovingTime * 1024) % Math.pow(2, 16))
-
- // Total stroke count
- bufferBuilder.writeUInt16LE(Math.round(data.totalNumberOfStrokes))
-
- // last stroke time time (ushort with 1024 resolution, resetting in every 64sec)
- bufferBuilder.writeUInt16LE(Math.round(data.driveLastStartTime * 1024) % Math.pow(2, 16))
-
- const buffer = bufferBuilder.getBuffer()
- if (buffer.length > this._subscriberMaxValueSize) {
- log.warn(`CyclingSpeedCadenceMeasurementCharacteristic - notification of ${buffer.length} bytes is too large for the subscriber`)
- }
- this._updateValueCallback(bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
-}
-
-export const cscFeaturesFlags =
-{
- wheelRevolutionDataSupported: (0x01 << 0),
- crankRevolutionDataSupported: (0x01 << 1),
- multipleSensorLocationSupported: (0x01 << 2)
-}
diff --git a/app/ble/csc/CyclingSpeedCadenceService.js b/app/ble/csc/CyclingSpeedCadenceService.js
deleted file mode 100644
index 261b38505f..0000000000
--- a/app/ble/csc/CyclingSpeedCadenceService.js
+++ /dev/null
@@ -1,36 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-*/
-import bleno from '@abandonware/bleno'
-import BufferBuilder from '../BufferBuilder.js'
-import { SensorLocationAsBuffer } from '../common/SensorLocation.js'
-import StaticReadCharacteristic from '../common/StaticReadCharacteristic.js'
-import CyclingSpeedCadenceControlPointCharacteristic from './CscControlPointCharacteristic.js'
-import CyclingSpeedCadenceMeasurementCharacteristic, { cscFeaturesFlags } from './CscMeasurementCharacteristic.js'
-
-export default class CyclingSpeedCadenceService extends bleno.PrimaryService {
- constructor (controlPointCallback) {
- const cscFeatureBuffer = new BufferBuilder()
- cscFeatureBuffer.writeUInt16LE(featuresFlag)
-
- const measurementCharacteristic = new CyclingSpeedCadenceMeasurementCharacteristic()
- super({
- // Cycling Speed and Cadence
- uuid: '1816',
- characteristics: [
- new StaticReadCharacteristic('2A5C', 'Cycling Speed and Cadence Feature', cscFeatureBuffer.getBuffer()),
- measurementCharacteristic,
- new CyclingSpeedCadenceControlPointCharacteristic(controlPointCallback),
- new StaticReadCharacteristic('2A5D', 'Sensor Location', SensorLocationAsBuffer())
- ]
- })
- this.measurementCharacteristic = measurementCharacteristic
- }
-
- notifyData (event) {
- this.measurementCharacteristic.notify(event)
- }
-}
-
-const featuresFlag = cscFeaturesFlags.crankRevolutionDataSupported | cscFeaturesFlags.wheelRevolutionDataSupported
diff --git a/app/ble/ftms/FitnessMachineControlPointCharacteristic.js b/app/ble/ftms/FitnessMachineControlPointCharacteristic.js
deleted file mode 100644
index 7d96096f12..0000000000
--- a/app/ble/ftms/FitnessMachineControlPointCharacteristic.js
+++ /dev/null
@@ -1,147 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- The connected Central can remotly control some parameters or our rowing monitor via this Control Point
-
- So far tested on:
- - Fulgaz: uses setIndoorBikeSimulationParameters
- - Zwift: uses startOrResume and setIndoorBikeSimulationParameters
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-
-// see https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0 for details
-const ControlPointOpCode = {
- requestControl: 0x00,
- reset: 0x01,
- setTargetSpeed: 0x02,
- setTargetInclincation: 0x03,
- setTargetResistanceLevel: 0x04,
- setTargetPower: 0x05,
- setTargetHeartRate: 0x06,
- startOrResume: 0x07,
- stopOrPause: 0x08,
- setTargetedExpendedEnergy: 0x09,
- setTargetedNumberOfSteps: 0x0A,
- setTargetedNumberOfStrides: 0x0B,
- setTargetedDistance: 0x0C,
- setTargetedTrainingTime: 0x0D,
- setTargetedTimeInTwoHeartRateZones: 0x0E,
- setTargetedTimeInThreeHeartRateZones: 0x0F,
- setTargetedTimeInFiveHeartRateZones: 0x10,
- setIndoorBikeSimulationParameters: 0x11,
- setWheelCircumference: 0x12,
- spinDownControl: 0x13,
- setTargetedCadence: 0x14,
- responseCode: 0x80
-}
-
-const ResultCode = {
- reserved: 0x00,
- success: 0x01,
- opCodeNotSupported: 0x02,
- invalidParameter: 0x03,
- operationFailed: 0x04,
- controlNotPermitted: 0x05
-}
-
-export default class FitnessMachineControlPointCharacteristic extends bleno.Characteristic {
- constructor (controlPointCallback) {
- super({
- // Fitness Machine Control Point
- uuid: '2AD9',
- value: null,
- properties: ['write']
- })
-
- this.controlled = false
- if (!controlPointCallback) { throw new Error('controlPointCallback required') }
- this.controlPointCallback = controlPointCallback
- }
-
- // Central sends a command to the Control Point
- // todo: handle offset and withoutResponse properly
- onWriteRequest (data, offset, withoutResponse, callback) {
- const opCode = data.readUInt8(0)
- switch (opCode) {
- case ControlPointOpCode.requestControl:
- if (!this.controlled) {
- if (this.controlPointCallback({ name: 'requestControl' })) {
- log.debug('requestControl sucessful')
- this.controlled = true
- callback(this.buildResponse(opCode, ResultCode.success))
- } else {
- callback(this.buildResponse(opCode, ResultCode.operationFailed))
- }
- } else {
- callback(this.buildResponse(opCode, ResultCode.controlNotPermitted))
- }
- break
-
- case ControlPointOpCode.reset:
- this.handleSimpleCommand(ControlPointOpCode.reset, 'reset', callback)
- // as per spec the reset command shall also reset the control
- this.controlled = false
- break
-
- case ControlPointOpCode.startOrResume:
- this.handleSimpleCommand(ControlPointOpCode.startOrResume, 'startOrResume', callback)
- break
-
- case ControlPointOpCode.stopOrPause: {
- const controlParameter = data.readUInt8(1)
- if (controlParameter === 1) {
- this.handleSimpleCommand(ControlPointOpCode.stopOrPause, 'stop', callback)
- } else if (controlParameter === 2) {
- this.handleSimpleCommand(ControlPointOpCode.stopOrPause, 'pause', callback)
- } else {
- log.error(`stopOrPause with invalid controlParameter: ${controlParameter}`)
- }
- break
- }
-
- // todo: Most tested bike apps use these to simulate a bike ride. Not sure how we can use these in our rower
- // since there is no adjustable resistance on the rowing machine
- case ControlPointOpCode.setIndoorBikeSimulationParameters: {
- const windspeed = data.readInt16LE(1) * 0.001
- const grade = data.readInt16LE(3) * 0.01
- const crr = data.readUInt8(5) * 0.0001
- const cw = data.readUInt8(6) * 0.01
- if (this.controlPointCallback({ name: 'setIndoorBikeSimulationParameters', value: { windspeed, grade, crr, cw } })) {
- callback(this.buildResponse(opCode, ResultCode.success))
- } else {
- callback(this.buildResponse(opCode, ResultCode.operationFailed))
- }
- break
- }
-
- default:
- log.info(`opCode ${opCode} is not supported`)
- callback(this.buildResponse(opCode, ResultCode.opCodeNotSupported))
- }
- }
-
- handleSimpleCommand (opCode, opName, callback) {
- if (this.controlled) {
- if (this.controlPointCallback({ name: opName })) {
- const response = this.buildResponse(opCode, ResultCode.success)
- callback(response)
- } else {
- callback(this.buildResponse(opCode, ResultCode.operationFailed))
- }
- } else {
- log.info(`initating command '${opName}' requires 'requestControl'`)
- callback(this.buildResponse(opCode, ResultCode.controlNotPermitted))
- }
- }
-
- // build the response message as defined by the spec
- buildResponse (opCode, resultCode) {
- const buffer = Buffer.alloc(3)
- buffer.writeUInt8(0x80, 0)
- buffer.writeUInt8(opCode, 1)
- buffer.writeUInt8(resultCode, 2)
- return buffer
- }
-}
diff --git a/app/ble/ftms/FitnessMachineService.js b/app/ble/ftms/FitnessMachineService.js
deleted file mode 100644
index d4703742b4..0000000000
--- a/app/ble/ftms/FitnessMachineService.js
+++ /dev/null
@@ -1,54 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implements the Fitness Machine Service (FTMS) according to specs.
- Either presents a FTMS Rower (for rower applications that can use parameters such as Stroke Rate) or
- simulates a FTMS Indoor Bike (for usage with bike training apps)
-
- Relevant parts from https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0
- For Discovery we should implement:
- - Fitness Machine Feature Characteristic
- - Rower Data Characteristic
- - Training Status Characteristic (not yet implemented) todo: Maybe implement a simple version of it to see which
- applications make use of it. Might become interesting, if we implement training management
- - Fitness Machine Status Characteristic
- - Fitness Machine Control Point Characteristic
-*/
-import bleno from '@abandonware/bleno'
-
-import RowerDataCharacteristic from './RowerDataCharacteristic.js'
-import RowerFeatureCharacteristic from './RowerFeatureCharacteristic.js'
-import IndoorBikeDataCharacteristic from './IndoorBikeDataCharacteristic.js'
-import IndoorBikeFeatureCharacteristic from './IndoorBikeFeatureCharacteristic.js'
-import FitnessMachineControlPointCharacteristic from './FitnessMachineControlPointCharacteristic.js'
-import FitnessMachineStatusCharacteristic from './FitnessMachineStatusCharacteristic.js'
-
-export default class FitnessMachineService extends bleno.PrimaryService {
- constructor (options, controlPointCallback) {
- const simulateIndoorBike = options?.simulateIndoorBike === true
- const dataCharacteristic = simulateIndoorBike ? new IndoorBikeDataCharacteristic() : new RowerDataCharacteristic()
- const featureCharacteristic = simulateIndoorBike ? new IndoorBikeFeatureCharacteristic() : new RowerFeatureCharacteristic()
- const statusCharacteristic = new FitnessMachineStatusCharacteristic()
- super({
- // Fitness Machine
- uuid: '1826',
- characteristics: [
- featureCharacteristic,
- dataCharacteristic,
- new FitnessMachineControlPointCharacteristic(controlPointCallback),
- statusCharacteristic
- ]
- })
- this.dataCharacteristic = dataCharacteristic
- this.statusCharacteristic = statusCharacteristic
- }
-
- notifyData (event) {
- this.dataCharacteristic.notify(event)
- }
-
- notifyStatus (event) {
- this.statusCharacteristic.notify(event)
- }
-}
diff --git a/app/ble/ftms/IndoorBikeDataCharacteristic.js b/app/ble/ftms/IndoorBikeDataCharacteristic.js
deleted file mode 100644
index 9a1b71c017..0000000000
--- a/app/ble/ftms/IndoorBikeDataCharacteristic.js
+++ /dev/null
@@ -1,100 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This implements the Indoor Bike Data Characteristic as defined by the Bluetooth SIG
- Currently hardly any applications exist that support these FTMS Characteristic for Rowing.
- So we use this to simulate an FTMS Indoor Bike characteristic.
- Of course we can not deliver rowing specific parameters like this (such as stroke rate), but
- this allows us to use the open rowing monitor with bike training platforms such as
- Zwift, Sufferfest, RGT Cycling, Kinomap, Bkool, Rouvy and more...
- So far tested on:
- - Kinomap.com: uses Power and Speed
- - Fulgaz: uses Power and Speed
- - Zwift: uses Power
- - RGT Cycling: connects Power but then disconnects again (seems something is missing here)
-
- From specs:
- The Server should notify this characteristic at a regular interval, typically once per second
- while in a connection and the interval is not configurable by the Client
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-import BufferBuilder from '../BufferBuilder.js'
-
-export default class IndoorBikeDataCharacteristic extends bleno.Characteristic {
- constructor () {
- super({
- // Indoor Bike Data
- uuid: '2AD2',
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`IndoorBikeDataCharacteristic - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- this._subscriberMaxValueSize = maxValueSize
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('IndoorBikeDataCharacteristic - central unsubscribed')
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- // ignore events without the mandatory fields
- if (!('cycleLinearVelocity' in data)) {
- log.error('can not deliver bike data without mandatory fields')
- return this.RESULT_SUCCESS
- }
-
- if (this._updateValueCallback) {
- const bufferBuilder = new BufferBuilder()
- // Field flags as defined in the Bluetooth Documentation
- // Instantaneous speed (default), Instantaneous Cadence (2), Total Distance (4),
- // Instantaneous Power (6), Total / Expended Energy (8), Heart Rate (9), Elapsed Time (11)
- // 01010100
- bufferBuilder.writeUInt8(0x54)
- // 00001011
- bufferBuilder.writeUInt8(0x0B)
-
- // see https://www.bluetooth.com/specifications/specs/gatt-specification-supplement-3/
- // for some of the data types
- // Instantaneous Speed in km/h
- bufferBuilder.writeUInt16LE(data.cycleLinearVelocity * 3.6 * 100)
- // Instantaneous Cadence in rotations per minute (we use this to communicate the strokes per minute)
- bufferBuilder.writeUInt16LE(Math.round(data.cycleStrokeRate * 2))
- // Total Distance in meters
- bufferBuilder.writeUInt24LE(Math.round(data.totalLinearDistance))
- // Instantaneous Power in watts
- bufferBuilder.writeUInt16LE(Math.round(data.cyclePower))
- // Energy
- // Total energy in kcal
- bufferBuilder.writeUInt16LE(Math.round(data.totalCalories))
- // Energy per hour
- // The Energy per Hour field represents the average expended energy of a user during a
- // period of one hour.
- bufferBuilder.writeUInt16LE(Math.round(data.totalCaloriesPerHour))
- // Energy per minute
- bufferBuilder.writeUInt8(Math.round(data.totalCaloriesPerMinute))
- // Heart Rate: Beats per minute with a resolution of 1
- bufferBuilder.writeUInt8(Math.round(data.heartrate))
- // Elapsed Time: Seconds with a resolution of 1
- bufferBuilder.writeUInt16LE(Math.round(data.totalMovingTime))
-
- const buffer = bufferBuilder.getBuffer()
- if (buffer.length > this._subscriberMaxValueSize) {
- log.warn(`IndoorBikeDataCharacteristic - notification of ${buffer.length} bytes is too large for the subscriber`)
- }
- this._updateValueCallback(bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
-}
diff --git a/app/ble/ftms/IndoorBikeFeatureCharacteristic.js b/app/ble/ftms/IndoorBikeFeatureCharacteristic.js
deleted file mode 100644
index 4c01098157..0000000000
--- a/app/ble/ftms/IndoorBikeFeatureCharacteristic.js
+++ /dev/null
@@ -1,36 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This implements the Indoor Bike Feature Characteristic as defined by the specification.
- Used to inform the Central about the features that the Open Rowing Monitor supports.
- Make sure that The Fitness Machine Features and Target Setting Features that are announced here
- are supported in IndoorBikeDataCharacteristic and FitnessMachineControlPointCharacteristic.
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-
-export default class IndoorBikeDataCharacteristic extends bleno.Characteristic {
- constructor (uuid, description, value) {
- super({
- // Fitness Machine Feature
- uuid: '2ACC',
- properties: ['read'],
- value: null
- })
- }
-
- onReadRequest (offset, callback) {
- // see https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0 for details
- // Fitness Machine Features for the IndoorBikeDataCharacteristic
- // Cadence Supported (1), Total Distance Supported (2), Expended Energy Supported (9),
- // Heart Rate Measurement Supported (10), Elapsed Time Supported (12), Power Measurement Supported (14)
- // 00000110 01010110
- // Target Setting Features for the IndoorBikeDataCharacteristic
- // none
- // 0000000 0000000
- const features = [0x06, 0x56, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
- log.debug('Features of Indoor Bike requested')
- callback(this.RESULT_SUCCESS, features.slice(offset, features.length))
- }
-}
diff --git a/app/ble/ftms/RowerDataCharacteristic.js b/app/ble/ftms/RowerDataCharacteristic.js
deleted file mode 100644
index a3a376266e..0000000000
--- a/app/ble/ftms/RowerDataCharacteristic.js
+++ /dev/null
@@ -1,100 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This implements the Rower Data Characteristic as defined by the Bluetooth SIG
- Currently not many applications exist that support thes FTMS Characteristic for Rowing so its hard
- to verify this. So far tested on:
- - Kinomap.com: uses Power, Split Time and Strokes per Minutes
-
- From the specs:
- The Server should notify this characteristic at a regular interval, typically once per second
- while in a connection and the interval is not configurable by the Client
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-import BufferBuilder from '../BufferBuilder.js'
-
-export default class RowerDataCharacteristic extends bleno.Characteristic {
- constructor () {
- super({
- // Rower Data
- uuid: '2AD1',
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`RowerDataCharacteristic - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- this._subscriberMaxValueSize = maxValueSize
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('RowerDataCharacteristic - central unsubscribed')
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- // ignore events without the mandatory fields
- if (!('cycleStrokeRate' in data && 'totalNumberOfStrokes' in data)) {
- return this.RESULT_SUCCESS
- }
-
- if (this._updateValueCallback) {
- const bufferBuilder = new BufferBuilder()
- // Field flags as defined in the Bluetooth Documentation
- // Stroke Rate (default), Stroke Count (default), Total Distance (2), Instantaneous Pace (3),
- // Instantaneous Power (5), Total / Expended Energy (8), Heart Rate (9), Elapsed Time (11)
- // todo: might add: Average Stroke Rate (1), Average Pace (4), Average Power (6)
- // Remaining Time (12)
- // 00101100
- bufferBuilder.writeUInt8(0x2c)
- // 00001011
- bufferBuilder.writeUInt8(0x0B)
-
- // see https://www.bluetooth.com/specifications/specs/gatt-specification-supplement-3/
- // for some of the data types
- // Stroke Rate in stroke/minute, value is multiplied by 2 to have a .5 precision
- bufferBuilder.writeUInt8(Math.round(data.cycleStrokeRate * 2))
- // Stroke Count
- bufferBuilder.writeUInt16LE(Math.round(data.totalNumberOfStrokes))
- // Total Distance in meters
- bufferBuilder.writeUInt24LE(Math.round(data.totalLinearDistance))
- // Instantaneous Pace in seconds/500m
- // if split is infinite (i.e. while pausing), should use the highest possible number (0xFFFF)
- // todo: eventhough mathematically correct, setting 0xFFFF (65535s) causes some ugly spikes
- // in some applications which could shift the axis (i.e. workout diagrams in MyHomeFit)
- // so instead for now we use 0 here
- bufferBuilder.writeUInt16LE(data.cyclePace !== Infinity && data.cyclePace < 65535 ? Math.round(data.cyclePace) : 0xFFFF)
- // Instantaneous Power in watts
- bufferBuilder.writeUInt16LE(Math.round(data.cyclePower))
- // Energy in kcal
- // Total energy in kcal
- bufferBuilder.writeUInt16LE(Math.round(data.totalCalories))
- // Energy per hour
- // The Energy per Hour field represents the average expended energy of a user during a
- // period of one hour.
- bufferBuilder.writeUInt16LE(Math.round(data.totalCaloriesPerHour))
- // Energy per minute
- bufferBuilder.writeUInt8(Math.round(data.totalCaloriesPerMinute))
- // Heart Rate: Beats per minute with a resolution of 1
- bufferBuilder.writeUInt8(Math.round(data.heartrate))
- // Elapsed Time: Seconds with a resolution of 1
- bufferBuilder.writeUInt16LE(Math.round(data.totalMovingTime))
-
- const buffer = bufferBuilder.getBuffer()
- if (buffer.length > this._subscriberMaxValueSize) {
- log.warn(`RowerDataCharacteristic - notification of ${buffer.length} bytes is too large for the subscriber`)
- }
- this._updateValueCallback(bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
-}
diff --git a/app/ble/ftms/RowerFeatureCharacteristic.js b/app/ble/ftms/RowerFeatureCharacteristic.js
deleted file mode 100644
index 04e929e85b..0000000000
--- a/app/ble/ftms/RowerFeatureCharacteristic.js
+++ /dev/null
@@ -1,37 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This implements the Rower Feature Characteristic as defined by the specification.
- Used to inform the Central about the features that the Open Rowing Monitor supports.
- Make sure that The Fitness Machine Features and Target Setting Features that are announced here
- are supported in RowerDataCharacteristic and FitnessMachineControlPointCharacteristic.
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-
-export default class RowerFeatureCharacteristic extends bleno.Characteristic {
- constructor () {
- super({
- // Fitness Machine Feature
- uuid: '2ACC',
- properties: ['read'],
- value: null
- })
- }
-
- onReadRequest (offset, callback) {
- // see https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0 for details
- // Fitness Machine Features for the RowerDataCharacteristic
- // Total Distance Supported (2), Pace Supported (5), Expended Energy Supported (9),
- // Heart Rate Measurement Supported (10), Elapsed Time Supported (bit 12),
- // Power Measurement Supported (14)
- // 00100100 01010110
- // Target Setting Features for the RowerDataCharacteristic
- // none
- // 0000000 0000000
- const features = [0x24, 0x56, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
- log.debug('Features of Rower requested')
- callback(this.RESULT_SUCCESS, features.slice(offset, features.length))
- };
-}
diff --git a/app/ble/pm5/DeviceInformationService.js b/app/ble/pm5/DeviceInformationService.js
deleted file mode 100644
index 9741d54269..0000000000
--- a/app/ble/pm5/DeviceInformationService.js
+++ /dev/null
@@ -1,32 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Provides the required Device Information of the PM5
-*/
-import bleno from '@abandonware/bleno'
-import { constants, getFullUUID } from './Pm5Constants.js'
-import ValueReadCharacteristic from './characteristic/ValueReadCharacteristic.js'
-
-export default class DeviceInformationService extends bleno.PrimaryService {
- constructor () {
- super({
- // InformationenService uuid as defined by the PM5 specification
- uuid: getFullUUID('0010'),
- characteristics: [
- // C2 module number string
- new ValueReadCharacteristic(getFullUUID('0011'), constants.model, 'model'),
- // C2 serial number string
- new ValueReadCharacteristic(getFullUUID('0012'), constants.serial, 'serial'),
- // C2 hardware revision string
- new ValueReadCharacteristic(getFullUUID('0013'), constants.hardwareRevision, 'hardwareRevision'),
- // C2 firmware revision string
- new ValueReadCharacteristic(getFullUUID('0014'), constants.firmwareRevision, 'firmwareRevision'),
- // C2 manufacturer name string
- new ValueReadCharacteristic(getFullUUID('0015'), constants.manufacturer, 'manufacturer'),
- // Erg Machine Type
- new ValueReadCharacteristic(getFullUUID('0016'), constants.ergMachineType, 'ergMachineType')
- ]
- })
- }
-}
diff --git a/app/ble/pm5/GapService.js b/app/ble/pm5/GapService.js
deleted file mode 100644
index f90c42c8b0..0000000000
--- a/app/ble/pm5/GapService.js
+++ /dev/null
@@ -1,31 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Provides all required GAP Characteristics of the PM5
- todo: not sure if this is correct, the normal GAP service has 0x1800
-*/
-import bleno from '@abandonware/bleno'
-import { constants, getFullUUID } from './Pm5Constants.js'
-import ValueReadCharacteristic from './characteristic/ValueReadCharacteristic.js'
-
-export default class GapService extends bleno.PrimaryService {
- constructor () {
- super({
- // GAP Service UUID of PM5
- uuid: getFullUUID('0000'),
- characteristics: [
- // GAP device name
- new ValueReadCharacteristic('2A00', constants.name),
- // GAP appearance
- new ValueReadCharacteristic('2A01', [0x00, 0x00]),
- // GAP peripheral privacy
- new ValueReadCharacteristic('2A02', [0x00]),
- // GAP reconnect address
- new ValueReadCharacteristic('2A03', '00:00:00:00:00:00'),
- // Peripheral preferred connection parameters
- new ValueReadCharacteristic('2A04', [0x18, 0x00, 0x18, 0x00, 0x00, 0x00, 0xE8, 0x03])
- ]
- })
- }
-}
diff --git a/app/ble/pm5/Pm5Constants.js b/app/ble/pm5/Pm5Constants.js
deleted file mode 100644
index e4c352d4f5..0000000000
--- a/app/ble/pm5/Pm5Constants.js
+++ /dev/null
@@ -1,27 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Some PM5 specific constants
-*/
-const constants = {
- serial: '123456789',
- model: 'PM5',
- name: 'PM5 123456789 Row',
- hardwareRevision: '907',
- // See https://www.concept2.com/service/monitors/pm5/firmware for available versions
- // please note: hardware versions exclude a software version, and thus might confuse the client
- firmwareRevision: '210',
- manufacturer: 'Concept2',
- ergMachineType: [0x05]
-}
-
-// PM5 uses 128bit UUIDs that are always prefixed and suffixed the same way
-function getFullUUID (uuid) {
- return `ce06${uuid}43e511e4916c0800200c9a66`
-}
-
-export {
- getFullUUID,
- constants
-}
diff --git a/app/ble/pm5/Pm5ControlService.js b/app/ble/pm5/Pm5ControlService.js
deleted file mode 100644
index 83e5a28e82..0000000000
--- a/app/ble/pm5/Pm5ControlService.js
+++ /dev/null
@@ -1,23 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- The Control service can be used to send control commands to the PM5 device
- todo: not yet wired
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from './Pm5Constants.js'
-import ControlTransmit from './characteristic/ControlTransmit.js'
-import ControlReceive from './characteristic/ControlReceive.js'
-
-export default class PM5ControlService extends bleno.PrimaryService {
- constructor () {
- super({
- uuid: getFullUUID('0020'),
- characteristics: [
- new ControlReceive(),
- new ControlTransmit()
- ]
- })
- }
-}
diff --git a/app/ble/pm5/Pm5RowingService.js b/app/ble/pm5/Pm5RowingService.js
deleted file mode 100644
index 8e00cf5f1d..0000000000
--- a/app/ble/pm5/Pm5RowingService.js
+++ /dev/null
@@ -1,90 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This seems to be the central service to get information about the workout
- This Primary Service provides a lot of stuff that we most certainly do not need to simulate a
- simple PM5 service.
-
- todo: figure out to which services some common applications subscribe and then just implement those
- // fluid simulation uses GeneralStatus STROKESTATE_DRIVING
- // cloud simulation uses MULTIPLEXER, AdditionalStatus -> currentPace
- // EXR: subscribes to: 'general status', 'additional status', 'additional status 2', 'additional stroke data'
- Might implement:
- * GeneralStatus
- * AdditionalStatus
- * AdditionalStatus2
- * (StrokeData)
- * AdditionalStrokeData
- * and of course the multiplexer
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from './Pm5Constants.js'
-import ValueReadCharacteristic from './characteristic/ValueReadCharacteristic.js'
-import MultiplexedCharacteristic from './characteristic/MultiplexedCharacteristic.js'
-import GeneralStatus from './characteristic/GeneralStatus.js'
-import AdditionalStatus from './characteristic/AdditionalStatus.js'
-import AdditionalStatus2 from './characteristic/AdditionalStatus2.js'
-import AdditionalStrokeData from './characteristic/AdditionalStrokeData.js'
-import StrokeData from './characteristic/StrokeData.js'
-
-export default class PM5RowingService extends bleno.PrimaryService {
- constructor () {
- const multiplexedCharacteristic = new MultiplexedCharacteristic()
- const generalStatus = new GeneralStatus(multiplexedCharacteristic)
- const additionalStatus = new AdditionalStatus(multiplexedCharacteristic)
- const additionalStatus2 = new AdditionalStatus2(multiplexedCharacteristic)
- const strokeData = new StrokeData(multiplexedCharacteristic)
- const additionalStrokeData = new AdditionalStrokeData(multiplexedCharacteristic)
- super({
- uuid: getFullUUID('0030'),
- characteristics: [
- // C2 rowing general status
- generalStatus,
- // C2 rowing additional status
- additionalStatus,
- // C2 rowing additional status 2
- additionalStatus2,
- // C2 rowing general status and additional status samplerate
- new ValueReadCharacteristic(getFullUUID('0034'), 'samplerate', 'samplerate'),
- // C2 rowing stroke data
- strokeData,
- // C2 rowing additional stroke data
- additionalStrokeData,
- // C2 rowing split/interval data
- new ValueReadCharacteristic(getFullUUID('0037'), 'split data', 'split data'),
- // C2 rowing additional split/interval data
- new ValueReadCharacteristic(getFullUUID('0038'), 'additional split data', 'additional split data'),
- // C2 rowing end of workout summary data
- new ValueReadCharacteristic(getFullUUID('0039'), 'workout summary', 'workout summary'),
- // C2 rowing end of workout additional summary data
- new ValueReadCharacteristic(getFullUUID('003A'), 'additional workout summary', 'additional workout summary'),
- // C2 rowing heart rate belt information
- new ValueReadCharacteristic(getFullUUID('003B'), 'heart rate belt information', 'heart rate belt information'),
- // C2 force curve data
- new ValueReadCharacteristic(getFullUUID('003D'), 'force curve data', 'force curve data'),
- // C2 multiplexed information
- multiplexedCharacteristic
- ]
- })
- this.generalStatus = generalStatus
- this.additionalStatus = additionalStatus
- this.additionalStatus2 = additionalStatus2
- this.strokeData = strokeData
- this.additionalStrokeData = additionalStrokeData
- this.multiplexedCharacteristic = multiplexedCharacteristic
- }
-
- notifyData (type, data) {
- if (type === 'strokeFinished' || type === 'metricsUpdate') {
- this.generalStatus.notify(data)
- this.additionalStatus.notify(data)
- this.additionalStatus2.notify(data)
- this.strokeData.notify(data)
- this.additionalStrokeData.notify(data)
- } else if (type === 'strokeStateChanged') {
- // the stroke state is delivered via the GeneralStatus Characteristic, so we only need to notify that one
- this.generalStatus.notify(data)
- }
- }
-}
diff --git a/app/ble/pm5/characteristic/AdditionalStatus.js b/app/ble/pm5/characteristic/AdditionalStatus.js
deleted file mode 100644
index 2173275976..0000000000
--- a/app/ble/pm5/characteristic/AdditionalStatus.js
+++ /dev/null
@@ -1,78 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implementation of the AdditionalStatus as defined in:
- https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-import BufferBuilder from '../../BufferBuilder.js'
-
-export default class AdditionalStatus extends bleno.Characteristic {
- constructor (multiplexedCharacteristic) {
- super({
- // id for AdditionalStatus as defined in the spec
- uuid: getFullUUID('0032'),
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- this._multiplexedCharacteristic = multiplexedCharacteristic
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`AdditionalStatus - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('AdditionalStatus - central unsubscribed')
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
- const bufferBuilder = new BufferBuilder()
- // elapsedTime: UInt24LE in 0.01 sec
- bufferBuilder.writeUInt24LE(Math.round(data.totalMovingTime * 100))
- // speed: UInt16LE in 0.001 m/sec
- bufferBuilder.writeUInt16LE(Math.round(data.cycleLinearVelocity * 1000))
- // strokeRate: UInt8 in strokes/min
- bufferBuilder.writeUInt8(Math.round(data.cycleStrokeRate))
- // heartrate: UInt8 in bpm, 255 if invalid
- bufferBuilder.writeUInt8(Math.round(data.heartrate))
- // currentPace: UInt16LE in 0.01 sec/500m
- // if split is infinite (i.e. while pausing), use the highest possible number
- bufferBuilder.writeUInt16LE(data.cyclePace !== Infinity && data.cyclePace > 0 && data.cyclePace < 655.34 ? data.cyclePace * 100 : 0xFFFF)
- // averagePace: UInt16LE in 0.01 sec/500m
- let averagePace = 0
- if (data.totalLinearDistance && data.totalLinearDistance !== 0) {
- averagePace = (data.totalMovingTime / data.totalLinearDistance) * 500
- }
- bufferBuilder.writeUInt16LE(Math.round(Math.min(averagePace * 100, 65535)))
- // restDistance: UInt16LE
- bufferBuilder.writeUInt16LE(0)
- // restTime: UInt24LE in 0.01 sec
- bufferBuilder.writeUInt24LE(0 * 100)
- if (!this._updateValueCallback) {
- // the multiplexer uses a slightly different format for the AdditionalStatus
- // it adds averagePower before the ergMachineType
- // averagePower: UInt16LE in watts
- bufferBuilder.writeUInt16LE(Math.round(data.cyclePower))
- }
- // ergMachineType: 0 TYPE_STATIC_D
- bufferBuilder.writeUInt8(0)
-
- if (this._updateValueCallback) {
- this._updateValueCallback(bufferBuilder.getBuffer())
- } else {
- this._multiplexedCharacteristic.notify(0x32, bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
- }
-}
diff --git a/app/ble/pm5/characteristic/AdditionalStatus2.js b/app/ble/pm5/characteristic/AdditionalStatus2.js
deleted file mode 100644
index 66ccc66aec..0000000000
--- a/app/ble/pm5/characteristic/AdditionalStatus2.js
+++ /dev/null
@@ -1,71 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implementation of the AdditionalStatus2 as defined in:
- https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-import BufferBuilder from '../../BufferBuilder.js'
-
-export default class AdditionalStatus2 extends bleno.Characteristic {
- constructor (multiplexedCharacteristic) {
- super({
- // id for AdditionalStatus2 as defined in the spec
- uuid: getFullUUID('0033'),
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- this._multiplexedCharacteristic = multiplexedCharacteristic
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`AdditionalStatus2 - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('AdditionalStatus2 - central unsubscribed')
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
- const bufferBuilder = new BufferBuilder()
- // elapsedTime: UInt24LE in 0.01 sec
- bufferBuilder.writeUInt24LE(Math.round(data.totalMovingTime * 100))
- // intervalCount: UInt8
- bufferBuilder.writeUInt8(0)
- if (this._updateValueCallback) {
- // the multiplexer uses a slightly different format for the AdditionalStatus2
- // it skips averagePower before totalCalories
- // averagePower: UInt16LE in watts
- bufferBuilder.writeUInt16LE(Math.round(data.cyclePower))
- }
- // totalCalories: UInt16LE in kCal
- bufferBuilder.writeUInt16LE(Math.round(data.totalCalories))
- // splitAveragePace: UInt16LE in 0.01 sec/500m
- bufferBuilder.writeUInt16LE(0 * 100)
- // splitAveragePower UInt16LE in watts
- bufferBuilder.writeUInt16LE(0)
- // splitAverageCalories
- bufferBuilder.writeUInt16LE(0)
- // lastSplitTime
- bufferBuilder.writeUInt24LE(0 * 100)
- // lastSplitDistance in 1 m
- bufferBuilder.writeUInt24LE(0)
-
- if (this._updateValueCallback) {
- this._updateValueCallback(bufferBuilder.getBuffer())
- } else {
- this._multiplexedCharacteristic.notify(0x33, bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
- }
-}
diff --git a/app/ble/pm5/characteristic/AdditionalStrokeData.js b/app/ble/pm5/characteristic/AdditionalStrokeData.js
deleted file mode 100644
index 2a05515806..0000000000
--- a/app/ble/pm5/characteristic/AdditionalStrokeData.js
+++ /dev/null
@@ -1,67 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implementation of the AdditionalStrokeData as defined in:
- https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-import BufferBuilder from '../../BufferBuilder.js'
-
-export default class AdditionalStrokeData extends bleno.Characteristic {
- constructor (multiplexedCharacteristic) {
- super({
- // id for AdditionalStrokeData as defined in the spec
- uuid: getFullUUID('0036'),
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- this._multiplexedCharacteristic = multiplexedCharacteristic
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`AdditionalStrokeData - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('AdditionalStrokeData - central unsubscribed')
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
- const bufferBuilder = new BufferBuilder()
- // elapsedTime: UInt24LE in 0.01 sec
- bufferBuilder.writeUInt24LE(Math.round(data.totalMovingTime * 100))
- // strokePower: UInt16LE in watts
- bufferBuilder.writeUInt16LE(Math.round(data.cyclePower))
- // strokeCalories: UInt16LE in cal
- bufferBuilder.writeUInt16LE(Math.round(data.strokeCalories * 1000))
- // strokeCount: UInt16LE
- bufferBuilder.writeUInt16LE(Math.round(data.totalNumberOfStrokes))
- // projectedWorkTime: UInt24LE in 1 sec
- bufferBuilder.writeUInt24LE(Math.round(data.cycleProjectedEndTime))
- // projectedWorkDistance: UInt24LE in 1 m
- bufferBuilder.writeUInt24LE(Math.round(data.cycleProjectedEndLinearDistance))
- if (!this._updateValueCallback) {
- // the multiplexer uses a slightly different format for the AdditionalStrokeData
- // it adds workPerStroke at the end
- // workPerStroke: UInt16LE in 0.1 Joules
- bufferBuilder.writeUInt16LE(Math.round(data.strokeWork * 10))
- }
-
- if (this._updateValueCallback) {
- this._updateValueCallback(bufferBuilder.getBuffer())
- } else {
- this._multiplexedCharacteristic.notify(0x36, bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
- }
-}
diff --git a/app/ble/pm5/characteristic/ControlReceive.js b/app/ble/pm5/characteristic/ControlReceive.js
deleted file mode 100644
index ace8b2347e..0000000000
--- a/app/ble/pm5/characteristic/ControlReceive.js
+++ /dev/null
@@ -1,28 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implementation of the ControlReceive Characteristic as defined in:
- https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
- Used to receive controls from the central
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-
-export default class ControlReceive extends bleno.Characteristic {
- constructor () {
- super({
- // id for ControlReceive as defined in the spec
- uuid: getFullUUID('0021'),
- value: null,
- properties: ['write']
- })
- this._updateValueCallback = null
- }
-
- // Central sends a command to the Control Point
- onWriteRequest (data, offset, withoutResponse, callback) {
- log.debug('ControlReceive command: ', data)
- }
-}
diff --git a/app/ble/pm5/characteristic/ControlTransmit.js b/app/ble/pm5/characteristic/ControlTransmit.js
deleted file mode 100644
index 644ec7a2b7..0000000000
--- a/app/ble/pm5/characteristic/ControlTransmit.js
+++ /dev/null
@@ -1,44 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implementation of the ControlTransmit Characteristic as defined in:
- https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
- Used to transmit controls to the central
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-import BufferBuilder from '../../BufferBuilder.js'
-
-export default class ControlTransmit extends bleno.Characteristic {
- constructor () {
- super({
- // id for ControlTransmit as defined in the spec
- uuid: getFullUUID('0022'),
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`ControlTransmit - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('ControlTransmit - central unsubscribed')
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- if (this._updateValueCallback) {
- const bufferBuilder = new BufferBuilder()
- this._updateValueCallback(bufferBuilder.getBuffer())
- return this.RESULT_SUCCESS
- }
- }
-}
diff --git a/app/ble/pm5/characteristic/GeneralStatus.js b/app/ble/pm5/characteristic/GeneralStatus.js
deleted file mode 100644
index c0116ee038..0000000000
--- a/app/ble/pm5/characteristic/GeneralStatus.js
+++ /dev/null
@@ -1,71 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implementation of the GeneralStatus as defined in:
- https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-import BufferBuilder from '../../BufferBuilder.js'
-
-export default class GeneralStatus extends bleno.Characteristic {
- constructor (multiplexedCharacteristic) {
- super({
- // id for GeneralStatus as defined in the spec
- uuid: getFullUUID('0031'),
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- this._multiplexedCharacteristic = multiplexedCharacteristic
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`GeneralStatus - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('GeneralStatus - central unsubscribed')
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
- const bufferBuilder = new BufferBuilder()
- // elapsedTime: UInt24LE in 0.01 sec
- bufferBuilder.writeUInt24LE(Math.round(data.totalMovingTime * 100))
- // distance: UInt24LE in 0.1 m
- bufferBuilder.writeUInt24LE(Math.round(data.totalLinearDistance * 10))
- // workoutType: UInt8 0 WORKOUTTYPE_JUSTROW_NOSPLITS, 2 WORKOUTTYPE_FIXEDDIST_NOSPLITS, 4 WORKOUTTYPE_FIXEDTIME_NOSPLITS
- bufferBuilder.writeUInt8(data.sessiontype === 'Distance' ? 2 : (data.sessiontype === 'Time' ? 4 : 0))
- // intervalType: UInt8 will always use 255 (NONE)
- bufferBuilder.writeUInt8(255)
- // workoutState: UInt8 0 WAITTOBEGIN, 1 WORKOUTROW, 10 WORKOUTEND
- bufferBuilder.writeUInt8(data.sessionStatus === 'Rowing' ? 1 : (data.sessionStatus === 'WaitingForStart' ? 0 : 10))
- // rowingState: UInt8 0 INACTIVE, 1 ACTIVE
- bufferBuilder.writeUInt8(data.sessionStatus === 'Rowing' ? 1 : 0)
- // strokeState: UInt8 2 DRIVING, 4 RECOVERY
- bufferBuilder.writeUInt8(data.strokeState === 'WaitingForDrive' ? 0 : (data.strokeState === 'Drive' ? 2 : 4))
- // totalWorkDistance: UInt24LE in 1 m
- bufferBuilder.writeUInt24LE(Math.round(data.totalLinearDistance))
- // workoutDuration: UInt24LE in 0.01 sec (if type TIME)
- bufferBuilder.writeUInt24LE(Math.round(data.totalMovingTime * 100))
- // workoutDurationType: UInt8 0 TIME, 0x40 CALORIES, 0x80 DISTANCE, 0xC0 WATTS
- bufferBuilder.writeUInt8(data.sessiontype === 'Distance' ? 0x80 : 0)
- // dragFactor: UInt8
- bufferBuilder.writeUInt8(Math.round(Math.min(data.dragFactor, 255)))
-
- if (this._updateValueCallback) {
- this._updateValueCallback(bufferBuilder.getBuffer())
- } else {
- this._multiplexedCharacteristic.notify(0x31, bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
- }
-}
diff --git a/app/ble/pm5/characteristic/MultiplexedCharacteristic.js b/app/ble/pm5/characteristic/MultiplexedCharacteristic.js
deleted file mode 100644
index 7f1ee4e38f..0000000000
--- a/app/ble/pm5/characteristic/MultiplexedCharacteristic.js
+++ /dev/null
@@ -1,59 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implements the Multiplexed Characteristic as defined by the spec:
-
- "On some Android platforms, there is a limitation to the number of notification messages allowed.
- To circumvent this issue, a single characteristic (C2 multiplexed data
- info) exists to allow multiple characteristics to be multiplexed onto a single characteristic. The last byte in the
- characteristic will indicate which data characteristic is multiplexed."
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-
-export default class MultiplexedCharacteristic extends bleno.Characteristic {
- constructor () {
- super({
- // id for MultiplexedInformation as defined in the spec
- uuid: getFullUUID('0080'),
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`MultiplexedCharacteristic - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('MultiplexedCharacteristic - central unsubscribed')
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- centralSubscribed () {
- return this._updateValueCallback !== null
- }
-
- notify (id, characteristicBuffer) {
- const characteristicId = Buffer.alloc(1)
- characteristicId.writeUInt8(id, 0)
- const buffer = Buffer.concat(
- [
- characteristicId,
- characteristicBuffer
- ],
- characteristicId.length + characteristicBuffer.length
- )
-
- if (this._updateValueCallback) {
- this._updateValueCallback(buffer)
- }
- return this.RESULT_SUCCESS
- }
-}
diff --git a/app/ble/pm5/characteristic/StrokeData.js b/app/ble/pm5/characteristic/StrokeData.js
deleted file mode 100644
index 4f69bda2e9..0000000000
--- a/app/ble/pm5/characteristic/StrokeData.js
+++ /dev/null
@@ -1,72 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implementation of the StrokeData as defined in:
- https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
- todo: we could calculate all the missing stroke metrics in the RowerEngine
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-import BufferBuilder from '../../BufferBuilder.js'
-
-export default class StrokeData extends bleno.Characteristic {
- constructor (multiplexedCharacteristic) {
- super({
- // id for StrokeData as defined in the spec
- uuid: getFullUUID('0035'),
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- this._multiplexedCharacteristic = multiplexedCharacteristic
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`StrokeData - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('StrokeData - central unsubscribed')
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
- const bufferBuilder = new BufferBuilder()
- // elapsedTime: UInt24LE in 0.01 sec
- bufferBuilder.writeUInt24LE(Math.round(data.totalMovingTime * 100))
- // distance: UInt24LE in 0.1 m
- bufferBuilder.writeUInt24LE(Math.round(data.totalLinearDistance * 10))
- // driveLength: UInt8 in 0.01 m
- bufferBuilder.writeUInt8(Math.round(data.driveLength * 100))
- // driveTime: UInt8 in 0.01 s
- bufferBuilder.writeUInt8(Math.round(data.driveDuration * 100))
- // strokeRecoveryTime: UInt16LE in 0.01 s
- bufferBuilder.writeUInt16LE(Math.round(data.recoveryDuration * 100))
- // strokeDistance: UInt16LE in 0.01 s
- bufferBuilder.writeUInt16LE(Math.round(data.cycleDistance * 100))
- // peakDriveForce: UInt16LE in 0.1 lbs
- bufferBuilder.writeUInt16LE(Math.round(data.drivePeakHandleForce * 0.224809 * 10))
- // averageDriveForce: UInt16LE in 0.1 lbs
- bufferBuilder.writeUInt16LE(Math.round(data.driveAverageHandleForce * 0.224809 * 10))
- if (this._updateValueCallback) {
- // workPerStroke is only added if data is not send via multiplexer
- // workPerStroke: UInt16LE in 0.1 Joules
- bufferBuilder.writeUInt16LE(Math.round(data.strokeWork * 10))
- }
- // strokeCount: UInt16LE
- bufferBuilder.writeUInt16LE(data.totalNumberOfStrokes)
- if (this._updateValueCallback) {
- this._updateValueCallback(bufferBuilder.getBuffer())
- } else {
- this._multiplexedCharacteristic.notify(0x35, bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
- }
-}
diff --git a/app/ble/pm5/characteristic/ValueReadCharacteristic.js b/app/ble/pm5/characteristic/ValueReadCharacteristic.js
deleted file mode 100644
index 7797cd109f..0000000000
--- a/app/ble/pm5/characteristic/ValueReadCharacteristic.js
+++ /dev/null
@@ -1,40 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- A simple Characteristic that gives read and notify access to a static value
- Currently also used as placeholder on a lot of characteristics that are not yet implemented properly
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-
-export default class ValueReadCharacteristic extends bleno.Characteristic {
- constructor (uuid, value, description) {
- super({
- uuid,
- properties: ['read', 'notify'],
- value: null
- })
- this.uuid = uuid
- this._value = Buffer.isBuffer(value) ? value : Buffer.from(value)
- this._description = description
- this._updateValueCallback = null
- }
-
- onReadRequest (offset, callback) {
- log.debug(`ValueReadRequest: ${this._description ? this._description : this.uuid}`)
- callback(this.RESULT_SUCCESS, this._value.slice(offset, this._value.length))
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`characteristic ${this._description ? this._description : this.uuid} - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug(`characteristic ${this._description ? this._description : this.uuid} - central unsubscribed`)
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-}
diff --git a/app/client/components/AppDialog.js b/app/client/components/AppDialog.js
index eab5d2efae..1d4e273a88 100644
--- a/app/client/components/AppDialog.js
+++ b/app/client/components/AppDialog.js
@@ -1,10 +1,9 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Component that renders a html dialog
*/
-
import { AppElement, html, css } from './AppElement.js'
import { customElement, property } from 'lit/decorators.js'
import { ref, createRef } from 'lit/directives/ref.js'
@@ -46,10 +45,15 @@ export class AppDialog extends AppElement {
justify-content: center;
align-items: center;
}
- button:hover {
+ button:hover:not(.disabled) {
filter: brightness(150%);
}
+ button.disabled {
+ filter: brightness(50%);
+ pointer: none
+ }
+
fieldset {
border: 0;
margin: unset;
@@ -67,6 +71,8 @@ export class AppDialog extends AppElement {
padding: 0;
}
`
+ @property({ type: Boolean })
+ isValid = true
@property({ type: Boolean, reflect: true })
dialogOpen
@@ -74,13 +80,13 @@ export class AppDialog extends AppElement {
render () {
return html`
@@ -95,6 +101,13 @@ export class AppDialog extends AppElement {
}
}
+ confirm () {
+ if (this.isValid) {
+ this.close({ target: { returnValue: 'confirm' } })
+ this.dialogOpen = false
+ }
+ }
+
firstUpdated () {
this.dialog.value.showModal()
}
diff --git a/app/client/components/AppElement.js b/app/client/components/AppElement.js
index efe6c39454..817492b415 100644
--- a/app/client/components/AppElement.js
+++ b/app/client/components/AppElement.js
@@ -1,27 +1,14 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/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(
diff --git a/app/client/components/BatteryIcon.js b/app/client/components/BatteryIcon.js
index 2321a736c5..7bc62e1e01 100644
--- a/app/client/components/BatteryIcon.js
+++ b/app/client/components/BatteryIcon.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Component that renders a battery indicator
*/
diff --git a/app/client/components/DashboardActions.js b/app/client/components/DashboardActions.js
index 109cdfa4f4..a24ca248fd 100644
--- a/app/client/components/DashboardActions.js
+++ b/app/client/components/DashboardActions.js
@@ -1,41 +1,57 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Component that renders the action buttons of the dashboard
*/
import { AppElement, html, css } from './AppElement.js'
-import { customElement, state } from 'lit/decorators.js'
-import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload } from '../lib/icons.js'
+import { customElement, property, state } from 'lit/decorators.js'
+import { iconUndo, iconExpand, iconCompress, iconPoweroff, iconBluetooth, iconUpload, iconHeartbeat, iconAntplus } from '../lib/icons.js'
import './AppDialog.js'
@customElement('dashboard-actions')
export class DashboardActions extends AppElement {
static styles = css`
button {
+ position: relative;
outline:none;
background-color: var(--theme-button-color);
border: 0;
border-radius: var(--theme-border-radius);
color: var(--theme-font-color);
- margin: 0.2em 0;
+ margin: 0.2em 4px;
font-size: 60%;
text-decoration: none;
display: inline-flex;
- width: 3.5em;
- height: 2.5em;
+ width: 3.2em;
+ min-width: 3.2em;
+ height: 2.2em;
justify-content: center;
align-items: center;
}
+
button:hover {
filter: brightness(150%);
}
+ button > div.text {
+ position: absolute;
+ left: 2px;
+ bottom: 2px;
+ font-size: 40%;
+ }
+
#fullscreen-icon {
display: inline-flex;
}
+ .top-button-group {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
#windowed-icon {
display: none;
}
@@ -45,7 +61,7 @@ export class DashboardActions extends AppElement {
}
.peripheral-mode {
- font-size: 80%;
+ font-size: 50%;
}
@media (display-mode: fullscreen) {
@@ -58,65 +74,95 @@ export class DashboardActions extends AppElement {
}
`
- @state({ type: Object })
- dialog
+ @property({ type: Object })
+ config = {}
+
+ @state()
+ _appMode = 'BROWSER'
+
+ @state()
+ _dialog
render () {
return html`
-
- ${this.renderOptionalButtons()}
-
-
${this.peripheralMode()}
- ${this.dialog ? this.dialog : ''}
+
+
+
+
${this.blePeripheralMode()}
+
+ ${this._dialog ? this._dialog : ''}
`
}
+ firstUpdated () {
+ switch (new URLSearchParams(window.location.search).get('mode')) {
+ case 'standalone':
+ this._appMode = 'STANDALONE'
+ break
+ case 'kiosk':
+ this._appMode = 'KIOSK'
+ break
+ default:
+ this._appMode = 'BROWSER'
+ }
+ }
+
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 if the
// browser supports this feature
- if (this.appState?.appMode === 'BROWSER' && document.documentElement.requestFullscreen) {
+ if (this._appMode === 'BROWSER' && document.documentElement.requestFullscreen) {
buttons.push(html`
`)
}
// add a button to power down the device, if browser is running on the device in kiosk mode
// and the shutdown feature is enabled
// (might also make sence to enable this for all clients but then we would need visual feedback)
- if (this.appState?.appMode === 'KIOSK' && this.appState?.config?.shutdownEnabled) {
+ if (this._appMode === 'KIOSK' && this.config?.shutdownEnabled) {
buttons.push(html`
-
+
`)
}
- if (this.appState?.config?.stravaUploadEnabled) {
+ if (this.config?.uploadEnabled) {
buttons.push(html`
-
+
`)
}
return buttons
}
- peripheralMode () {
- const value = this.appState?.config?.peripheralMode
-
+ blePeripheralMode () {
+ const value = this.config?.blePeripheralMode
switch (value) {
case 'PM5':
return 'C2 PM5'
case 'FTMSBIKE':
return 'FTMS Bike'
case 'CSC':
- return 'BLE Bike Speed + Cadence'
+ return 'Bike Speed + Cadence'
case 'CPS':
- return 'BLE Bike Power'
+ return 'Bike Power'
case 'FTMS':
return 'FTMS Rower'
default:
- return ''
+ return 'Off'
}
}
@@ -135,34 +181,42 @@ export class DashboardActions extends AppElement {
this.sendEvent('triggerAction', { command: 'reset' })
}
- switchPeripheralMode () {
- this.sendEvent('triggerAction', { command: 'switchPeripheralMode' })
+ switchBlePeripheralMode () {
+ this.sendEvent('triggerAction', { command: 'switchBlePeripheralMode' })
+ }
+
+ switchAntPeripheralMode () {
+ this.sendEvent('triggerAction', { command: 'switchAntPeripheralMode' })
+ }
+
+ switchHrmPeripheralMode () {
+ this.sendEvent('triggerAction', { command: 'switchHrmMode' })
}
uploadTraining () {
- this.dialog = html`
+ this._dialog = html`
-
- Do you want to finish your workout and upload it to Strava?
+
+ Do you want to finish your workout and upload it to webservices (Strava, Intervals.icu and RowsAndAll)?
`
function dialogClosed (event) {
- this.dialog = undefined
+ this._dialog = undefined
if (event.detail === 'confirm') {
- this.sendEvent('triggerAction', { command: 'uploadTraining' })
+ this.sendEvent('triggerAction', { command: 'upload' })
}
}
}
shutdown () {
- this.dialog = html`
+ this._dialog = html`
-
+
Do you want to shutdown the device?
`
function dialogClosed (event) {
- this.dialog = undefined
+ this._dialog = undefined
if (event.detail === 'confirm') {
this.sendEvent('triggerAction', { command: 'shutdown' })
}
diff --git a/app/client/components/DashboardForceCurve.js b/app/client/components/DashboardForceCurve.js
new file mode 100644
index 0000000000..2e57958c7c
--- /dev/null
+++ b/app/client/components/DashboardForceCurve.js
@@ -0,0 +1,125 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Component that renders a metric of the dashboard
+*/
+
+import { AppElement, html, css } from './AppElement.js'
+import { customElement, property, state } from 'lit/decorators.js'
+import ChartDataLabels from 'chartjs-plugin-datalabels'
+import { Chart, Filler, Legend, LinearScale, LineController, LineElement, PointElement } from 'chart.js'
+
+@customElement('dashboard-force-curve')
+export class DashboardForceCurve extends AppElement {
+ static styles = css`
+ canvas {
+ margin-top: 24px;
+ }
+ `
+
+ constructor () {
+ super()
+ Chart.register(ChartDataLabels, Legend, Filler, LinearScale, LineController, PointElement, LineElement)
+ }
+
+ @property({ type: Object })
+ value = []
+
+ @state()
+ _chart
+
+ firstUpdated () {
+ const ctx = this.renderRoot.querySelector('#chart').getContext('2d')
+ this._chart = new Chart(
+ ctx,
+ {
+ type: 'line',
+ data: {
+ datasets: [
+ {
+ fill: true,
+ data: this.value?.map((data, index) => ({ y: data, x: index })),
+ pointRadius: 1,
+ borderColor: 'rgb(255,255,255)',
+ backgroundColor: 'rgb(220,220,220)'
+ }
+ ]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ datalabels: {
+ anchor: 'center',
+ align: 'top',
+ formatter: (value) => `Peak: ${Math.round(value.y)}`,
+ display: (ctx) => Math.max(
+ ...ctx.dataset.data.map((point) => point.y)
+ ) === ctx.dataset.data[ctx.dataIndex].y,
+ font: {
+ size: 16
+ },
+ color: 'rgb(255,255,255)'
+ },
+ legend: {
+ title: {
+ display: true,
+ text: 'Force Curve',
+ color: 'rgb(255,255,255)',
+ font: {
+ size: 32
+ },
+ padding: {
+ }
+ },
+ labels: {
+ boxWidth: 0,
+ font: {
+ size: 0
+ }
+ }
+ }
+ },
+ scales: {
+ x: {
+ type: 'linear',
+ display: false
+ },
+ y: {
+ ticks: {
+ color: 'rgb(255,255,255)'
+ }
+ }
+ },
+ animations: {
+ tension: {
+ duration: 200,
+ easing: 'easeInQuad'
+ },
+ y: {
+ duration: 200,
+ easing: 'easeInQuad'
+ },
+ x: {
+ duration: 200,
+ easing: 'easeInQuad'
+ }
+ }
+ }
+ }
+ )
+ }
+
+ render () {
+ if (this._chart?.data) {
+ this._chart.data.datasets[0].data = this.value?.map((data, index) => ({ y: data, x: index }))
+ this.forceCurve = this.value
+ this._chart.update()
+ }
+
+ return html`
+
+ `
+ }
+}
diff --git a/app/client/components/DashboardMetric.js b/app/client/components/DashboardMetric.js
index 185c89f470..0301874fee 100644
--- a/app/client/components/DashboardMetric.js
+++ b/app/client/components/DashboardMetric.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Component that renders a metric of the dashboard
*/
@@ -35,19 +35,21 @@ export class DashboardMetric extends AppElement {
`
@property({ type: Object })
- icon
+ icon = ''
@property({ type: String })
unit = ''
@property({ type: String })
- value = ''
+ value
render () {
return html`
- ${this.icon}
+
- ${this.value !== undefined ? this.value : '--'}
+ ${this.value !== undefined ? this.value : '--'}
${this.unit}
diff --git a/app/client/components/PerformanceDashboard.js b/app/client/components/PerformanceDashboard.js
index a37fcb41fc..7d0e932eb3 100644
--- a/app/client/components/PerformanceDashboard.js
+++ b/app/client/components/PerformanceDashboard.js
@@ -1,17 +1,15 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/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 './BatteryIcon.js'
-import { icon_route, icon_stopwatch, icon_bolt, icon_paddle, icon_heartbeat, icon_fire, icon_clock } from '../lib/icons.js'
+import { customElement, property, state } from 'lit/decorators.js'
+import './SettingsDialog.js'
+import { iconSettings } from '../lib/icons.js'
+import { DASHBOARD_METRICS } from '../store/dashboardMetrics.js'
@customElement('performance-dashboard')
export class PerformanceDashboard extends AppElement {
@@ -22,7 +20,6 @@ export class PerformanceDashboard extends AppElement {
padding: 1vw;
grid-gap: 1vw;
grid-template-columns: repeat(4, minmax(0, 1fr));
- grid-template-rows: repeat(2, minmax(0, 1fr));
}
@media (orientation: portrait) {
@@ -32,7 +29,7 @@ export class PerformanceDashboard extends AppElement {
}
}
- dashboard-metric, dashboard-actions {
+ dashboard-metric, dashboard-actions, dashboard-force-curve {
background: var(--theme-widget-color);
text-align: center;
position: relative;
@@ -43,64 +40,70 @@ export class PerformanceDashboard extends AppElement {
dashboard-actions {
padding: 0.5em 0 0 0;
}
+
+ .settings {
+ padding: 0.1em 0;
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ z-index: 20;
+ }
+
+ .settings .icon {
+ cursor: pointer;
+ height: 1em;
+ }
+
+ .settings:hover .icon {
+ filter: brightness(150%);
+ }
`
+ @property()
+ appState = {}
+
+ @state()
+ _dialog
- @property({ type: Object })
- metrics
+ dashboardMetricComponentsFactory = (appState) => {
+ const metrics = appState.metrics
+ const configs = appState.config
- @property({ type: Object })
- appState = APP_STATE
+ const dashboardMetricComponents = Object.keys(DASHBOARD_METRICS).reduce((dashboardMetrics, key) => {
+ dashboardMetrics[key] = DASHBOARD_METRICS[key].template(metrics, configs)
+
+ return dashboardMetrics
+ }, {})
+
+ return dashboardMetricComponents
+ }
render () {
- const metrics = this.calculateFormattedMetrics(this.appState.metrics)
+ const metricConfig = [...new Set(this.appState.config.guiConfigs.dashboardMetrics)].reduce((prev, metricName) => {
+ prev.push(this.dashboardMetricComponentsFactory(this.appState)[metricName])
+ return prev
+ }, [])
+
return html`
-
-
-
-
- ${metrics?.heartrate?.value
- ? html`
-
- ${metrics?.heartrateBatteryLevel?.value
- ? html`
-
- `
- : ''
- }
- `
- : html``}
-
-
-
+
+
+ ${iconSettings}
+ ${this._dialog ? this._dialog : ''}
+
+
+ ${metricConfig}
`
}
- // todo: so far this is just a port of the formatter from the initial proof of concept client
- // we could split this up to make it more readable and testable
- calculateFormattedMetrics (metrics) {
- const fieldFormatter = {
- totalLinearDistanceFormatted: (value) => value >= 10000
- ? { value: (value / 1000).toFixed(2), unit: 'km' }
- : { value: Math.round(value), unit: 'm' },
- totalCalories: (value) => Math.round(value),
- cyclePower: (value) => Math.round(value),
- cycleStrokeRate: (value) => Math.round(value)
- }
+ openSettings () {
+ this._dialog = html``
- 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
- }
- }
+ /* eslint-disable-next-line no-unused-vars -- Standard construct?? */
+ function dialogClosed (event) {
+ this._dialog = undefined
}
- return formattedMetrics
}
}
diff --git a/app/client/components/SettingsDialog.js b/app/client/components/SettingsDialog.js
new file mode 100644
index 0000000000..d0471cef88
--- /dev/null
+++ b/app/client/components/SettingsDialog.js
@@ -0,0 +1,253 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Component that renders the action buttons of the dashboard
+*/
+
+import { AppElement, html, css } from './AppElement.js'
+import { customElement, property, query, queryAll, state } from 'lit/decorators.js'
+import { iconSettings } from '../lib/icons.js'
+import './AppDialog.js'
+import { DASHBOARD_METRICS } from '../store/dashboardMetrics.js'
+
+@customElement('settings-dialog')
+export class DashboardActions extends AppElement {
+ static styles = css`
+ .metric-selector-feedback{
+ font-size: 0.5em;
+ padding-top: 8px;
+ }
+
+ .settings-dialog>div.metric-selector{
+ display: grid;
+ grid-template-columns: repeat(3,max-content);
+ gap: 8px;
+ }
+
+ .experimental-settings {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .experimental-settings label {
+ width: fit-content;
+ margin-top: 8px;
+ font-size: 0.7em;
+ }
+
+ .experimental-settings label>input {
+ font-size: 0.7em;
+ }
+
+ .settings-dialog>div>label{
+ font-size: 0.6em;
+ width: fit-content;
+ }
+
+ input[type="checkbox"]{
+ cursor: pointer;
+ align-self: center;
+ width: 1.5em;
+ height: 1.5em;
+ }
+
+ label>span {
+ cursor: pointer;
+ -webkit-user-select: none;
+ user-select: none;
+ }
+
+ .icon {
+ height: 1.6em;
+ }
+
+ legend{
+ text-align: center;
+ }
+
+ table {
+ min-height: 70px;
+ margin-top: 8px;
+ width: 100%;
+ }
+
+ table, th, td {
+ font-size: 0.9em;
+ border: 1px solid white;
+ border-collapse: collapse;
+ }
+
+ tr {
+ height: 50%;
+ }
+
+ th, td {
+ padding: 8px;
+ text-align: center;
+ background-color: var(--theme-widget-color);
+ }
+
+ .show-icons-selector {
+ display: flex;
+ gap: 8px;
+ }
+
+ app-dialog > *:last-child {
+ margin-bottom: -24px;
+ }
+ `
+
+ @property({ type: Object })
+ config = {}
+
+ @queryAll('.metric-selector input')
+ _inputs
+
+ @query('input[name="showIcons"]')
+ _showIconInput
+
+ @query('input[name="maxNumberOfTiles"]')
+ _maxNumberOfTilesInput
+
+ @state()
+ _selectedMetrics = []
+
+ @state()
+ _sumSelectedSlots = 0
+
+ @state()
+ _isValid = false
+
+ @state()
+ _showIcons = true
+
+ @state()
+ _maxNumberOfTiles = 8
+
+ render () {
+ return html`
+
+
+
+ Select metrics to be shown:
+
+ ${this.renderAvailableMetricList()}
+
+ Slots remaining: ${this._maxNumberOfTiles - this._sumSelectedSlots}
+
+ ${this.renderSelectedMetrics()}
+
+
+
+
+
+
+ Experimental settings:
+
+
+
+ `
+ }
+
+ firstUpdated () {
+ this._selectedMetrics = [...this.config.dashboardMetrics]
+ this._sumSelectedSlots = this._selectedMetrics.length
+ this._showIcons = this.config.showIcons
+ this._maxNumberOfTiles = this.config.maxNumberOfTiles
+ if (this._sumSelectedSlots === this._maxNumberOfTiles) {
+ this._isValid = true
+ } else {
+ this._isValid = false
+ }
+ [...this._inputs].forEach(input => {
+ input.checked = this._selectedMetrics.find(metric => metric === input.name) !== undefined
+ })
+ this._showIconInput.checked = this._showIcons
+ this._maxNumberOfTilesInput.checked = this._maxNumberOfTiles === 12
+ }
+
+ renderAvailableMetricList () {
+ return Object.keys(DASHBOARD_METRICS).map(key => html`
+
+ `)
+ }
+
+ renderSelectedMetrics () {
+ const selectedMetrics = [html`${[0, 1, 2, 3].map(index => html`| ${this._selectedMetrics[index]} | `)}
`]
+ selectedMetrics.push(html`${[4, 5, 6, 7].map(index => html`| ${this._selectedMetrics[index]} | `)}
`)
+ if (this._maxNumberOfTiles === 12) {
+ selectedMetrics.push(html`${[8, 9, 10, 11].map(index => html`| ${this._selectedMetrics[index]} | `)}
`)
+ }
+
+ return selectedMetrics
+ }
+
+ toggleCheck (e) {
+ if (e.target.checked && ((this._selectedMetrics.length % 4 === 3 && e.target.size > 1) || (this._sumSelectedSlots + e.target.size > this._maxNumberOfTiles))) {
+ this._isValid = this.isFormValid()
+ e.target.checked = false
+ return
+ }
+
+ if (e.target.checked) {
+ for (let index = 0; index < e.target.size; index++) {
+ this._selectedMetrics = [...this._selectedMetrics, e.target.name]
+ }
+ } else {
+ for (let index = 0; index < e.target.size; index++) {
+ this._selectedMetrics.splice(this._selectedMetrics.findIndex(metric => metric === e.target.name), 1)
+ this._selectedMetrics = [...this._selectedMetrics]
+ }
+ }
+
+ this._sumSelectedSlots = this._selectedMetrics.length
+ if (this.isFormValid()) {
+ this._isValid = true
+ } else {
+ this._isValid = false
+ }
+ }
+
+ toggleIcons (e) {
+ this._showIcons = e.target.checked
+ }
+
+ toggleMaxTiles (e) {
+ this._maxNumberOfTiles = e.target.checked ? 12 : 8
+ this._isValid = this.isFormValid()
+ }
+
+ isFormValid () {
+ return this._sumSelectedSlots === this._maxNumberOfTiles && this._selectedMetrics[3] !== this._selectedMetrics[4] && this._selectedMetrics[7] !== this._selectedMetrics?.[8]
+ }
+
+ close (event) {
+ this.dispatchEvent(new CustomEvent('close'))
+ if (event.detail === 'confirm') {
+ this.sendEvent('changeGuiSetting', {
+ dashboardMetrics: this._selectedMetrics,
+ showIcons: this._showIcons,
+ maxNumberOfTiles: this._maxNumberOfTiles
+ })
+ }
+ }
+}
diff --git a/app/client/index.js b/app/client/index.js
index b26dfcd4e6..4bfc50271d 100644
--- a/app/client/index.js
+++ b/app/client/index.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Main Initialization Component of the Web Component App
*/
@@ -14,10 +14,7 @@ import './components/PerformanceDashboard.js'
@customElement('web-app')
export class App extends LitElement {
@state()
- appState = APP_STATE
-
- @state()
- metrics
+ _appState = APP_STATE
constructor () {
super()
@@ -28,6 +25,11 @@ export class App extends LitElement {
// todo: we also want a mechanism here to get notified of state changes
})
+ const config = this._appState.config.guiConfigs
+ Object.keys(config).forEach(key => {
+ config[key] = JSON.parse(localStorage.getItem(key)) ?? config[key]
+ })
+
// 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
@@ -39,20 +41,28 @@ export class App extends LitElement {
this.addEventListener('triggerAction', (event) => {
this.app.handleAction(event.detail)
})
+
+ // notify the app about the triggered action
+ this.addEventListener('changeGuiSetting', (event) => {
+ Object.keys(event.detail).forEach(key => {
+ localStorage.setItem(key, JSON.stringify(event.detail[key]))
+ })
+ this.updateState({ config: { ...this._appState.config, guiConfigs: { ...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 }
+ this._appState = { ...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))
+ return JSON.parse(JSON.stringify(this._appState))
}
// once we have multiple views, then we would rather reference some kind of router here
@@ -60,8 +70,7 @@ export class App extends LitElement {
render () {
return html`
`
}
diff --git a/app/client/lib/app.js b/app/client/lib/app.js
index 86da9f67b7..011ae1e7de 100644
--- a/app/client/lib/app.js
+++ b/app/client/lib/app.js
@@ -1,49 +1,29 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Initialization file of the Open Rowing Monitor App
*/
-
+/* eslint-disable no-console -- This runs client side, so I guess we have no logging capabilities? */
import NoSleep from 'nosleep.js'
import { filterObjectByKeys } from './helper.js'
-const rowingMetricsFields = ['totalNumberOfStrokes', 'totalLinearDistanceFormatted', 'totalCalories', 'cyclePower', 'heartrate',
- 'heartrateBatteryLevel', 'cyclePaceFormatted', 'cycleStrokeRate', 'totalMovingTimeFormatted']
-
export function createApp (app) {
- const urlParameters = new URLSearchParams(window.location.search)
- const mode = urlParameters.get('mode')
- const appMode = mode === 'standalone' ? 'STANDALONE' : mode === 'kiosk' ? 'KIOSK' : 'BROWSER'
- app.updateState({ ...app.getState(), appMode })
-
- const stravaAuthorizationCode = urlParameters.get('code')
-
let socket
initWebsocket()
resetFields()
requestWakeLock()
- function websocketOpened () {
- if (stravaAuthorizationCode) {
- handleStravaAuthorization(stravaAuthorizationCode)
- }
- }
-
- function handleStravaAuthorization (stravaAuthorizationCode) {
- if (socket)socket.send(JSON.stringify({ command: 'stravaAuthorizationCode', data: stravaAuthorizationCode }))
- }
-
let initialWebsocketOpenend = true
function initWebsocket () {
// use the native websocket implementation of browser to communicate with backend
socket = new WebSocket(`ws://${location.host}/websocket`)
+ /* eslint-disable-next-line no-unused-vars -- Standard construct?? */
socket.addEventListener('open', (event) => {
console.log('websocket opened')
if (initialWebsocketOpenend) {
- websocketOpened()
initialWebsocketOpenend = false
}
})
@@ -53,6 +33,7 @@ export function createApp (app) {
socket.close()
})
+ /* eslint-disable-next-line no-unused-vars -- Standard construct?? */
socket.addEventListener('close', (event) => {
console.log('websocket closed, attempting reconnect')
setTimeout(() => {
@@ -71,33 +52,15 @@ export function createApp (app) {
const data = message.data
switch (message.type) {
case 'config': {
- app.updateState({ ...app.getState(), config: data })
+ app.updateState({ ...app.getState(), config: { ...app.getState().config, ...data } })
break
}
case 'metrics': {
- let activeFields = rowingMetricsFields
- // if we are in reset state only update heart rate and peripheral mode
- if (data.totalNumberOfStrokes < 1) {
- if (data.totalLinearDistanceFormatted > 0) {
- activeFields = ['totalLinearDistanceFormatted', 'heartrate', 'heartrateBatteryLevel']
- } else if (data.totalMovingTimeFormatted !== '00:00') {
- activeFields = ['totalMovingTimeFormatted', 'heartrate', 'heartrateBatteryLevel']
- } else {
- activeFields = ['heartrate', 'heartrateBatteryLevel']
- }
- }
-
- const filteredData = filterObjectByKeys(data, activeFields)
- app.updateState({ ...app.getState(), metrics: filteredData })
- break
- }
- case 'authorizeStrava': {
- const currentUrl = encodeURIComponent(window.location.href)
- window.location.href = `https://www.strava.com/oauth/authorize?client_id=${data.stravaClientId}&response_type=code&redirect_uri=${currentUrl}&approval_prompt=force&scope=activity:write`
+ app.updateState({ ...app.getState(), metrics: data })
break
}
default: {
- console.error(`unknown message type: ${message.type}`, message.data)
+ console.error('unknown message type: %s', message.type, message.data)
}
}
} catch (err) {
@@ -124,27 +87,38 @@ export function createApp (app) {
function resetFields () {
const appState = app.getState()
// drop all metrics except heartrate
- appState.metrics = filterObjectByKeys(appState.metrics, ['heartrate', 'heartrateBatteryLevel'])
- app.updateState(appState)
+ app.updateState({ ...appState, metrics: { ...filterObjectByKeys(appState.metrics, ['heartrate', 'heartRateBatteryLevel']) } })
}
function handleAction (action) {
+ if (!socket) {
+ console.error('no socket available for communication!')
+ return
+ }
switch (action.command) {
- case 'switchPeripheralMode': {
- if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' }))
+ case 'switchBlePeripheralMode': {
+ socket.send(JSON.stringify({ command: 'switchBlePeripheralMode' }))
+ break
+ }
+ case 'switchAntPeripheralMode': {
+ socket.send(JSON.stringify({ command: 'switchAntPeripheralMode' }))
+ break
+ }
+ case 'switchHrmMode': {
+ socket.send(JSON.stringify({ command: 'switchHrmMode' }))
break
}
case 'reset': {
resetFields()
- if (socket)socket.send(JSON.stringify({ command: 'reset' }))
+ socket.send(JSON.stringify({ command: 'reset' }))
break
}
- case 'uploadTraining': {
- if (socket)socket.send(JSON.stringify({ command: 'uploadTraining' }))
+ case 'upload': {
+ socket.send(JSON.stringify({ command: 'upload' }))
break
}
case 'shutdown': {
- if (socket)socket.send(JSON.stringify({ command: 'shutdown' }))
+ socket.send(JSON.stringify({ command: 'shutdown' }))
break
}
default: {
diff --git a/app/client/lib/helper.js b/app/client/lib/helper.js
index 16bae1394f..e249ea6298 100644
--- a/app/client/lib/helper.js
+++ b/app/client/lib/helper.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Helper functions
*/
@@ -14,3 +14,43 @@ export function filterObjectByKeys (object, keys) {
return obj
}, {})
}
+
+/**
+ * Pipe for converting seconds to a human readable time format 00:00
+ * @param {number} timeInSeconds The actual time in seconds.
+*/
+export function secondsToTimeString (timeInSeconds) {
+ if (timeInSeconds === undefined || timeInSeconds === null || isNaN(timeInSeconds)) { return '--' }
+ if (timeInSeconds === Infinity) { return '∞' }
+ const timeInRoundedSeconds = Math.round(timeInSeconds)
+ const hours = Math.floor(timeInRoundedSeconds / 3600)
+ const minutes = Math.floor(timeInRoundedSeconds / 60) - (hours * 60)
+ const seconds = Math.floor(timeInRoundedSeconds % 60)
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
+ } else {
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`
+ }
+}
+
+/**
+ * Pipe for formatting distance in meters with units
+ * @param {number} value The distance in meters.
+*/
+export function formatDistance (value) {
+ return value >= 99999.5 ?
+ { distance: formatNumber((value / 1000), 2), unit: 'km' } :
+ { distance: formatNumber(value), unit: 'm' }
+}
+
+/**
+ * Pipe for formatting numbers to specific decimal
+ * @param {number} value The number.
+ * @param {number} decimalPlaces The number of decimal places to round to (default: 0).
+*/
+export function formatNumber (value, decimalPlaces = 0) {
+ const decimal = Math.pow(10, decimalPlaces)
+ if (value === undefined || value === null || value === Infinity || isNaN(value) || value === 0) { return '--' }
+
+ return Math.round(value * decimal) / decimal
+}
diff --git a/app/client/lib/helper.test.js b/app/client/lib/helper.test.js
index 42fd02c692..96578a3b7e 100644
--- a/app/client/lib/helper.test.js
+++ b/app/client/lib/helper.test.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
import { test } from 'uvu'
import * as assert from 'uvu/assert'
diff --git a/app/client/lib/icons.js b/app/client/lib/icons.js
index 23b9a75668..85da58ae7a 100644
--- a/app/client/lib/icons.js
+++ b/app/client/lib/icons.js
@@ -1,27 +1,41 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
SVG Icons that are used by the Application
*/
import { svg } from 'lit'
-export const icon_route = svg``
-export const icon_stopwatch = svg``
-export const icon_bolt = svg``
-export const icon_paddle = svg`
+export const iconRoute = svg``
+export const iconStopwatch = svg``
+export const iconBolt = svg``
+export const iconPaddle = svg`
`
-export const icon_heartbeat = svg``
-export const icon_fire = svg``
-export const icon_clock = svg``
-export const icon_undo = svg``
-export const icon_poweroff = svg``
-export const icon_expand = svg``
-export const icon_compress = svg``
-export const icon_bluetooth = svg``
-export const icon_upload = svg``
+export const iconHeartbeat = svg``
+export const iconFire = svg``
+export const iconClock = svg``
+export const iconAlarmclock = svg`
+
+ `
+export const iconUndo = svg``
+export const iconPoweroff = svg``
+export const iconExpand = svg``
+export const iconCompress = svg``
+export const iconBluetooth = svg``
+export const iconUpload = svg``
+
+export const iconAntplus = svg``
+export const iconSettings = svg``
+export const rowerIcon = svg``
diff --git a/app/client/store/appState.js b/app/client/store/appState.js
index 12666d7de6..39ed76d3a0 100644
--- a/app/client/store/appState.js
+++ b/app/client/store/appState.js
@@ -1,21 +1,55 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Defines the global state of the app
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
-
+/**
+ * @file Defines the initial global state of the webclient, before the webserver pushes actual data
+ */
export const APP_STATE = {
- // currently can be STANDALONE (Mobile Home Screen App), KIOSK (Raspberry Pi deployment) or '' (default)
- appMode: '',
// contains all the rowing metrics that are delivered from the backend
- metrics: {},
+ metrics: {
+ strokeState: 'WaitingForDrive',
+ sessionState: 'WaitingForStart',
+ totalMovingTime: 0,
+ pauseCountdownTime: 0,
+ totalNumberOfStrokes: 0,
+ totalLinearDistance: 0,
+ cyclePace: Infinity,
+ cyclePower: 0,
+ cycleStrokeRate: 0,
+ driveLength: 0,
+ driveDuration: 0,
+ driveHandleForceCurve: [],
+ driveDistance: 0,
+ recoveryDuration: 0,
+ dragFactor: undefined,
+ interval: {
+ type: 'justrow',
+ movingTime: {
+ sinceStart: 0,
+ toEnd: 0
+ },
+ distance: {
+ fromStart: 0,
+ toEnd: 0
+ }
+ }
+ },
config: {
- // currently can be FTMS, FTMSBIKE, PM5, CSC, CPS
- peripheralMode: '',
- // true if upload to strava is enabled
- stravaUploadEnabled: false,
+ // currently can be FTMS, FTMSBIKE, PM5, CSC, CPS, OFF
+ blePeripheralMode: '',
+ // currently can be ANT, BLE, OFF
+ hrmPeripheralMode: '',
+ // currently can be FE, OFF
+ antPeripheralMode: '',
+ // true if manual upload to strava, intervals or rowsandall is enabled
+ uploadEnabled: false,
// true if remote device shutdown is enabled
- shutdownEnabled: false
+ shutdownEnabled: false,
+ guiConfigs: {
+ dashboardMetrics: ['distance', 'timer', 'pace', 'power', 'stkRate', 'totalStk', 'calories', 'actions'],
+ showIcons: true,
+ maxNumberOfTiles: 8
+ }
}
}
diff --git a/app/client/store/dashboardMetrics.js b/app/client/store/dashboardMetrics.js
new file mode 100644
index 0000000000..648ea43780
--- /dev/null
+++ b/app/client/store/dashboardMetrics.js
@@ -0,0 +1,110 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+
+import { html } from 'lit'
+import { formatDistance, formatNumber, secondsToTimeString } from '../lib/helper'
+import { iconBolt, iconClock, iconAlarmclock, iconFire, iconHeartbeat, iconPaddle, iconRoute, iconStopwatch, rowerIcon } from '../lib/icons'
+import '../components/DashboardForceCurve.js'
+import '../components/DashboardActions.js'
+import '../components/DashboardMetric.js'
+import '../components/BatteryIcon.js'
+
+export const DASHBOARD_METRICS = {
+ distance: {
+ displayName: 'Distance',
+ size: 1,
+ template: (metrics, config) => {
+ let distance
+ switch (true) {
+ case (metrics?.interval?.type === 'rest' && metrics?.pauseCountdownTime > 0):
+ distance = 0
+ break
+ case (metrics?.interval?.type === 'distance'):
+ distance = Math.max(metrics?.interval?.distance?.toEnd, 0)
+ break
+ default:
+ distance = Math.max(metrics?.interval?.distance?.fromStart, 0)
+ }
+ const linearDistance = formatDistance(distance ?? 0)
+
+ return simpleMetricFactory(linearDistance.distance, linearDistance.unit, config?.guiConfigs?.showIcons ? iconRoute : '')
+ }
+ },
+
+ pace: { displayName: 'Pace/500', size: 1, template: (metrics, config) => simpleMetricFactory(secondsToTimeString(metrics?.cyclePace), '/500m', config?.guiConfigs?.showIcons ? iconStopwatch : '') },
+
+ power: { displayName: 'Power', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.cyclePower), 'watt', config?.guiConfigs?.showIcons ? iconBolt : '') },
+
+ stkRate: { displayName: 'Stroke rate', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.cycleStrokeRate), '/min', config?.guiConfigs?.showIcons ? iconPaddle : '') },
+ heartRate: {
+ displayName: 'Heart rate',
+ size: 1,
+ template: (metrics, config) => html`
+ ${metrics?.heartRateBatteryLevel > 0 ?
+ html`` :
+ ''}
+ `
+ },
+
+ totalStk: { displayName: 'Total strokes', size: 1, template: (metrics, config) => simpleMetricFactory(metrics?.totalNumberOfStrokes, 'stk', config?.guiConfigs?.showIcons ? iconPaddle : '') },
+
+ calories: {
+ displayName: 'Calories',
+ size: 1,
+ template: (metrics, config) => {
+ const calories = metrics?.interval?.type === 'Calories' ? Math.max(metrics?.interval?.TargetCalories - metrics?.interval?.Calories, 0) : metrics?.totalCalories
+
+ return simpleMetricFactory(formatNumber(calories ?? 0), 'kcal', config?.guiConfigs?.showIcons ? iconFire : '')
+ }
+ },
+
+ timer: {
+ displayName: 'Timer',
+ size: 1,
+ template: (metrics, config) => {
+ let time
+ let icon
+ switch (true) {
+ case (metrics?.interval?.type === 'rest' && metrics?.pauseCountdownTime > 0):
+ time = metrics?.pauseCountdownTime
+ icon = iconAlarmclock
+ break
+ case (metrics?.interval?.type === 'time'):
+ time = Math.max(metrics?.interval?.movingTime?.toEnd, 0)
+ icon = iconClock
+ break
+ default:
+ time = Math.max(metrics?.interval?.movingTime?.sinceStart, 0)
+ icon = iconClock
+ }
+
+ return simpleMetricFactory(secondsToTimeString(time ?? 0), '', config?.guiConfigs?.showIcons ? icon : '')
+ }
+ },
+
+ distancePerStk: { displayName: 'Dist per Stroke', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.cycleDistance, 1), 'm', config?.guiConfigs?.showIcons ? rowerIcon : '') },
+
+ dragFactor: { displayName: 'Drag factor', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.dragFactor), '', config?.guiConfigs?.showIcons ? 'Drag' : '') },
+
+ driveLength: { displayName: 'Drive length', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.driveLength, 2), 'm', config?.guiConfigs?.showIcons ? 'Drive' : '') },
+
+ driveDuration: { displayName: 'Drive duration', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.driveDuration, 2), 'sec', config?.guiConfigs?.showIcons ? 'Drive' : '') },
+
+ recoveryDuration: { displayName: 'Recovery duration', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.recoveryDuration, 2), 'sec', config?.guiConfigs?.showIcons ? 'Recovery' : '') },
+
+ forceCurve: { displayName: 'Force curve', size: 2, template: (metrics) => html`` },
+
+ actions: { displayName: 'Actions', size: 1, template: (_, config) => html`` }
+}
+
+/**
+ * Helper function to create a simple metric tile
+ * @param {string | number} value The metric to show
+ * @param {string} unit The unit of the metric.
+ * @param {string | import('lit').TemplateResult<2>} icon The number of decimal places to round to (default: 0).
+*/
+function simpleMetricFactory (value = '--', unit = '', icon = '') {
+ return html``
+}
diff --git a/app/engine/Flywheel.js b/app/engine/Flywheel.js
index b20b09c1e3..e2024e3c95 100644
--- a/app/engine/Flywheel.js
+++ b/app/engine/Flywheel.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
This models the flywheel with all of its attributes, which we can also test for being powered
@@ -22,25 +22,27 @@
import loglevel from 'loglevel'
import { createStreamFilter } from './utils/StreamFilter.js'
-import { createSeries } from './utils/Series.js'
-import { createOLSLinearSeries } from './utils/OLSLinearSeries.js'
+import { createTSLinearSeries } from './utils/FullTSLinearSeries.js'
import { createTSQuadraticSeries } from './utils/FullTSQuadraticSeries.js'
+import { createWeighedSeries } from './utils/WeighedSeries.js'
+
const log = loglevel.getLogger('RowingEngine')
-function createFlywheel (rowerSettings) {
+export function createFlywheel (rowerSettings) {
const angularDisplacementPerImpulse = (2.0 * Math.PI) / rowerSettings.numOfImpulsesPerRevolution
- const flankLength = Math.max(3, rowerSettings.flankLength)
+ const flankLength = rowerSettings.flankLength
const minimumDragFactorSamples = Math.floor(rowerSettings.minimumRecoveryTime / rowerSettings.maximumTimeBetweenImpulses)
- const minumumTorqueBeforeStroke = rowerSettings.minumumForceBeforeStroke * (rowerSettings.sprocketRadius / 100)
+ const minimumAngularVelocity = angularDisplacementPerImpulse / rowerSettings.maximumTimeBetweenImpulses
+ const minimumTorqueBeforeStroke = rowerSettings.minimumForceBeforeStroke * (rowerSettings.sprocketRadius / 100)
const currentDt = createStreamFilter(rowerSettings.smoothing, rowerSettings.maximumTimeBetweenImpulses)
- const _deltaTime = createOLSLinearSeries(flankLength)
+ const _deltaTime = createTSLinearSeries(flankLength)
const _angularDistance = createTSQuadraticSeries(flankLength)
- const _angularVelocityMatrix = []
- const _angularAccelerationMatrix = []
- const drag = createStreamFilter(rowerSettings.dragFactorSmoothing, (rowerSettings.dragFactor / 1000000))
- const recoveryDeltaTime = createOLSLinearSeries()
+ const drag = createWeighedSeries(rowerSettings.dragFactorSmoothing, (rowerSettings.dragFactor / 1000000))
+ const recoveryDeltaTime = createTSLinearSeries()
const strokedetectionMinimalGoodnessOfFit = rowerSettings.minimumStrokeQuality
- const minumumRecoverySlope = createStreamFilter(rowerSettings.dragFactorSmoothing, rowerSettings.minumumRecoverySlope)
+ const minimumRecoverySlope = createWeighedSeries(rowerSettings.dragFactorSmoothing, rowerSettings.minimumRecoverySlope)
+ let _angularVelocityMatrix = []
+ let _angularAccelerationMatrix = []
let _deltaTimeBeforeFlank
let _angularVelocityAtBeginFlank
let _angularVelocityBeforeFlank
@@ -57,11 +59,12 @@ function createFlywheel (rowerSettings) {
let currentAngularDistance
reset()
+ /* eslint-disable max-statements -- we need to maintain a lot of metrics in the main loop, nothing we can do about that */
function pushValue (dataPoint) {
- if (dataPoint > rowerSettings.maximumStrokeTimeBeforePause || dataPoint < 0) {
+ if (isNaN(dataPoint) || dataPoint < 0 || dataPoint > rowerSettings.maximumStrokeTimeBeforePause) {
// This typicaly happends after a pause, we need to fix this as it throws off all time calculations
- log.debug(`*** WARNING: currentDt of ${dataPoint} sec isn't between 0 and maximumStrokeTimeBeforePause (${rowerSettings.maximumStrokeTimeBeforePause} sec)`)
- dataPoint = currentDt.clean()
+ log.debug(`*** WARNING: currentDt of ${dataPoint} sec isn't between 0 and maximumStrokeTimeBeforePause (${rowerSettings.maximumStrokeTimeBeforePause} sec), value skipped`)
+ return
}
if (dataPoint > rowerSettings.maximumTimeBetweenImpulses && maintainMetrics) {
@@ -69,9 +72,15 @@ function createFlywheel (rowerSettings) {
log.debug(`*** WARNING: currentDt of ${dataPoint} sec is above maximumTimeBetweenImpulses (${rowerSettings.maximumTimeBetweenImpulses} sec)`)
}
- if (dataPoint < rowerSettings.minimumTimeBetweenImpulses && maintainMetrics) {
- // This shouldn't happen, but let's log it to clarify there is some issue going on here
- log.debug(`*** WARNING: currentDt of ${dataPoint} sec is above minimumTimeBetweenImpulses (${rowerSettings.minimumTimeBetweenImpulses} sec)`)
+ if (dataPoint < rowerSettings.minimumTimeBetweenImpulses) {
+ if (_deltaTime.length() >= flankLength && maintainMetrics) {
+ // We are in a normal operational mode, so this shouldn't happen, but let's log it to clarify there is some issue going on here, but accept the value as the TS estimator can handle it
+ log.debug(`*** WARNING: currentDt of ${dataPoint} sec is below minimumTimeBetweenImpulses (${rowerSettings.minimumTimeBetweenImpulses} sec)`)
+ } else {
+ // This is probably due to the start-up noise of a slow but accelerating flywheel as the flink isn't filled or we aren't maintaining metrics
+ log.debug(`*** WARNING: currentDt of ${dataPoint} sec is below minimumTimeBetweenImpulses (${rowerSettings.minimumTimeBetweenImpulses} sec) in a startup phase, value skipped, consider udjusting the gpio debounce filter`)
+ return
+ }
}
currentDt.push(dataPoint)
@@ -81,7 +90,7 @@ function createFlywheel (rowerSettings) {
// Also we nend feed the Drag calculation. We need to do this, BEFORE the array shifts, as the valueAtSeriesBeginvalue
// value before the shift is certain to be part of a specific rowing phase (i.e. Drive or Recovery), once the buffer is filled completely
totalNumberOfImpulses += 1
- _deltaTimeBeforeFlank = _deltaTime.yAtSeriesBegin()
+ _deltaTimeBeforeFlank = _deltaTime.Y.atSeriesBegin()
totalTimeSpinning += _deltaTimeBeforeFlank
_angularVelocityBeforeFlank = _angularVelocityAtBeginFlank
_angularAccelerationBeforeFlank = _angularAccelerationAtBeginFlank
@@ -99,7 +108,7 @@ function createFlywheel (rowerSettings) {
}
// Let's feed the stroke detection algorithm
- // Please note that deltaTime MUST use dirty data to be ale to use the OLS algorithms effictively (Otherwise the Goodness of Fit can't be used as a filter!)
+ // Please note that deltaTime MUST use dirty data to be ale to use the regression algorithms effictively (Otherwise the Goodness of Fit can't be used as a filter!)
currentRawTime += currentDt.raw()
currentAngularDistance += angularDisplacementPerImpulse
_deltaTime.push(currentRawTime, currentDt.raw())
@@ -116,22 +125,24 @@ function createFlywheel (rowerSettings) {
}
// Let's make room for a new set of values for angular velocity and acceleration
- _angularVelocityMatrix[_angularVelocityMatrix.length] = createSeries(flankLength)
- _angularAccelerationMatrix[_angularAccelerationMatrix.length] = createSeries(flankLength)
+ _angularVelocityMatrix[_angularVelocityMatrix.length] = createWeighedSeries(flankLength, 0)
+ _angularAccelerationMatrix[_angularAccelerationMatrix.length] = createWeighedSeries(flankLength, 0)
let i = 0
+
while (i < _angularVelocityMatrix.length) {
- _angularVelocityMatrix[i].push(_angularDistance.firstDerivativeAtPosition(i))
- _angularAccelerationMatrix[i].push(_angularDistance.secondDerivativeAtPosition(i))
+ _angularVelocityMatrix[i].push(_angularDistance.firstDerivativeAtPosition(i), _angularDistance.goodnessOfFit())
+ _angularAccelerationMatrix[i].push(_angularDistance.secondDerivativeAtPosition(i), _angularDistance.goodnessOfFit())
i++
}
- _angularVelocityAtBeginFlank = _angularVelocityMatrix[0].median()
- _angularAccelerationAtBeginFlank = _angularAccelerationMatrix[0].median()
+ _angularVelocityAtBeginFlank = _angularVelocityMatrix[0].weighedAverage()
+ _angularAccelerationAtBeginFlank = _angularAccelerationMatrix[0].weighedAverage()
// And finally calculate the torque
- _torqueAtBeginFlank = (rowerSettings.flywheelInertia * _angularAccelerationAtBeginFlank + drag.clean() * Math.pow(_angularVelocityAtBeginFlank, 2))
+ _torqueAtBeginFlank = (rowerSettings.flywheelInertia * _angularAccelerationAtBeginFlank + drag.weighedAverage() * Math.pow(_angularVelocityAtBeginFlank, 2))
}
+ /* eslint-enable max-statements */
function maintainStateOnly () {
maintainMetrics = false
@@ -152,11 +163,12 @@ function createFlywheel (rowerSettings) {
// Calculation of the drag-factor
if (rowerSettings.autoAdjustDragFactor && recoveryDeltaTime.length() > minimumDragFactorSamples && recoveryDeltaTime.slope() > 0 && (!drag.reliable() || recoveryDeltaTime.goodnessOfFit() >= rowerSettings.minimumDragQuality)) {
- drag.push(slopeToDrag(recoveryDeltaTime.slope()))
+ drag.push(slopeToDrag(recoveryDeltaTime.slope()), recoveryDeltaTime.goodnessOfFit())
+
log.debug(`*** Calculated drag factor: ${(slopeToDrag(recoveryDeltaTime.slope()) * 1000000).toFixed(4)}, no. samples: ${recoveryDeltaTime.length()}, Goodness of Fit: ${recoveryDeltaTime.goodnessOfFit().toFixed(4)}`)
if (rowerSettings.autoAdjustRecoverySlope) {
// We are allowed to autoadjust stroke detection slope as well, so let's do that
- minumumRecoverySlope.push((1 - rowerSettings.autoAdjustRecoverySlopeMargin) * recoveryDeltaTime.slope())
+ minimumRecoverySlope.push((1 - rowerSettings.autoAdjustRecoverySlopeMargin) * recoveryDeltaTime.slope(), recoveryDeltaTime.goodnessOfFit())
log.debug(`*** Calculated recovery slope: ${recoveryDeltaTime.slope().toFixed(6)}, Goodness of Fit: ${recoveryDeltaTime.goodnessOfFit().toFixed(4)}`)
} else {
// We aren't allowed to adjust the slope, let's report the slope to help help the user configure it
@@ -213,16 +225,26 @@ function createFlywheel (rowerSettings) {
}
function dragFactor () {
- // Ths function returns the current dragfactor of the flywheel
- return drag.clean()
+ // This function returns the current dragfactor of the flywheel
+ return drag.weighedAverage()
+ }
+
+ function dragFactorIsReliable () {
+ // This returns whether the dragfactor is considered reliable, based on measurements instead of a default value
+ // We can't use reliable() as a filter on the dragFactor() function as Rower.js always needs some dragfactor for most calculations
+ if (rowerSettings.autoAdjustDragFactor) {
+ return drag.reliable()
+ } else {
+ return true
+ }
}
function isDwelling () {
// Check if the flywheel is spinning down beyond a recovery phase indicating that the rower has stopped rowing
// We conclude this based on
- // * A decelerating flywheel as the slope of the CurrentDt's goes up
- // * All CurrentDt's in the flank are above the maximum
- if (_deltaTime.slope() > 0 && deltaTimesAbove(rowerSettings.maximumTimeBetweenImpulses)) {
+ // * The angular velocity at the begin of the flank is above the minimum angular velocity (dependent on maximumTimeBetweenImpulses)
+ // * The entire flank has a positive trend, i.e. the flywheel is decelerating consistent with the dragforce being present
+ if (_angularVelocityAtBeginFlank < minimumAngularVelocity && deltaTimeSlopeAbove(minimumRecoverySlope.weighedAverage())) {
return true
} else {
return false
@@ -230,9 +252,9 @@ function createFlywheel (rowerSettings) {
}
function isAboveMinimumSpeed () {
- // Check if the flywheel has reached its minimum speed. We conclude this based on all CurrentDt's in the flank are below
- // the maximum, indicating a sufficiently fast flywheel
- if (deltaTimesEqualorBelow(rowerSettings.maximumTimeBetweenImpulses)) {
+ // Check if the flywheel has reached its minimum speed, and that it isn't flywheel noise. We conclude this based on the first element in the flank
+ // as this angular velocity is created by all curves that are in that flank and having an acceleration in the rest of the flank
+ if ((_angularVelocityAtBeginFlank >= minimumAngularVelocity) && (_deltaTime.Y.atSeriesBegin() <= rowerSettings.maximumTimeBetweenImpulses) && (_deltaTime.Y.atSeriesBegin() > rowerSettings.minimumTimeBetweenImpulses)) {
return true
} else {
return false
@@ -240,8 +262,9 @@ function createFlywheel (rowerSettings) {
}
function isUnpowered () {
- if ((deltaTimeSlopeAbove(minumumRecoverySlope.clean()) || torqueAbsent()) && _deltaTime.length() >= flankLength) {
- // We reached the minimum number of increasing currentDt values
+ // We consider the flywheel unpowered when there is an acceleration consistent with the drag being the only forces AND no torque being seen
+ // As in the first stroke drag is unreliable for automatic drag updating machines, torque can't be used when drag indicates it is unreliable for these machines
+ if (deltaTimeSlopeAbove(minimumRecoverySlope.weighedAverage()) && (torqueAbsent() || (rowerSettings.autoAdjustDragFactor && !drag.reliable()))) {
return true
} else {
return false
@@ -249,23 +272,7 @@ function createFlywheel (rowerSettings) {
}
function isPowered () {
- if ((deltaTimeSlopeBelow(minumumRecoverySlope.clean()) && torquePresent()) || _deltaTime.length() < flankLength) {
- return true
- } else {
- return false
- }
- }
-
- function deltaTimesAbove (threshold) {
- if (_deltaTime.numberOfYValuesAbove(threshold) === flankLength) {
- return true
- } else {
- return false
- }
- }
-
- function deltaTimesEqualorBelow (threshold) {
- if (_deltaTime.numberOfYValuesEqualOrBelow(threshold) === flankLength) {
+ if (deltaTimeSlopeBelow(minimumRecoverySlope.weighedAverage()) && torquePresent()) {
return true
} else {
return false
@@ -276,6 +283,7 @@ function createFlywheel (rowerSettings) {
// This is a typical indication that the flywheel is accelerating. We use the slope of successive currentDt's
// A (more) negative slope indicates a powered flywheel. When set to 0, it determines whether the DeltaT's are decreasing
// When set to a value below 0, it will become more stringent. In automatic, a percentage of the current slope (i.e. dragfactor) is used
+ // Please note, as this acceleration isn't linear, _deltaTime.goodnessOfFit() will not be good by definition, so we need omit it
if (_deltaTime.slope() < threshold && _deltaTime.length() >= flankLength) {
return true
} else {
@@ -296,8 +304,8 @@ function createFlywheel (rowerSettings) {
}
function torquePresent () {
- // This is a typical indication that the flywheel is decelerating which might work on some machines: successive currentDt's are increasing
- if (_torqueAtBeginFlank > minumumTorqueBeforeStroke) {
+ // This is a typical indication that the flywheel is accelerating: the torque is above a certain threshold (so a force is present on the handle)
+ if (_torqueAtBeginFlank >= minimumTorqueBeforeStroke) {
return true
} else {
return false
@@ -305,8 +313,11 @@ function createFlywheel (rowerSettings) {
}
function torqueAbsent () {
- // This is a typical indication that the flywheel is Accelerating which might work on some machines: successive currentDt's are decreasing
- if (_torqueAtBeginFlank < minumumTorqueBeforeStroke) {
+ // This is a typical indication that the flywheel is decelerating: the torque is below a certain threshold (so a force is absent on the handle)
+ // We need to consider the situation rowerSettings.autoAdjustDragFactor && !drag.reliable() as a high default dragfactor (as set via config) blocks the
+ // detection of the first recovery based on Torque, and thus the calculation of the true dragfactor in that setting.
+ // This let the recovery detection fall back onto slope-based stroke detection only for the first stroke (until drag is calculated reliably)
+ if (_torqueAtBeginFlank < minimumTorqueBeforeStroke) {
return true
} else {
return false
@@ -329,6 +340,10 @@ function createFlywheel (rowerSettings) {
currentCleanTime = 0
currentRawTime = 0
currentAngularDistance = 0
+ _angularVelocityMatrix = null
+ _angularVelocityMatrix = []
+ _angularAccelerationMatrix = null
+ _angularAccelerationMatrix = []
_deltaTime.push(0, 0)
_angularDistance.push(0, 0)
_deltaTimeBeforeFlank = 0
@@ -351,11 +366,11 @@ function createFlywheel (rowerSettings) {
angularAcceleration,
torque,
dragFactor,
+ dragFactorIsReliable,
isDwelling,
isAboveMinimumSpeed,
isUnpowered,
- isPowered
+ isPowered,
+ reset
}
}
-
-export { createFlywheel }
diff --git a/app/engine/Flywheel.test.js b/app/engine/Flywheel.test.js
index 9e2276ca28..0fa485eb45 100644
--- a/app/engine/Flywheel.test.js
+++ b/app/engine/Flywheel.test.js
@@ -1,33 +1,36 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
import { test } from 'uvu'
import * as assert from 'uvu/assert'
import { deepMerge } from '../tools/Helper.js'
-import { replayRowingSession } from '../tools/RowingRecorder.js'
+import { replayRowingSession } from '../recorders/RowingReplayer.js'
import rowerProfiles from '../../config/rowerProfiles.js'
import { createFlywheel } from './Flywheel.js'
-const baseConfig = {
+const baseConfig = { // Based on Concept 2 settings, as this is the validation system
numOfImpulsesPerRevolution: 6,
- smoothing: 1,
- flankLength: 11,
- minimumStrokeQuality: 0.30,
- minumumRecoverySlope: 0,
- autoAdjustRecoverySlope: true,
- autoAdjustRecoverySlopeMargin: 0.10,
- minumumForceBeforeStroke: 50,
- minimumRecoveryTime: 2,
- minimumTimeBetweenImpulses: 0.005,
- maximumTimeBetweenImpulses: 0.02,
+ sprocketRadius: 1.4,
+ maximumStrokeTimeBeforePause: 6.0,
+ dragFactor: 110,
autoAdjustDragFactor: true,
+ minimumDragQuality: 0.95,
dragFactorSmoothing: 3,
- dragFactor: 100,
- minimumDragQuality: 0.83,
- flywheelInertia: 0.1,
- sprocketRadius: 2
+ minimumTimeBetweenImpulses: 0.005,
+ maximumTimeBetweenImpulses: 0.020,
+ flankLength: 12,
+ smoothing: 1,
+ minimumStrokeQuality: 0.36,
+ minimumForceBeforeStroke: 10,
+ minimumRecoverySlope: 0.00070,
+ autoAdjustRecoverySlope: true,
+ autoAdjustRecoverySlopeMargin: 0.15,
+ minimumDriveTime: 0.40,
+ minimumRecoveryTime: 0.90,
+ flywheelInertia: 0.1031,
+ magicConstant: 2.8
}
// Test behaviour for no datapoints
@@ -39,20 +42,17 @@ test('Correct Flywheel behaviour at initialisation', () => {
testAngularVelocity(flywheel, 0)
testAngularAcceleration(flywheel, 0)
testTorque(flywheel, 0)
- testDragFactor(flywheel, 0.0001)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, false)
- testIsPowered(flywheel, true)
+ testIsPowered(flywheel, false)
})
// Test behaviour for one datapoint
-// ToDo: Add additional test for testing the behaviour after a single datapoint
// Test behaviour for perfect upgoing flank
-// ToDo: Add additional test to test isDwelling, isUnpowered and isPowered with an upgoing flank
// Test behaviour for perfect downgoing flank
-// ToDo: Add additional test to test isDwelling, isUnpowered and isPowered with an downgoing flank
// Test behaviour for perfect stroke
test('Correct Flywheel behaviour for a noisefree stroke', () => {
@@ -64,10 +64,10 @@ test('Correct Flywheel behaviour for a noisefree stroke', () => {
testAngularVelocity(flywheel, 0)
testAngularAcceleration(flywheel, 0)
testTorque(flywheel, 0)
- testDragFactor(flywheel, 0.0001)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, false)
- testIsPowered(flywheel, true)
+ testIsPowered(flywheel, false)
flywheel.pushValue(0.011221636)
flywheel.pushValue(0.011175504)
flywheel.pushValue(0.01116456)
@@ -87,13 +87,13 @@ test('Correct Flywheel behaviour for a noisefree stroke', () => {
flywheel.pushValue(0.010526151)
flywheel.pushValue(0.010511225)
flywheel.pushValue(0.010386684)
- testDeltaTime(flywheel, 0.011051853)
- testSpinningTime(flywheel, 0.088970487)
- testAngularPosition(flywheel, 9.42477796076938)
- testAngularVelocity(flywheel, 95.27559080008358)
- testAngularAcceleration(flywheel, 23.690349229418256)
- testTorque(flywheel, 3.276778743172323)
- testDragFactor(flywheel, 0.0001)
+ testDeltaTime(flywheel, 0.011062297)
+ testSpinningTime(flywheel, 0.077918634)
+ testAngularPosition(flywheel, 8.377580409572781)
+ testAngularVelocity(flywheel, 94.77498684553687)
+ testAngularAcceleration(flywheel, 28.980405331480235)
+ testTorque(flywheel, 3.975932584148498)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, false)
testIsPowered(flywheel, true)
@@ -112,13 +112,13 @@ test('Correct Flywheel behaviour for a noisefree stroke', () => {
flywheel.pushValue(0.011099509)
flywheel.pushValue(0.011131862)
flywheel.pushValue(0.011209919)
- testDeltaTime(flywheel, 0.01089567)
- testSpinningTime(flywheel, 0.24984299900000007)
- testAngularPosition(flywheel, 25.132741228718345)
- testAngularVelocity(flywheel, 96.63189639573201)
- testAngularAcceleration(flywheel, -28.68758647905641)
- testTorque(flywheel, -1.9349863078020926)
- testDragFactor(flywheel, 0.0001)
+ testDeltaTime(flywheel, 0.010722165)
+ testSpinningTime(flywheel, 0.23894732900000007)
+ testAngularPosition(flywheel, 24.085543677521745)
+ testAngularVelocity(flywheel, 97.12541571421204)
+ testAngularAcceleration(flywheel, -29.657604177526746)
+ testTorque(flywheel, -2.0200308891605716)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, true)
testIsPowered(flywheel, false)
@@ -137,32 +137,27 @@ test('Correct Flywheel behaviour for a noisefree stroke', () => {
flywheel.pushValue(0.021099509)
flywheel.pushValue(0.021131862)
flywheel.pushValue(0.021209919)
- testDeltaTime(flywheel, 0.02089567)
- testSpinningTime(flywheel, 0.45433115300000004)
- testAngularPosition(flywheel, 40.84070449666731)
- testAngularVelocity(flywheel, 50.44417826920988)
- testAngularAcceleration(flywheel, -25.426721357529768)
- testTorque(flywheel, -2.2882106236273945)
- testDragFactor(flywheel, 0.0001)
+ testDeltaTime(flywheel, 0.020722165)
+ testSpinningTime(flywheel, 0.43343548300000007)
+ testAngularPosition(flywheel, 39.79350694547071)
+ testAngularVelocity(flywheel, 50.85265548983507)
+ testAngularAcceleration(flywheel, -159.89027501034317)
+ testTorque(flywheel, -16.20022817082592)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, true)
testIsUnpowered(flywheel, true)
testIsPowered(flywheel, false)
})
// Test behaviour for noisy upgoing flank
-// ToDo: Add additional test to test isDwelling, isUnpowered and isPowered with an upgoing flank
// Test behaviour for noisy downgoing flank
-// ToDo: Add additional test to test isDwelling, isUnpowered and isPowered with an downgoing flank
// Test behaviour for noisy stroke
-// ToDo: Add additional test to test isDwelling, isUnpowered and isPowered with an upgoing and downgoing flank
// Test drag factor calculation
-// ToDo: Add additional test to test dragfactor calculation
// Test Dynamic stroke detection
-// ToDo: Add additional test to test isDwelling, isUnpowered and isPowered with an upgoing and downgoing flank with dynamic stroke detection
// Test behaviour for not maintaining metrics
test('Correct Flywheel behaviour at maintainStateOnly', () => {
@@ -174,10 +169,10 @@ test('Correct Flywheel behaviour at maintainStateOnly', () => {
testAngularVelocity(flywheel, 0)
testAngularAcceleration(flywheel, 0)
testTorque(flywheel, 0)
- testDragFactor(flywheel, 0.0001)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, false)
- testIsPowered(flywheel, true)
+ testIsPowered(flywheel, false)
flywheel.maintainStateOnly()
flywheel.pushValue(0.011221636)
flywheel.pushValue(0.011175504)
@@ -204,7 +199,7 @@ test('Correct Flywheel behaviour at maintainStateOnly', () => {
testAngularVelocity(flywheel, 0)
testAngularAcceleration(flywheel, 0)
testTorque(flywheel, 0)
- testDragFactor(flywheel, 0.0001)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, false)
testIsPowered(flywheel, true)
@@ -229,7 +224,7 @@ test('Correct Flywheel behaviour at maintainStateOnly', () => {
testAngularVelocity(flywheel, 0)
testAngularAcceleration(flywheel, 0)
testTorque(flywheel, 0)
- testDragFactor(flywheel, 0.0001)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, true)
testIsPowered(flywheel, false)
@@ -240,9 +235,9 @@ test('Correct Flywheel behaviour with a SportsTech WRX700', async () => {
testSpinningTime(flywheel, 0)
testAngularPosition(flywheel, 0)
testDragFactor(flywheel, (rowerProfiles.Sportstech_WRX700.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
// Inject 16 strokes
- flywheel.maintainStateAndMetrics()
await replayRowingSession(flywheel.pushValue, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
testSpinningTime(flywheel, 46.302522627)
testAngularPosition(flywheel, 741.4158662471912)
@@ -254,9 +249,9 @@ test('Correct Flywheel behaviour with a DKN R-320', async () => {
testSpinningTime(flywheel, 0)
testAngularPosition(flywheel, 0)
testDragFactor(flywheel, (rowerProfiles.DKN_R320.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
// Inject 10 strokes
- flywheel.maintainStateAndMetrics()
await replayRowingSession(flywheel.pushValue, { filename: 'recordings/DKNR320.csv', realtime: false, loop: false })
testSpinningTime(flywheel, 22.249536391000003)
@@ -270,13 +265,13 @@ test('Correct Flywheel behaviour with a NordicTrack RX800', async () => {
testSpinningTime(flywheel, 0)
testAngularPosition(flywheel, 0)
testDragFactor(flywheel, (rowerProfiles.NordicTrack_RX800.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
// Inject 10 strokes
- flywheel.maintainStateAndMetrics()
await replayRowingSession(flywheel.pushValue, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
- testSpinningTime(flywheel, 22.65622640199999)
- testAngularPosition(flywheel, 1446.7034169780998)
+ testSpinningTime(flywheel, 22.612226401999987)
+ testAngularPosition(flywheel, 1443.5618243245099)
// As we don't detect strokes here (this is a function of Rower.js, the dragcalculation shouldn't be triggered
testDragFactor(flywheel, (rowerProfiles.NordicTrack_RX800.dragFactor / 1000000))
})
@@ -286,24 +281,40 @@ test('Correct Flywheel behaviour with a full session on a SportsTech WRX700', as
testSpinningTime(flywheel, 0)
testAngularPosition(flywheel, 0)
testDragFactor(flywheel, (rowerProfiles.Sportstech_WRX700.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
// Inject 846 strokes
- flywheel.maintainStateAndMetrics()
await replayRowingSession(flywheel.pushValue, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
- testSpinningTime(flywheel, 2342.741183077012)
- testAngularPosition(flywheel, 37337.82868791469)
+ testSpinningTime(flywheel, 2340.0100514160117)
+ testAngularPosition(flywheel, 37325.26231730033)
// The dragfactor should remain static
testDragFactor(flywheel, (rowerProfiles.Sportstech_WRX700.dragFactor / 1000000))
})
+test('A full session for a Concept2 Model C should produce plausible results', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C))
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testDragFactor(flywheel, (rowerProfiles.Concept2_Model_C.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
+
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ testSpinningTime(flywheel, 181.47141999999985)
+ testAngularPosition(flywheel, 15636.753834467596)
+ // As we don't detect strokes here (this is a function of Rower.js, the dragcalculation shouldn't be triggered
+ testDragFactor(flywheel, (rowerProfiles.Concept2_Model_C.dragFactor / 1000000))
+})
+
test('A full session for a Concept2 RowErg should produce plausible results', async () => {
const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg))
testSpinningTime(flywheel, 0)
testAngularPosition(flywheel, 0)
testDragFactor(flywheel, (rowerProfiles.Concept2_RowErg.dragFactor / 1000000))
-
flywheel.maintainStateAndMetrics()
+
await replayRowingSession(flywheel.pushValue, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
testSpinningTime(flywheel, 591.0432650000008)
testAngularPosition(flywheel, 65961.92655232249)
// As we don't detect strokes here (this is a function of Rower.js, the dragcalculation shouldn't be triggered
@@ -311,7 +322,6 @@ test('A full session for a Concept2 RowErg should produce plausible results', as
})
// Test behaviour after reset
-// ToDo: Add additional test to test isDwelling, isUnpowered and isPowered after a reset
function testDeltaTime (flywheel, expectedValue) {
assert.ok(flywheel.deltaTime() === expectedValue, `deltaTime should be ${expectedValue} sec at ${flywheel.spinningTime()} sec, is ${flywheel.deltaTime()}`)
@@ -353,4 +363,8 @@ function testIsPowered (flywheel, expectedValue) {
assert.ok(flywheel.isPowered() === expectedValue, `isPowered should be ${expectedValue} at ${flywheel.spinningTime()} sec, is ${flywheel.isPowered()}`)
}
+function reportAll (flywheel) { // eslint-disable-line no-unused-vars
+ assert.ok(0, `deltaTime: ${flywheel.deltaTime()}, spinningTime: ${flywheel.spinningTime()}, ang. pos: ${flywheel.angularPosition()}, ang. vel: ${flywheel.angularVelocity()}, Ang. acc: ${flywheel.angularAcceleration()}, Torque: ${flywheel.torque()}, DF: ${flywheel.dragFactor()}`)
+}
+
test.run()
diff --git a/app/engine/Rower.js b/app/engine/Rower.js
index 82f73e2523..6857fb7f55 100644
--- a/app/engine/Rower.js
+++ b/app/engine/Rower.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
The Rowing Engine models the physics of a real rowing boat.
It takes impulses from the flywheel of a rowing machine and estimates
@@ -10,25 +10,25 @@
Physics of Rowing by Anu Dudhia: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics
Also Dave Vernooy has some good explanations here: https://dvernooy.github.io/projects/ergware
*/
-
+/* eslint-disable max-lines -- There is a lot of state machine dependent math going on here. Hard to keep short while maintaining readability */
import loglevel from 'loglevel'
import { createFlywheel } from './Flywheel.js'
import { createCurveMetrics } from './utils/curveMetrics.js'
const log = loglevel.getLogger('RowingEngine')
-function createRower (rowerSettings) {
+export function createRower (rowerSettings) {
const flywheel = createFlywheel(rowerSettings)
const sprocketRadius = rowerSettings.sprocketRadius / 100
- const driveHandleForce = createCurveMetrics(2)
- const driveHandleVelocity = createCurveMetrics(3)
- const driveHandlePower = createCurveMetrics(1)
+ const driveHandleForce = createCurveMetrics()
+ const driveHandleVelocity = createCurveMetrics()
+ const driveHandlePower = createCurveMetrics()
let _strokeState = 'WaitingForDrive'
let _totalNumberOfStrokes = -1.0
let recoveryPhaseStartTime = 0.0
- let _recoveryDuration = 0.0
+ let _recoveryDuration
let drivePhaseStartTime = 0.0
- let _driveDuration = 0.0
+ let _driveDuration
let drivePhaseStartAngularPosition = 0.0
let drivePhaseAngularDisplacement = 0.0
let _driveLinearDistance = 0.0
@@ -36,13 +36,15 @@ function createRower (rowerSettings) {
let recoveryPhaseAngularDisplacement = 0.0
let _recoveryLinearDistance = 0.0
const minimumCycleDuration = rowerSettings.minimumDriveTime + rowerSettings.minimumRecoveryTime
- let _cycleDuration = minimumCycleDuration
- let _cycleLinearVelocity = 0.0
- let _cyclePower = 0.0
+ let _cycleDuration
+ let _cycleLinearVelocity
+ let _cyclePower
let totalLinearDistance = 0.0
let preliminaryTotalLinearDistance = 0.0
let _driveLength = 0.0
+ flywheel.maintainStateOnly()
+
// called if the sensor detected an impulse, currentDt is an interval in seconds
function handleRotationImpulse (currentDt) {
// Provide the flywheel with new data
@@ -53,19 +55,32 @@ function createRower (rowerSettings) {
case (_strokeState === 'Stopped'):
// We are in a stopped state, so don't do anything
break
- case (_strokeState === 'WaitingForDrive' && flywheel.isPowered() && flywheel.isAboveMinimumSpeed()):
- // We change into the "Drive" phase since were waiting for a drive phase, and we see a clear force exerted on the flywheel
+ case (_strokeState === 'WaitingForDrive' && flywheel.isAboveMinimumSpeed() && flywheel.isPowered()):
+ // We are above the minimum speed, so we can leave the WaitingForDrive state
// As we are not certain what caused the "WaitingForDrive", we explicitly start the flywheel maintaining metrics again
- log.debug(`*** Rowing (re)started with a DRIVE phase at time: ${flywheel.spinningTime().toFixed(4)} sec`)
flywheel.maintainStateAndMetrics()
+ // We change into the "Drive" phase since were waiting for a drive phase, and we see a clear force exerted on the flywheel
+ log.debug(`*** Rowing (re)started with a DRIVE phase at time: ${flywheel.spinningTime().toFixed(4)} sec`)
_strokeState = 'Drive'
startDrivePhase()
break
+ case (_strokeState === 'WaitingForDrive' && flywheel.isAboveMinimumSpeed() && flywheel.isUnpowered()):
+ // We are above the minimum speed, so we can leave the WaitingForDrive state
+ // As we are not certain what caused the "WaitingForDrive", we explicitly start the flywheel maintaining metrics again
+ flywheel.maintainStateAndMetrics()
+ // We change into the "REcovery" phase, as somehow there is a force exerted on the flywheel consistent with a dragforce
+ // We need to update the _totalNumberOfStrokes manually as startDrivePhase() normally does this
+ log.debug(`*** Rowing (re)started with a RECOVERY phase at time: ${flywheel.spinningTime().toFixed(4)} sec`)
+ _totalNumberOfStrokes++
+ _strokeState = 'Recovery'
+ startRecoveryPhase()
+ break
case (_strokeState === 'WaitingForDrive'):
// We can't change into the "Drive" phase since we are waiting for a drive phase, but there isn't a clear force exerted on the flywheel. So, there is nothing more to do
break
- case (_strokeState === 'Drive' && ((flywheel.spinningTime() - drivePhaseStartTime) >= rowerSettings.minimumDriveTime) && flywheel.isUnpowered()):
+ case (_strokeState === 'Drive' && ((flywheel.spinningTime() - drivePhaseStartTime) >= rowerSettings.minimumDriveTime || _totalNumberOfStrokes < 1) && flywheel.isUnpowered()):
// We change into the "Recovery" phase since we have been long enough in the Drive phase, and we see a clear lack of power exerted on the flywheel
+ // In the first stroke, we might not exceed the minimumdrivetime in the first stroke, so we shouldn't allow it to limit us.
log.debug(`*** RECOVERY phase started at time: ${flywheel.spinningTime().toFixed(4)} sec`)
_strokeState = 'Recovery'
endDrivePhase()
@@ -73,7 +88,7 @@ function createRower (rowerSettings) {
break
case (_strokeState === 'Drive' && flywheel.isUnpowered()):
// We seem to have lost power to the flywheel, but it is too early according to the settings. We stay in the Drive Phase
- log.debug(`Time: ${flywheel.spinningTime().toFixed(4)} sec: Delta Time trend is upwards, suggests no power, but waiting for for drive phase length (${(flywheel.spinningTime() - drivePhaseStartTime).toFixed(4)} sec) to exceed minimumDriveTime (${rowerSettings.minimumDriveTime} sec)`)
+ log.debug(`Time: ${flywheel.spinningTime().toFixed(4)} sec: Delta Time trend is upwards, suggests no power, but waiting for drive phase length (${(flywheel.spinningTime() - drivePhaseStartTime).toFixed(4)} sec) to exceed minimumDriveTime (${rowerSettings.minimumDriveTime} sec)`)
updateDrivePhase()
break
case (_strokeState === 'Drive'):
@@ -140,16 +155,22 @@ function createRower (rowerSettings) {
function endDrivePhase () {
// Here, we conclude the Drive Phase
- // The FSM guarantees that we have a credible driveDuration and cycletime
+ // The FSM guarantees that we have a credible driveDuration and cycletime in normal operation, but NOT at the start
_driveDuration = flywheel.spinningTime() - drivePhaseStartTime
- _cycleDuration = _recoveryDuration + _driveDuration
drivePhaseAngularDisplacement = flywheel.angularPosition() - drivePhaseStartAngularPosition
_driveLength = drivePhaseAngularDisplacement * sprocketRadius
_driveLinearDistance = calculateLinearDistance(drivePhaseAngularDisplacement, _driveDuration)
totalLinearDistance += _driveLinearDistance
- _cyclePower = calculateCyclePower()
- _cycleLinearVelocity = calculateLinearVelocity(drivePhaseAngularDisplacement + recoveryPhaseAngularDisplacement, _cycleDuration)
preliminaryTotalLinearDistance = totalLinearDistance
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ _cycleDuration = _recoveryDuration + _driveDuration
+ _cycleLinearVelocity = calculateLinearVelocity(drivePhaseAngularDisplacement + recoveryPhaseAngularDisplacement, _cycleDuration)
+ _cyclePower = calculateCyclePower()
+ } else {
+ _cycleDuration = undefined
+ _cycleLinearVelocity = undefined
+ _cyclePower = undefined
+ }
}
function startRecoveryPhase () {
@@ -168,23 +189,29 @@ function createRower (rowerSettings) {
function endRecoveryPhase () {
// First, we conclude the recovery phase
- // The FSM guarantees that we have a credible recoveryDuration and cycletime
+ // The FSM guarantees that we have a credible recoveryDuration and cycletime in normal operation, but NOT at the start
+ flywheel.markRecoveryPhaseCompleted() // This MUST be executed before the dragfactor is used in any calculation here!
_recoveryDuration = flywheel.spinningTime() - recoveryPhaseStartTime
- _cycleDuration = _recoveryDuration + _driveDuration
recoveryPhaseAngularDisplacement = flywheel.angularPosition() - recoveryPhaseStartAngularPosition
_recoveryLinearDistance = calculateLinearDistance(recoveryPhaseAngularDisplacement, _recoveryDuration)
totalLinearDistance += _recoveryLinearDistance
preliminaryTotalLinearDistance = totalLinearDistance
- _cycleLinearVelocity = calculateLinearVelocity(drivePhaseAngularDisplacement + recoveryPhaseAngularDisplacement, _cycleDuration)
- _cyclePower = calculateCyclePower()
- flywheel.markRecoveryPhaseCompleted()
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ _cycleDuration = _recoveryDuration + _driveDuration
+ _cycleLinearVelocity = calculateLinearVelocity(drivePhaseAngularDisplacement + recoveryPhaseAngularDisplacement, _cycleDuration)
+ _cyclePower = calculateCyclePower()
+ } else {
+ _cycleDuration = undefined
+ _cycleLinearVelocity = undefined
+ _cyclePower = undefined
+ }
}
function calculateLinearDistance (baseAngularDisplacement, baseTime) {
if (baseAngularDisplacement >= 0) {
return Math.pow((flywheel.dragFactor() / rowerSettings.magicConstant), 1.0 / 3.0) * baseAngularDisplacement
} else {
- log.error(`Time: ${flywheel.spinningTime().toFixed(4)} sec: calculateLinearDistance error: baseAngularDisplacement was not credible, baseTime: ${baseAngularDisplacement}`)
+ log.error(`Time: ${flywheel.spinningTime().toFixed(4)} sec: calculateLinearDistance error: Angular Displacement of ${baseAngularDisplacement} was not credible, baseTime = ${baseTime}`)
return 0
}
}
@@ -235,59 +262,114 @@ function createRower (rowerSettings) {
}
function cycleDuration () {
- return _cycleDuration
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _cycleDuration
+ } else {
+ return undefined
+ }
}
function cycleLinearDistance () {
- return _driveLinearDistance + _recoveryLinearDistance
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _driveLinearDistance + _recoveryLinearDistance
+ } else {
+ return undefined
+ }
}
function cycleLinearVelocity () {
- return _cycleLinearVelocity
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _cycleLinearVelocity
+ } else {
+ return undefined
+ }
}
function cyclePower () {
- return _cyclePower
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _cyclePower
+ } else {
+ return undefined
+ }
}
-
function driveDuration () {
- return _driveDuration
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return _driveDuration
+ } else {
+ return undefined
+ }
}
function driveLinearDistance () {
- return _driveLinearDistance
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return _driveLinearDistance
+ } else {
+ return undefined
+ }
}
function driveLength () {
- return _driveLength
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return _driveLength
+ } else {
+ return undefined
+ }
}
function driveAverageHandleForce () {
- return driveHandleForce.average()
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandleForce.average()
+ } else {
+ return undefined
+ }
}
function drivePeakHandleForce () {
- return driveHandleForce.peak()
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandleForce.peak()
+ } else {
+ return undefined
+ }
}
function driveHandleForceCurve () {
- return driveHandleForce.curve()
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandleForce.curve()
+ } else {
+ return undefined
+ }
}
function driveHandleVelocityCurve () {
- return driveHandleVelocity.curve()
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandleVelocity.curve()
+ } else {
+ return undefined
+ }
}
function driveHandlePowerCurve () {
- return driveHandlePower.curve()
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandlePower.curve()
+ } else {
+ return undefined
+ }
}
function recoveryDuration () {
- return _recoveryDuration
+ if (_recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _recoveryDuration
+ } else {
+ return undefined
+ }
}
function recoveryDragFactor () {
- return flywheel.dragFactor() * 1000000
+ if (flywheel.dragFactorIsReliable()) {
+ return flywheel.dragFactor() * 1000000
+ } else {
+ return undefined
+ }
}
function instantHandlePower () {
@@ -299,8 +381,11 @@ function createRower (rowerSettings) {
}
function allowMovement () {
- log.debug(`*** ALLOW MOVEMENT command by RowingEngine recieved at time: ${flywheel.spinningTime().toFixed(4)} sec`)
- _strokeState = 'WaitingForDrive'
+ if (_strokeState === 'Stopped') {
+ // We have to check whether there actually was a stop/pause, in order to prevent weird behaviour from the state machine
+ log.debug(`*** ALLOW MOVEMENT command by RowingEngine recieved at time: ${flywheel.spinningTime().toFixed(4)} sec`)
+ _strokeState = 'WaitingForDrive'
+ }
}
function pauseMoving () {
@@ -317,7 +402,10 @@ function createRower (rowerSettings) {
function reset () {
_strokeState = 'WaitingForDrive'
- flywheel.maintainStateOnly()
+ flywheel.reset()
+ driveHandleForce.reset()
+ driveHandleVelocity.reset()
+ driveHandlePower.reset()
_totalNumberOfStrokes = -1.0
drivePhaseStartTime = 0.0
drivePhaseStartAngularPosition = 0.0
@@ -365,5 +453,3 @@ function createRower (rowerSettings) {
reset
}
}
-
-export { createRower }
diff --git a/app/engine/Rower.test.js b/app/engine/Rower.test.js
index 9e32ab5be7..938d255988 100644
--- a/app/engine/Rower.test.js
+++ b/app/engine/Rower.test.js
@@ -1,40 +1,42 @@
'use strict'
/*
-
- This test is a test of the Rower object, that tests wether this object fills all fields correctly, given one validated rower, (the
- Concept2 RowErg) using a validated cycle of strokes. This thoroughly tests the raw physics of the translation of Angular physics
- to Linear physics. The combination with all possible known rowers is tested when testing the above function RowingStatistics, as
- these statistics are dependent on these settings as well.
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
+/**
+ * This test is a test of the Rower object, that tests wether this object fills all fields correctly, given one validated rower, (the
+ * Concept2 RowErg) using a validated cycle of strokes. This thoroughly tests the raw physics of the translation of Angular physics
+ * to Linear physics. The combination with all possible known rowers is tested when testing the above function RowingStatistics, as
+ * these statistics are dependent on these settings as well.
+ */
import { test } from 'uvu'
import * as assert from 'uvu/assert'
import rowerProfiles from '../../config/rowerProfiles.js'
-import { replayRowingSession } from '../tools/RowingRecorder.js'
+import { replayRowingSession } from '../recorders/RowingReplayer.js'
import { deepMerge } from '../tools/Helper.js'
import { createRower } from './Rower.js'
-const baseConfig = {
+const baseConfig = { // Based on Concept 2 settings, as this is the validation system
numOfImpulsesPerRevolution: 6,
- smoothing: 1,
- flankLength: 11,
- minimumStrokeQuality: 0.30,
- minumumRecoverySlope: 0,
- autoAdjustRecoverySlope: true,
- autoAdjustRecoverySlopeMargin: 0.10,
- minumumForceBeforeStroke: 50,
- minimumRecoveryTime: 0.9,
- minimumDriveTime: 0.4,
- maximumStrokeTimeBeforePause: 6.0,
- minimumTimeBetweenImpulses: 0.005,
- maximumTimeBetweenImpulses: 0.02,
+ sprocketRadius: 1.4,
+ maximumStrokeTimeBeforePause: 0.3, // Modification to standard settings to shorten test cases
+ dragFactor: 110,
autoAdjustDragFactor: true,
+ minimumDragQuality: 0.95,
dragFactorSmoothing: 3,
- dragFactor: 100,
- minimumDragQuality: 0.83,
- flywheelInertia: 0.1,
- magicConstant: 2.8,
- sprocketRadius: 2
+ minimumTimeBetweenImpulses: 0.005,
+ maximumTimeBetweenImpulses: 0.017,
+ flankLength: 12,
+ smoothing: 1,
+ minimumStrokeQuality: 0.36,
+ minimumForceBeforeStroke: 20, // Modification to standard settings to shorten test cases
+ minimumRecoverySlope: 0.00070,
+ autoAdjustRecoverySlope: false, // Modification to standard settings to shorten test cases
+ autoAdjustRecoverySlopeMargin: 0.04,
+ minimumDriveTime: 0.04, // Modification to standard settings to shorten test cases
+ minimumRecoveryTime: 0.09, // Modification to standard settings to shorten test cases
+ flywheelInertia: 0.10138,
+ magicConstant: 2.8
}
// Test behaviour for no datapoints
@@ -44,68 +46,43 @@ test('Correct rower behaviour at initialisation', () => {
testTotalMovingTimeSinceStart(rower, 0)
testTotalNumberOfStrokes(rower, 0)
testTotalLinearDistanceSinceStart(rower, 0)
- testCycleDuration(rower, 1.3)
- testCycleLinearDistance(rower, 0)
- testCycleLinearVelocity(rower, 0)
- testCyclePower(rower, 0)
- testDriveDuration(rower, 0)
- testDriveLinearDistance(rower, 0)
- testDriveLength(rower, 0)
- testDriveAverageHandleForce(rower, 0)
- testDrivePeakHandleForce(rower, 0)
- testRecoveryDuration(rower, 0)
- testRecoveryDragFactor(rower, 100)
+ testCycleDuration(rower, undefined) // Default value
+ testCycleLinearDistance(rower, undefined)
+ testCycleLinearVelocity(rower, undefined)
+ testCyclePower(rower, undefined)
+ testDriveDuration(rower, undefined)
+ testDriveLinearDistance(rower, undefined)
+ testDriveLength(rower, undefined)
+ testDriveAverageHandleForce(rower, undefined)
+ testDrivePeakHandleForce(rower, undefined)
+ testRecoveryDuration(rower, undefined)
+ testRecoveryDragFactor(rower, undefined)
testInstantHandlePower(rower, 0)
})
-// Test behaviour for one series of datapoint
-// ToDo: add detailed test with a series of datapoints describng a complete stroke
+// Test behaviour for one datapoint
// Test behaviour for three perfect identical strokes, including settingling behaviour of metrics
-test('Correct Rower behaviour for three noisefree strokes with dynamic dragfactor and stroke detection', () => {
- const specificConfig = {
- numOfImpulsesPerRevolution: 6,
- smoothing: 1,
- flankLength: 11,
- minimumStrokeQuality: 0.30,
- minumumRecoverySlope: 0,
- autoAdjustRecoverySlope: true,
- autoAdjustRecoverySlopeMargin: 0.10,
- minumumForceBeforeStroke: 50,
- minimumDriveTime: 0.1,
- minimumRecoveryTime: 0.2,
- maximumStrokeTimeBeforePause: 0.2,
- minimumTimeBetweenImpulses: 0.005,
- maximumTimeBetweenImpulses: 0.02,
- autoAdjustDragFactor: true,
- dragFactorSmoothing: 3,
- dragFactor: 100,
- minimumDragQuality: 0.83,
- flywheelInertia: 0.1,
- magicConstant: 2.8,
- sprocketRadius: 2
- }
-
- const rower = createRower(specificConfig)
+test('Test behaviour for three perfect identical strokes, including settingling behaviour of metrics', () => {
+ const rower = createRower(baseConfig)
testStrokeState(rower, 'WaitingForDrive')
testTotalMovingTimeSinceStart(rower, 0)
testTotalLinearDistanceSinceStart(rower, 0)
testTotalNumberOfStrokes(rower, 0)
- testCycleDuration(rower, 0.30000000000000004)
- testCycleLinearDistance(rower, 0)
- testCycleLinearVelocity(rower, 0)
- testCyclePower(rower, 0)
- testDriveDuration(rower, 0)
- testDriveLinearDistance(rower, 0)
- testDriveLength(rower, 0)
- testDriveAverageHandleForce(rower, 0)
- testDrivePeakHandleForce(rower, 0)
- testRecoveryDuration(rower, 0)
- testRecoveryDragFactor(rower, 100)
+ testCycleDuration(rower, undefined) // Default value
+ testCycleLinearDistance(rower, undefined)
+ testCycleLinearVelocity(rower, undefined)
+ testCyclePower(rower, undefined)
+ testDriveDuration(rower, undefined)
+ testDriveLinearDistance(rower, undefined)
+ testDriveLength(rower, undefined)
+ testDriveAverageHandleForce(rower, undefined)
+ testDrivePeakHandleForce(rower, undefined)
+ testRecoveryDuration(rower, undefined)
+ testRecoveryDragFactor(rower, undefined)
testInstantHandlePower(rower, 0)
// Drive initial stroke starts here
rower.handleRotationImpulse(0.011221636)
- testStrokeState(rower, 'WaitingForDrive')
rower.handleRotationImpulse(0.011175504)
rower.handleRotationImpulse(0.01116456)
rower.handleRotationImpulse(0.011130263)
@@ -115,11 +92,8 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.011051853)
rower.handleRotationImpulse(0.010973313)
rower.handleRotationImpulse(0.010919756)
- testStrokeState(rower, 'WaitingForDrive')
rower.handleRotationImpulse(0.01086431)
- testStrokeState(rower, 'Drive')
rower.handleRotationImpulse(0.010800864)
- testStrokeState(rower, 'Drive')
rower.handleRotationImpulse(0.010956987)
rower.handleRotationImpulse(0.010653396)
rower.handleRotationImpulse(0.010648619)
@@ -128,21 +102,21 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.010511225)
rower.handleRotationImpulse(0.010386684)
testStrokeState(rower, 'Drive')
- testTotalMovingTimeSinceStart(rower, 0.088970487)
- testTotalLinearDistanceSinceStart(rower, 0.27588786257094444)
+ testTotalMovingTimeSinceStart(rower, 0.077918634)
+ testTotalLinearDistanceSinceStart(rower, 0.2491943602992768)
testTotalNumberOfStrokes(rower, 1)
- testCycleDuration(rower, 0.30000000000000004)
- testCycleLinearDistance(rower, 0.27588786257094444)
- testCycleLinearVelocity(rower, 0) // Shouldn't this one be filled after the first drive?
- testCyclePower(rower, 0) // Shouldn't this one be filled after the first drive?
- testDriveDuration(rower, 0) // Shouldn't this one be filled after the first drive?
- testDriveLinearDistance(rower, 0.27588786257094444)
- testDriveLength(rower, 0) // Shouldn't this one be filled after the first drive?
- testDriveAverageHandleForce(rower, 156.05318736972495)
- testDrivePeakHandleForce(rower, 163.83893715861615)
- testRecoveryDuration(rower, 0)
- testRecoveryDragFactor(rower, 100)
- testInstantHandlePower(rower, 312.1970306768984)
+ testCycleDuration(rower, undefined) // still default value
+ testCycleLinearDistance(rower, undefined)
+ testCycleLinearVelocity(rower, undefined) // This isn't filled after the first drive, as we haven't survived a complete cycle yet
+ testCyclePower(rower, undefined) // This isn't filled after the first drive, as we haven't survived a complete cycle yet
+ testDriveDuration(rower, undefined) // This isn't filled after the first drive as it is too short
+ testDriveLinearDistance(rower, undefined)
+ testDriveLength(rower, undefined)
+ testDriveAverageHandleForce(rower, undefined)
+ testDrivePeakHandleForce(rower, undefined)
+ testRecoveryDuration(rower, undefined)
+ testRecoveryDragFactor(rower, undefined)
+ testInstantHandlePower(rower, 372.09477620281604)
// Recovery initial stroke starts here
rower.handleRotationImpulse(0.010769)
rower.handleRotationImpulse(0.010707554)
@@ -160,22 +134,22 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.011131862)
rower.handleRotationImpulse(0.011209919)
testStrokeState(rower, 'Recovery')
- testTotalMovingTimeSinceStart(rower, 0.24984299900000007)
- testTotalLinearDistanceSinceStart(rower, 0.7931776048914653)
+ testTotalMovingTimeSinceStart(rower, 0.23894732900000007)
+ testTotalLinearDistanceSinceStart(rower, 0.7831822752262985)
testTotalNumberOfStrokes(rower, 1)
- testCycleDuration(rower, 0.143485717)
- testCycleLinearDistance(rower, 0.7931776048914653)
- testCycleLinearVelocity(rower, 3.1244766799874912)
- testCyclePower(rower, 0)
+ testCycleDuration(rower, undefined)
+ testCycleLinearDistance(rower, undefined)
+ testCycleLinearVelocity(rower, undefined)
+ testCyclePower(rower, undefined)
testDriveDuration(rower, 0.143485717)
- testDriveLinearDistance(rower, 0.4483177766777847)
- testDriveLength(rower, 0.2722713633111154)
- testDriveAverageHandleForce(rower, 168.33379255795953)
- testDrivePeakHandleForce(rower, 220.19702843648562)
- testRecoveryDuration(rower, 0)
- testRecoveryDragFactor(rower, 100)
+ testDriveLinearDistance(rower, 0.46278952627008546)
+ testDriveLength(rower, 0.19058995431778075)
+ testDriveAverageHandleForce(rower, 276.20193475035796)
+ testDrivePeakHandleForce(rower, 325.1619554833936)
+ testRecoveryDuration(rower, undefined)
+ testRecoveryDragFactor(rower, undefined)
testInstantHandlePower(rower, 0)
- // Drive seconds stroke starts here
+ // Drive second stroke starts here
rower.handleRotationImpulse(0.011221636)
rower.handleRotationImpulse(0.011175504)
rower.handleRotationImpulse(0.01116456)
@@ -196,21 +170,21 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.010511225)
rower.handleRotationImpulse(0.010386684)
testStrokeState(rower, 'Drive')
- testTotalMovingTimeSinceStart(rower, 0.46020725100000004)
- testTotalLinearDistanceSinceStart(rower, 1.519974294345203)
+ testTotalMovingTimeSinceStart(rower, 0.44915539800000004)
+ testTotalLinearDistanceSinceStart(rower, 1.828822466846578)
testTotalNumberOfStrokes(rower, 2)
- testCycleDuration(rower, 0.404798464)
- testCycleLinearDistance(rower, 1.0716565176674184)
- testCycleLinearVelocity(rower, 3.1521398371477467)
- testCyclePower(rower, 87.69492447163606)
+ testCycleDuration(rower, 0.34889498300000005)
+ testCycleLinearDistance(rower, 1.3660329405764926)
+ testCycleLinearVelocity(rower, 4.474643028948317)
+ testCyclePower(rower, 250.86103806520188)
testDriveDuration(rower, 0.143485717)
- testDriveLinearDistance(rower, 0.24399292995458496)
- testDriveLength(rower, 0.2722713633111154)
- testDriveAverageHandleForce(rower, 156.87845718774872)
- testDrivePeakHandleForce(rower, 227.37033987102245)
- testRecoveryDuration(rower, 0.261312747)
- testRecoveryDragFactor(rower, 283.33086731525583)
- testInstantHandlePower(rower, 432.851053772137)
+ testDriveLinearDistance(rower, 0.43908201661387253)
+ testDriveLength(rower, 0.19058995431778075)
+ testDriveAverageHandleForce(rower, 236.59556700196183)
+ testDrivePeakHandleForce(rower, 380.1396336099103)
+ testRecoveryDuration(rower, 0.20540926600000003)
+ testRecoveryDragFactor(rower, 283.12720365097886)
+ testInstantHandlePower(rower, 504.63602120716615)
// Recovery second stroke starts here
rower.handleRotationImpulse(0.010769)
rower.handleRotationImpulse(0.010707554)
@@ -228,20 +202,20 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.011131862)
rower.handleRotationImpulse(0.011209919)
testStrokeState(rower, 'Recovery')
- testTotalMovingTimeSinceStart(rower, 0.6210797630000001)
- testTotalLinearDistanceSinceStart(rower, 2.2519530842089575)
+ testTotalMovingTimeSinceStart(rower, 0.6101840930000001)
+ testTotalLinearDistanceSinceStart(rower, 2.5606258278697)
testTotalNumberOfStrokes(rower, 2)
- testCycleDuration(rower, 0.37123676400000005)
- testCycleLinearDistance(rower, 0.9759717198183395)
- testCycleLinearVelocity(rower, 4.469255430992759)
- testCyclePower(rower, 249.95599708025222)
- testDriveDuration(rower, 0.10992401700000004)
- testDriveLinearDistance(rower, 0.48798585990916965)
- testDriveLength(rower, 0.20943951023931945)
- testDriveAverageHandleForce(rower, 198.7144253754593)
- testDrivePeakHandleForce(rower, 294.92974697493514)
- testRecoveryDuration(rower, 0.261312747)
- testRecoveryDragFactor(rower, 283.33086731525583)
+ testCycleDuration(rower, 0.44526865700000007)
+ testCycleLinearDistance(rower, 1.1708853776369939)
+ testCycleLinearVelocity(rower, 4.492259872066099)
+ testCyclePower(rower, 253.83566752220193)
+ testDriveDuration(rower, 0.23985939100000003)
+ testDriveLinearDistance(rower, 1.0733115961672441)
+ testDriveLength(rower, 0.322536845768552)
+ testDriveAverageHandleForce(rower, 285.0923064376231)
+ testDrivePeakHandleForce(rower, 439.7407274840117)
+ testRecoveryDuration(rower, 0.20540926600000003)
+ testRecoveryDragFactor(rower, 283.12720365097886) // As we decelerate the flywheel quite fast, this is expected
testInstantHandlePower(rower, 0)
// Drive third stroke starts here
rower.handleRotationImpulse(0.011221636)
@@ -264,21 +238,21 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.010511225)
rower.handleRotationImpulse(0.010386684)
testStrokeState(rower, 'Drive')
- testTotalMovingTimeSinceStart(rower, 0.8314440150000004)
- testTotalLinearDistanceSinceStart(rower, 3.17912621803638)
+ testTotalMovingTimeSinceStart(rower, 0.8203921620000004)
+ testTotalLinearDistanceSinceStart(rower, 3.4875767518323193)
testTotalNumberOfStrokes(rower, 3)
- testCycleDuration(rower, 0.3376750640000003)
- testCycleLinearDistance(rower, 1.4151589937365927)
- testCycleLinearVelocity(rower, 4.479916721710978)
- testCyclePower(rower, 251.74905786098182)
- testDriveDuration(rower, 0.10992401700000004)
- testDriveLinearDistance(rower, 0.3903886879273361)
- testDriveLength(rower, 0.20943951023931945)
- testDriveAverageHandleForce(rower, 140.7974193430079)
- testDrivePeakHandleForce(rower, 227.3703398700472)
- testRecoveryDuration(rower, 0.22775104700000026)
- testRecoveryDragFactor(rower, 283.33086731525583)
- testInstantHandlePower(rower, 432.8510537702822)
+ testCycleDuration(rower, 0.3379838680000002)
+ testCycleLinearDistance(rower, 1.0245247054323694)
+ testCycleLinearVelocity(rower, 4.4747508859834575)
+ testCyclePower(rower, 250.8791788061379)
+ testDriveDuration(rower, 0.23985939100000003)
+ testDriveLinearDistance(rower, 0.5854426888184969)
+ testDriveLength(rower, 0.322536845768552)
+ testDriveAverageHandleForce(rower, 194.28476369698888)
+ testDrivePeakHandleForce(rower, 380.1396336085015)
+ testRecoveryDuration(rower, 0.09812447700000015)
+ testRecoveryDragFactor(rower, 283.12720365097886)
+ testInstantHandlePower(rower, 504.63602120535336)
// Recovery third stroke starts here
rower.handleRotationImpulse(0.010769)
rower.handleRotationImpulse(0.010707554)
@@ -296,20 +270,20 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.011131862)
rower.handleRotationImpulse(0.011209919)
testStrokeState(rower, 'Recovery')
- testTotalMovingTimeSinceStart(rower, 0.9923165270000005)
- testTotalLinearDistanceSinceStart(rower, 3.911105007900135)
+ testTotalMovingTimeSinceStart(rower, 0.9814208570000005)
+ testTotalLinearDistanceSinceStart(rower, 4.219380112855441)
testTotalNumberOfStrokes(rower, 3)
testCycleDuration(rower, 0.3712367640000004)
- testCycleLinearDistance(rower, 1.122367477791091)
- testCycleLinearVelocity(rower, 4.469255430992756)
- testCyclePower(rower, 249.95599708025168)
- testDriveDuration(rower, 0.14348571700000012)
- testDriveLinearDistance(rower, 0.634381617881921)
- testDriveLength(rower, 0.2722713633111155)
- testDriveAverageHandleForce(rower, 177.72502014311627)
- testDrivePeakHandleForce(rower, 294.9297469748562)
- testRecoveryDuration(rower, 0.22775104700000026)
- testRecoveryDragFactor(rower, 283.33086731525583)
+ testCycleLinearDistance(rower, 1.3172460498416183)
+ testCycleLinearVelocity(rower, 4.46818431211662)
+ testCyclePower(rower, 249.77632391313173)
+ testDriveDuration(rower, 0.27311228700000023)
+ testDriveLinearDistance(rower, 1.2196722683718688)
+ testDriveLength(rower, 0.3665191429188092)
+ testDriveAverageHandleForce(rower, 254.91449219500532)
+ testDrivePeakHandleForce(rower, 439.74072748282515)
+ testRecoveryDuration(rower, 0.09812447700000015)
+ testRecoveryDragFactor(rower, 283.12720365097886)
testInstantHandlePower(rower, 0)
// Dwelling state starts here
rower.handleRotationImpulse(0.020769)
@@ -328,40 +302,40 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.021131862)
rower.handleRotationImpulse(0.021209919)
testStrokeState(rower, 'WaitingForDrive')
- testTotalMovingTimeSinceStart(rower, 1.1137102920000004)
+ testTotalMovingTimeSinceStart(rower, 1.1344792920000004)
testTotalNumberOfStrokes(rower, 3)
- testTotalLinearDistanceSinceStart(rower, 4.447889453800221)
- testCycleDuration(rower, 0.37123676400000005)
- testCycleLinearDistance(rower, 1.6591519236911776)
- testCycleLinearVelocity(rower, 4.469255430992759)
- testCyclePower(rower, 249.95599708025233)
- testDriveDuration(rower, 0.14348571700000012)
- testDriveLinearDistance(rower, 0.634381617881921)
- testDriveLength(rower, 0.2722713633111155)
- testDriveAverageHandleForce(rower, 177.72502014311627)
- testDrivePeakHandleForce(rower, 294.9297469748562)
- testRecoveryDuration(rower, 0.22775104699999993)
- testRecoveryDragFactor(rower, 283.33086731525583)
+ testTotalLinearDistanceSinceStart(rower, 4.8536096924088135)
+ testCycleDuration(rower, 0.4476004410000002)
+ testCycleLinearDistance(rower, 1.9514756293949902)
+ testCycleLinearVelocity(rower, 4.359860828186694)
+ testCyclePower(rower, 232.0469744651364)
+ testDriveDuration(rower, 0.27311228700000023)
+ testDriveLinearDistance(rower, 1.2196722683718688)
+ testDriveLength(rower, 0.3665191429188092)
+ testDriveAverageHandleForce(rower, 254.91449219500532)
+ testDrivePeakHandleForce(rower, 439.74072748282515)
+ testRecoveryDuration(rower, 0.17448815399999995)
+ testRecoveryDragFactor(rower, 283.12720365097886)
testInstantHandlePower(rower, 0)
})
// Test behaviour for noisy upgoing flank
-// ToDo: add detailed test with a series of datapoints describng a complete upgoing flank
// Test behaviour for noisy downgoing flank
-// ToDo: add detailed test with a series of datapoints describng a complete downgoing flank
// Test behaviour for noisy stroke
-// ToDo: add detailed test with a series of datapoints describng a complete upgoing and downgoing flank
+
+// Test behaviour after reset
+
+// Test behaviour for one datapoint
+
+// Test behaviour for noisy stroke
// Test drag factor calculation
-// ToDo: add a test to test the dragfactor calculation (can be reused from Flywheel.test.js)
// Test Dynamic stroke detection
-// ToDo: add a test to test the dynamic stroke detection (can be reused from Flywheel.test.js)
// Test behaviour after reset
-// ToDo: add detailed test with a series of datapoints followed by a reset
// Test behaviour with real-life data
@@ -375,7 +349,7 @@ test('sample data for Sportstech WRX700 should produce plausible results', async
await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
testTotalMovingTimeSinceStart(rower, 46.302522627)
- testTotalLinearDistanceSinceStart(rower, 166.2959671641673)
+ testTotalLinearDistanceSinceStart(rower, 166.29596716416734)
testTotalNumberOfStrokes(rower, 16)
// As dragFactor is static, it should remain in place
testRecoveryDragFactor(rower, rowerProfiles.Sportstech_WRX700.dragFactor)
@@ -386,6 +360,7 @@ test('sample data for DKN R-320 should produce plausible results', async () => {
testTotalMovingTimeSinceStart(rower, 0)
testTotalLinearDistanceSinceStart(rower, 0)
testTotalNumberOfStrokes(rower, 0)
+ // As dragFactor is static, it should be known at initialisation
testRecoveryDragFactor(rower, rowerProfiles.DKN_R320.dragFactor)
await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/DKNR320.csv', realtime: false, loop: false })
@@ -402,15 +377,15 @@ test('sample data for NordicTrack RX800 should produce plausible results', async
testTotalMovingTimeSinceStart(rower, 0)
testTotalLinearDistanceSinceStart(rower, 0)
testTotalNumberOfStrokes(rower, 0)
- testRecoveryDragFactor(rower, rowerProfiles.NordicTrack_RX800.dragFactor)
+ testRecoveryDragFactor(rower, undefined)
await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
- testTotalMovingTimeSinceStart(rower, 17.389910236000024)
- testTotalLinearDistanceSinceStart(rower, 62.052936751782944)
- testTotalNumberOfStrokes(rower, 8)
+ testTotalMovingTimeSinceStart(rower, 22.259092749999997)
+ testTotalLinearDistanceSinceStart(rower, 80.49260485116434)
+ testTotalNumberOfStrokes(rower, 10)
// As dragFactor is dynamic, it should have changed
- testRecoveryDragFactor(rower, 486.702741763346)
+ testRecoveryDragFactor(rower, 491.1395313462149)
})
test('A full session for SportsTech WRX700 should produce plausible results', async () => {
@@ -422,27 +397,43 @@ test('A full session for SportsTech WRX700 should produce plausible results', as
await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
- testTotalMovingTimeSinceStart(rower, 2342.241183077012)
- testTotalLinearDistanceSinceStart(rower, 8408.914799199298)
+ testTotalMovingTimeSinceStart(rower, 2340.0100514160117)
+ testTotalLinearDistanceSinceStart(rower, 8406.791871958883)
testTotalNumberOfStrokes(rower, 846)
// As dragFactor is static, it should remain in place
testRecoveryDragFactor(rower, rowerProfiles.Sportstech_WRX700.dragFactor)
})
+test('A full session for a Concept2 Model C should produce plausible results', async () => {
+ const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C))
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ testRecoveryDragFactor(rower, undefined)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 181.47141999999985)
+ testTotalLinearDistanceSinceStart(rower, 552.0863658667265)
+ testTotalNumberOfStrokes(rower, 84)
+ // As dragFactor isn't static, it should have changed
+ testRecoveryDragFactor(rower, 123.82587294279575)
+})
+
test('A full session for a Concept2 RowErg should produce plausible results', async () => {
const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg))
testTotalMovingTimeSinceStart(rower, 0)
testTotalLinearDistanceSinceStart(rower, 0)
testTotalNumberOfStrokes(rower, 0)
- testRecoveryDragFactor(rower, rowerProfiles.Concept2_RowErg.dragFactor)
+ testRecoveryDragFactor(rower, undefined)
await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
- testTotalMovingTimeSinceStart(rower, 590.4201840000001)
- testTotalLinearDistanceSinceStart(rower, 2030.6574002852396)
+ testTotalMovingTimeSinceStart(rower, 590.111937)
+ testTotalLinearDistanceSinceStart(rower, 2027.493082238415)
testTotalNumberOfStrokes(rower, 206)
- // As dragFactor isn't static, it should be updated
- testRecoveryDragFactor(rower, 80.81243631988698)
+ // As dragFactor isn't static, it should have changed
+ testRecoveryDragFactor(rower, 80.60573080009686)
})
function testStrokeState (rower, expectedValue) {
@@ -510,10 +501,8 @@ function testInstantHandlePower (rower, expectedValue) {
assert.ok(rower.instantHandlePower() === expectedValue, `instantHandlePower should be ${expectedValue} Watt at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.instantHandlePower()}`)
}
-/*
-function reportAll (rower) {
- assert.ok(0, `time: ${rower.totalMovingTimeSinceStart()}, state ${rower.strokeState()}, No Strokes: ${rower.totalNumberOfStrokes()}, Lin Distance: ${rower.totalLinearDistanceSinceStart()}, cycle dur: ${rower.cycleDuration()}, cycle Lin Dist: ${rower.cycleLinearDistance()}, Lin Velocity: ${rower.cycleLinearVelocity()}, Power: ${rower.cyclePower()}, Drive Dur: ${rower.driveDuration()}, Drive Lin. Dist. ${rower.driveLinearDistance()}, Drive Length: ${rower.driveLength()}, Av. Handle Force: ${rower.driveAverageHandleForce()}, Peak Handle Force: ${rower.drivePeakHandleForce()}, Rec. Dur: ${rower.recoveryDuration()}, Dragfactor: ${rower.recoveryDragFactor()}, Inst Handle Power: ${rower.instantHandlePower()}`)
+function reportAll (rower) { // eslint-disable-line no-unused-vars
+ assert.ok(0, `time: ${rower.totalMovingTimeSinceStart()}, state ${rower.strokeState()}, No Strokes: ${rower.totalNumberOfStrokes() + 1}, Lin Distance: ${rower.totalLinearDistanceSinceStart()}, cycle dur: ${rower.cycleDuration()}, cycle Lin Dist: ${rower.cycleLinearDistance()}, Lin Velocity: ${rower.cycleLinearVelocity()}, Power: ${rower.cyclePower()}, Drive Dur: ${rower.driveDuration()}, Drive Lin. Dist. ${rower.driveLinearDistance()}, Drive Length: ${rower.driveLength()}, Av. Handle Force: ${rower.driveAverageHandleForce()}, Peak Handle Force: ${rower.drivePeakHandleForce()}, Rec. Dur: ${rower.recoveryDuration()}, Dragfactor: ${rower.recoveryDragFactor()}, Inst Handle Power: ${rower.instantHandlePower()}`)
}
-*/
test.run()
diff --git a/app/engine/RowingStatistics.js b/app/engine/RowingStatistics.js
index 8249b3d56f..44ff977070 100644
--- a/app/engine/RowingStatistics.js
+++ b/app/engine/RowingStatistics.js
@@ -1,10 +1,10 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This Module calculates the training specific metrics.
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
-import { EventEmitter } from 'events'
+/**
+ * This Module creates a persistent, consistent and user presentable set of metrics.
+ */
import { createRower } from './Rower.js'
import { createOLSLinearSeries } from './utils/OLSLinearSeries.js'
import { createStreamFilter } from './utils/StreamFilter.js'
@@ -13,165 +13,57 @@ import { createCurveAligner } from './utils/CurveAligner.js'
import loglevel from 'loglevel'
const log = loglevel.getLogger('RowingEngine')
-function createRowingStatistics (config, session) {
- const numOfDataPointsForAveraging = config.numOfPhasesForAveragingScreenData
- const webUpdateInterval = Math.min(config.webUpdateInterval, 2000)
- const peripheralUpdateInterval = Math.min(config.peripheralUpdateInterval, 1000)
- const emitter = new EventEmitter()
+export function createRowingStatistics (config) {
+ const numOfDataPointsForAveraging = config.numOfPhasesForAveragingScreenData // Used for metrics updated twice per cycle
+ const halfNumOfDataPointsForAveraging = Math.round(numOfDataPointsForAveraging / 2) // Used for metrics updated twice per cycle
const rower = createRower(config.rowerSettings)
const minimumStrokeTime = config.rowerSettings.minimumRecoveryTime + config.rowerSettings.minimumDriveTime
const maximumStrokeTime = config.rowerSettings.maximumStrokeTimeBeforePause
- const cycleDuration = createStreamFilter(numOfDataPointsForAveraging, (minimumStrokeTime + maximumStrokeTime) / 2)
- const cycleDistance = createStreamFilter(numOfDataPointsForAveraging, 0)
- const cyclePower = createStreamFilter(numOfDataPointsForAveraging, 0)
- const cycleLinearVelocity = createStreamFilter(numOfDataPointsForAveraging, 0)
- let sessionStatus = 'WaitingForStart'
- let heartrateResetTimer
+ const cycleDuration = createStreamFilter(numOfDataPointsForAveraging, undefined)
+ const cycleDistance = createStreamFilter(numOfDataPointsForAveraging, undefined)
+ const cyclePower = createStreamFilter(numOfDataPointsForAveraging, undefined)
+ const cycleLinearVelocity = createStreamFilter(numOfDataPointsForAveraging, undefined)
+ let metricsContext
let totalLinearDistance = 0.0
let totalMovingTime = 0
- let totalNumberOfStrokes = 0
+ let totalNumberOfStrokes = -1
let driveLastStartTime = 0
let strokeCalories = 0
let strokeWork = 0
const calories = createOLSLinearSeries()
- const distanceOverTime = createOLSLinearSeries(Math.min(4, numOfDataPointsForAveraging))
- const driveDuration = createStreamFilter(numOfDataPointsForAveraging, config.rowerSettings.minimumDriveTime)
- const driveLength = createStreamFilter(numOfDataPointsForAveraging, 1.1)
- const driveDistance = createStreamFilter(numOfDataPointsForAveraging, 3)
- const recoveryDuration = createStreamFilter(numOfDataPointsForAveraging, config.rowerSettings.minimumRecoveryTime)
- const driveAverageHandleForce = createStreamFilter(numOfDataPointsForAveraging, 0.0)
- const drivePeakHandleForce = createStreamFilter(numOfDataPointsForAveraging, 0.0)
- const driveHandleForceCurve = createCurveAligner(config.rowerSettings.minumumForceBeforeStroke)
+ const driveDuration = createStreamFilter(halfNumOfDataPointsForAveraging, undefined)
+ const driveLength = createStreamFilter(halfNumOfDataPointsForAveraging, undefined)
+ const driveDistance = createStreamFilter(halfNumOfDataPointsForAveraging, undefined)
+ const recoveryDuration = createStreamFilter(halfNumOfDataPointsForAveraging, undefined)
+ const driveAverageHandleForce = createStreamFilter(halfNumOfDataPointsForAveraging, undefined)
+ const drivePeakHandleForce = createStreamFilter(halfNumOfDataPointsForAveraging, undefined)
+ const driveHandleForceCurve = createCurveAligner(config.rowerSettings.minimumForceBeforeStroke)
const driveHandleVelocityCurve = createCurveAligner(1.0)
const driveHandlePowerCurve = createCurveAligner(50)
- let dragFactor = config.rowerSettings.dragFactor
- let heartrate = 0
- let heartrateBatteryLevel = 0
- const postExerciseHR = []
+ let dragFactor
let instantPower = 0.0
let lastStrokeState = 'WaitingForDrive'
- // send metrics to the web clients periodically
- setInterval(emitWebMetrics, webUpdateInterval)
-
- // notify bluetooth peripherall each second (even if data did not change)
- // todo: the FTMS protocol also supports that peripherals deliver a preferred update interval
- // we could respect this and set the update rate accordingly
- setInterval(emitPeripheralMetrics, peripheralUpdateInterval)
+ resetMetricsContext()
- function handleRotationImpulse (currentDt) {
- // Provide the rower with new data
- rower.handleRotationImpulse(currentDt)
-
- // This is the core of the finite state machine that defines all state transitions
- switch (true) {
- case (sessionStatus === 'WaitingForStart' && rower.strokeState() === 'Drive'):
- sessionStatus = 'Rowing'
- startTraining()
- updateContinousMetrics()
- emitMetrics('recoveryFinished')
- break
- case (sessionStatus === 'Paused' && rower.strokeState() === 'Drive'):
- sessionStatus = 'Rowing'
- resumeTraining()
- updateContinousMetrics()
- emitMetrics('recoveryFinished')
- break
- case (sessionStatus !== 'Stopped' && rower.strokeState() === 'Stopped'):
- sessionStatus = 'Stopped'
- // We need to emit the metrics AFTER the sessionstatus changes to anything other than "Rowing", which forces most merics to zero
- // This is intended behaviour, as the rower/flywheel indicate the rower has stopped somehow
- stopTraining()
- break
- case (sessionStatus === 'Rowing' && rower.strokeState() === 'WaitingForDrive'):
- sessionStatus = 'Paused'
- pauseTraining()
- break
- case (sessionStatus === 'Rowing' && lastStrokeState === 'Recovery' && rower.strokeState() === 'Drive' && intervalTargetReached()):
- updateContinousMetrics()
- updateCycleMetrics()
- handleRecoveryEnd()
- emitMetrics('intervalTargetReached')
- break
- case (sessionStatus === 'Rowing' && lastStrokeState === 'Recovery' && rower.strokeState() === 'Drive'):
- updateContinousMetrics()
- updateCycleMetrics()
- handleRecoveryEnd()
- emitMetrics('recoveryFinished')
- break
- case (sessionStatus === 'Rowing' && lastStrokeState === 'Drive' && rower.strokeState() === 'Recovery' && intervalTargetReached()):
- updateContinousMetrics()
- updateCycleMetrics()
- handleDriveEnd()
- emitMetrics('intervalTargetReached')
- break
- case (sessionStatus === 'Rowing' && lastStrokeState === 'Drive' && rower.strokeState() === 'Recovery'):
- updateContinousMetrics()
- updateCycleMetrics()
- handleDriveEnd()
- emitMetrics('driveFinished')
- break
- case (sessionStatus === 'Rowing' && intervalTargetReached()):
- updateContinousMetrics()
- emitMetrics('intervalTargetReached')
- break
- case (sessionStatus === 'Rowing'):
- updateContinousMetrics()
- break
- case (sessionStatus === 'Paused'):
- // We are in a paused state, we won't update any metrics
- break
- case (sessionStatus === 'WaitingForStart'):
- // We can't change into the "Rowing" state since we are waiting for a drive phase that didn't come
- break
- case (sessionStatus === 'Stopped'):
- // We are in a stopped state, so we won't update any metrics
- break
- default:
- log.error(`Time: ${rower.totalMovingTimeSinceStart()}, state ${rower.strokeState()} found in the Rowing Statistics, which is not captured by Finite State Machine`)
- }
- lastStrokeState = rower.strokeState()
- }
-
- function startTraining () {
- rower.allowMovement()
- }
-
- function allowResumeTraining () {
- rower.allowMovement()
- sessionStatus = 'WaitingForStart'
- }
-
- function resumeTraining () {
+ function allowStartOrResumeTraining () {
rower.allowMovement()
}
function stopTraining () {
rower.stopMoving()
lastStrokeState = 'Stopped'
- // Emitting the metrics BEFORE the sessionstatus changes to anything other than "Rowing" forces most merics to zero
- // As there are more than one way to this method, we FIRST emit the metrics and then set them to zero
- // If they need to be forced to zero (as the flywheel seems to have stopped), this status has to be set before the call
- emitMetrics('rowingStopped')
- sessionStatus = 'Stopped'
- postExerciseHR.splice(0, postExerciseHR.length)
- measureRecoveryHR()
}
// clear the metrics in case the user pauses rowing
function pauseTraining () {
- log.debug('*** Paused rowing ***')
rower.pauseMoving()
+ metricsContext.isMoving = false
cycleDuration.reset()
cycleDistance.reset()
cyclePower.reset()
cycleLinearVelocity.reset()
lastStrokeState = 'WaitingForDrive'
- // We need to emit the metrics BEFORE the sessionstatus changes to anything other than "Rowing", as it forces most merics to zero
- emitMetrics('rowingPaused')
- sessionStatus = 'Paused'
- postExerciseHR.splice(0, postExerciseHR.length)
- measureRecoveryHR()
}
function resetTraining () {
@@ -183,18 +75,104 @@ function createRowingStatistics (config, session) {
totalLinearDistance = 0.0
totalNumberOfStrokes = -1
driveLastStartTime = 0
- distanceOverTime.reset()
driveDuration.reset()
+ recoveryDuration.reset()
+ driveLength.reset()
+ driveDistance.reset()
+ driveAverageHandleForce.reset()
+ drivePeakHandleForce.reset()
+ driveHandleForceCurve.reset()
+ driveHandleVelocityCurve.reset()
+ driveHandlePowerCurve.reset()
cycleDuration.reset()
cycleDistance.reset()
cyclePower.reset()
strokeCalories = 0
strokeWork = 0
- postExerciseHR.splice(0, postExerciseHR.length)
cycleLinearVelocity.reset()
lastStrokeState = 'WaitingForDrive'
- emitMetrics('rowingPaused')
- sessionStatus = 'WaitingForStart'
+ resetMetricsContext()
+ }
+
+ /**
+ * Calculates the linear metrics based on a currentDt
+ *
+ * @param {float} time between two impulses in seconds
+ *
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/new-ble-api/docs/Architecture.md#rowingstatisticsjs|the architecture description}
+ */
+ function handleRotationImpulse (currentDt) {
+ // Provide the rower with new data
+ rower.handleRotationImpulse(currentDt)
+
+ resetMetricsContext()
+
+ // This is the core of the finite state machine that defines all state transitions
+ switch (true) {
+ case (lastStrokeState === 'WaitingForDrive' && rower.strokeState() === 'Drive'):
+ updateContinousMetrics()
+ metricsContext.isMoving = true
+ metricsContext.isDriveStart = true
+ break
+ case (lastStrokeState === 'WaitingForDrive' && rower.strokeState() === 'Recovery'):
+ updateContinousMetrics()
+ metricsContext.isMoving = true
+ metricsContext.isRecoveryStart = true
+ break
+ case (lastStrokeState === 'WaitingForDrive'):
+ // We can't change into the "Rowing" state since we are waiting for a drive phase that didn't come
+ metricsContext.isMoving = false // This has the disired side-effect that the many of the reported instanous metrics are zero-ed
+ break
+ case (lastStrokeState !== 'Stopped' && rower.strokeState() === 'Stopped'):
+ metricsContext.isMoving = false // This has the disired side-effect that the many of the reported instanous metrics are zero-ed
+ // This is intended behaviour, as the rower/flywheel indicate the rower has stopped somehow. So zero-ing all metrics fits that state better then a last know good state
+ break
+ case (lastStrokeState === 'Stopped'):
+ metricsContext.isMoving = false
+ // We are in a stopped state, so we won't update any metrics
+ // This is a permanent state, regardless of current action of the flywheel
+ break
+ case (lastStrokeState !== 'WaitingForDrive' && rower.strokeState() === 'WaitingForDrive'):
+ metricsContext.isMoving = false // This has the desired side-effect that the many of the reported instanous metrics are zero-ed
+ // Please note, the sessionmanager will trigger a pause based on this condition
+ break
+ // From this point on, we can be certain that the LastStrokeState and rower.strokeState() aren't 'Stopped' or 'WaitingForDrive', so we are processing an active stroke
+ case (lastStrokeState === 'Recovery' && rower.strokeState() === 'Drive'):
+ updateContinousMetrics()
+ updateCycleMetrics()
+ handleRecoveryEnd()
+ metricsContext.isMoving = true
+ metricsContext.isDriveStart = true
+ break
+ case (lastStrokeState === 'Recovery' && rower.strokeState() === 'Recovery'):
+ updateContinousMetrics()
+ metricsContext.isMoving = true
+ break
+ case (lastStrokeState === 'Drive' && rower.strokeState() === 'Recovery'):
+ updateContinousMetrics()
+ updateCycleMetrics()
+ handleDriveEnd()
+ metricsContext.isMoving = true
+ metricsContext.isRecoveryStart = true
+ break
+ case (lastStrokeState === 'Drive' && rower.strokeState() === 'Drive'):
+ updateContinousMetrics()
+ metricsContext.isMoving = true
+ break
+ default:
+ log.error(`Time: ${rower.totalMovingTimeSinceStart()}, combination of last stroke state ${lastStrokeState} and state ${rower.strokeState()} found in the Rowing Statistics, which is not captured by Finite State Machine`)
+ }
+ lastStrokeState = rower.strokeState()
+ return allMetrics()
+ }
+
+ // Basic metricContext structure
+ function resetMetricsContext () {
+ metricsContext = {
+ isMoving: false,
+ isDriveStart: false,
+ isRecoveryStart: false
+ }
}
// initiated when updating key statistics
@@ -205,8 +183,7 @@ function createRowingStatistics (config, session) {
}
function updateCycleMetrics () {
- distanceOverTime.push(rower.totalMovingTimeSinceStart(), rower.totalLinearDistanceSinceStart())
- if (rower.cycleDuration() < maximumStrokeTime && rower.cycleDuration() > minimumStrokeTime) {
+ if (rower.cycleDuration() !== undefined && rower.cycleDuration() < maximumStrokeTime && rower.cycleDuration() > minimumStrokeTime && totalNumberOfStrokes > 0) {
// stroke duration has to be credible to be accepted
cycleDuration.push(rower.cycleDuration())
cycleDistance.push(rower.cycleLinearDistance())
@@ -218,133 +195,76 @@ function createRowingStatistics (config, session) {
}
function handleDriveEnd () {
- driveDuration.push(rower.driveDuration())
- driveLength.push(rower.driveLength())
- driveDistance.push(rower.driveLinearDistance())
- driveAverageHandleForce.push(rower.driveAverageHandleForce())
- drivePeakHandleForce.push(rower.drivePeakHandleForce())
- driveHandleForceCurve.push(rower.driveHandleForceCurve())
- driveHandleVelocityCurve.push(rower.driveHandleVelocityCurve())
- driveHandlePowerCurve.push(rower.driveHandlePowerCurve())
+ if (rower.driveDuration() !== undefined) {
+ driveDuration.push(rower.driveDuration())
+ driveLength.push(rower.driveLength())
+ driveDistance.push(rower.driveLinearDistance())
+ driveAverageHandleForce.push(rower.driveAverageHandleForce())
+ drivePeakHandleForce.push(rower.drivePeakHandleForce())
+ driveHandleForceCurve.push(rower.driveHandleForceCurve())
+ driveHandleVelocityCurve.push(rower.driveHandleVelocityCurve())
+ driveHandlePowerCurve.push(rower.driveHandlePowerCurve())
+ }
}
// initiated when the stroke state changes
function handleRecoveryEnd () {
totalNumberOfStrokes = rower.totalNumberOfStrokes()
driveLastStartTime = rower.driveLastStartTime()
- recoveryDuration.push(rower.recoveryDuration())
- dragFactor = rower.recoveryDragFactor()
-
- // based on: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics/ergometer.html#section11
- strokeCalories = (4 * cyclePower.clean() + 350) * (cycleDuration.clean()) / 4200
- strokeWork = cyclePower.clean() * cycleDuration.clean()
- const totalCalories = calories.yAtSeriesEnd() + strokeCalories
- calories.push(totalMovingTime, totalCalories)
- }
-
- // initiated when a new heart rate value is received from heart rate sensor
- function handleHeartrateMeasurement (value) {
- // set the heart rate to zero if we did not receive a value for some time
- if (heartrateResetTimer)clearInterval(heartrateResetTimer)
- heartrateResetTimer = setTimeout(() => {
- heartrate = 0
- heartrateBatteryLevel = 0
- }, 6000)
- heartrate = value.heartrate
- heartrateBatteryLevel = value.batteryLevel
- }
-
- function intervalTargetReached () {
- if ((session.targetDistance > 0 && rower.totalLinearDistanceSinceStart() >= session.targetDistance) || (session.targetTime > 0 && rower.totalMovingTimeSinceStart() >= session.targetTime)) {
- return true
+ if (rower.recoveryDuration() !== undefined) {
+ recoveryDuration.push(rower.recoveryDuration())
+ }
+ if (rower.recoveryDuration() !== undefined && rower.recoveryDragFactor() !== undefined) {
+ dragFactor = rower.recoveryDragFactor()
} else {
- return false
+ dragFactor = undefined
}
- }
- function measureRecoveryHR () {
- // This function is called when the rowing session is stopped. postExerciseHR[0] is the last measured excercise HR
- // Thus postExerciseHR[1] is Recovery HR after 1 min, etc..
- if (heartrate !== undefined && heartrate > config.userSettings.restingHR && sessionStatus !== 'Rowing') {
- log.debug(`*** HRR-${postExerciseHR.length}: ${heartrate}`)
- postExerciseHR.push(heartrate)
- if ((postExerciseHR.length > 1) && (postExerciseHR.length <= 4)) {
- // We skip reporting postExerciseHR[0] and only report measuring postExerciseHR[1], postExerciseHR[2], postExerciseHR[3]
- emitter.emit('HRRecoveryUpdate', postExerciseHR)
- }
- if (postExerciseHR.length < 4) {
- // We haven't got three post-exercise HR measurements yet, let's schedule the next measurement
- setTimeout(measureRecoveryHR, 60000)
- }
+ if (cyclePower.reliable() && cycleDuration.reliable()) {
+ // ToDo: see if this can be made part of the continuousmatrcs as Garmin and Concept2 also have a 'calories' type of training
+ // based on: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics/ergometer.html#section11
+ strokeCalories = (4 * cyclePower.clean() + 350) * (cycleDuration.clean()) / 4200
+ strokeWork = cyclePower.clean() * cycleDuration.clean()
+ const totalCalories = calories.Y.atSeriesEnd() + strokeCalories
+ calories.push(totalMovingTime, totalCalories)
}
}
- function emitWebMetrics () {
- emitMetrics('webMetricsUpdate')
- }
-
- function emitPeripheralMetrics () {
- emitMetrics('peripheralMetricsUpdate')
- }
-
- function emitMetrics (emitType = 'webMetricsUpdate') {
- emitter.emit(emitType, getMetrics())
- }
-
- function getMetrics () {
- const cyclePace = cycleLinearVelocity.clean() !== 0 && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? (500.0 / cycleLinearVelocity.clean()) : Infinity
+ /* eslint-disable complexity -- As this is the central metric being delivered to all consumers, who need to accept this at face value, we need a lot of defensive coding */
+ function allMetrics () {
+ const cyclePace = cycleLinearVelocity.clean() !== 0 && cycleLinearVelocity.raw() > 0 && metricsContext.isMoving === true ? (500.0 / cycleLinearVelocity.clean()) : Infinity
return {
- sessiontype: session.targetDistance > 0 ? 'Distance' : (session.targetTime > 0 ? 'Time' : 'JustRow'),
- sessionStatus,
+ metricsContext,
strokeState: rower.strokeState(),
totalMovingTime: totalMovingTime > 0 ? totalMovingTime : 0,
- driveLastStartTime: driveLastStartTime > 0 ? driveLastStartTime : 0,
- totalMovingTimeFormatted: session.targetTime > 0 ? secondsToTimeString(Math.round(Math.max(session.targetTime - totalMovingTime, 0))) : secondsToTimeString(Math.round(totalMovingTime)),
totalNumberOfStrokes: totalNumberOfStrokes > 0 ? totalNumberOfStrokes : 0,
totalLinearDistance: totalLinearDistance > 0 ? totalLinearDistance : 0, // meters
- totalLinearDistanceFormatted: session.targetDistance > 0 ? Math.max(session.targetDistance - totalLinearDistance, 0) : totalLinearDistance,
strokeCalories: strokeCalories > 0 ? strokeCalories : 0, // kCal
strokeWork: strokeWork > 0 ? strokeWork : 0, // Joules
- totalCalories: calories.yAtSeriesEnd() > 0 ? calories.yAtSeriesEnd() : 0, // kcal
+ totalCalories: calories.Y.atSeriesEnd() > 0 ? calories.Y.atSeriesEnd() : 0, // kcal
totalCaloriesPerMinute: totalMovingTime > 60 ? caloriesPerPeriod(totalMovingTime - 60, totalMovingTime) : caloriesPerPeriod(0, 60),
totalCaloriesPerHour: totalMovingTime > 3600 ? caloriesPerPeriod(totalMovingTime - 3600, totalMovingTime) : caloriesPerPeriod(0, 3600),
- cycleDuration: cycleDuration.clean() > minimumStrokeTime && cycleDuration.clean() < maximumStrokeTime && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? cycleDuration.clean() : NaN, // seconds
- cycleStrokeRate: cycleDuration.clean() > minimumStrokeTime && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? (60.0 / cycleDuration.clean()) : 0, // strokeRate in SPM
- cycleDistance: cycleDistance.raw() > 0 && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? cycleDistance.clean() : 0, // meters
- cycleLinearVelocity: cycleLinearVelocity.clean() > 0 && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? cycleLinearVelocity.clean() : 0, // m/s
- cyclePace: cycleLinearVelocity.raw() > 0 ? cyclePace : Infinity, // seconds/500m
- cyclePaceFormatted: cycleLinearVelocity.raw() > 0 ? secondsToTimeString(Math.round(cyclePace)) : Infinity,
- cyclePower: cyclePower.clean() > 0 && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? cyclePower.clean() : 0, // watts
- cycleProjectedEndTime: session.targetDistance > 0 ? distanceOverTime.projectY(session.targetDistance) : session.targetTime,
- cycleProjectedEndLinearDistance: session.targetTime > 0 ? distanceOverTime.projectX(session.targetTime) : session.targetDistance,
- driveDuration: driveDuration.clean() >= config.rowerSettings.minimumDriveTime && totalNumberOfStrokes > 0 && sessionStatus === 'Rowing' ? driveDuration.clean() : NaN, // seconds
- driveLength: driveLength.clean() > 0 && sessionStatus === 'Rowing' ? driveLength.clean() : NaN, // meters of chain movement
- driveDistance: driveDistance.clean() >= 0 && sessionStatus === 'Rowing' ? driveDistance.clean() : NaN, // meters
- driveAverageHandleForce: driveAverageHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? driveAverageHandleForce.clean() : NaN,
- drivePeakHandleForce: drivePeakHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? drivePeakHandleForce.clean() : NaN,
- driveHandleForceCurve: drivePeakHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? driveHandleForceCurve.lastCompleteCurve() : [NaN],
- driveHandleVelocityCurve: drivePeakHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? driveHandleVelocityCurve.lastCompleteCurve() : [NaN],
- driveHandlePowerCurve: drivePeakHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? driveHandlePowerCurve.lastCompleteCurve() : [NaN],
- recoveryDuration: recoveryDuration.clean() >= config.rowerSettings.minimumRecoveryTime && totalNumberOfStrokes > 0 && sessionStatus === 'Rowing' ? recoveryDuration.clean() : NaN, // seconds
- dragFactor: dragFactor > 0 ? dragFactor : config.rowerSettings.dragFactor, // Dragfactor
- instantPower: instantPower > 0 && rower.strokeState() === 'Drive' ? instantPower : 0,
- heartrate: heartrate > 30 ? heartrate : undefined,
- heartrateBatteryLevel: heartrateBatteryLevel > 0 ? heartrateBatteryLevel : undefined // BE AWARE, changing undefined to NaN kills the GUI!!!
- }
- }
-
- // converts a timeStamp in seconds to a human readable hh:mm:ss format
- function secondsToTimeString (secondsTimeStamp) {
- if (secondsTimeStamp === Infinity) return '∞'
- const hours = Math.floor(secondsTimeStamp / 60 / 60)
- const minutes = Math.floor(secondsTimeStamp / 60) - (hours * 60)
- const seconds = Math.floor(secondsTimeStamp % 60)
- if (hours > 0) {
- return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
- } else {
- return `${minutes}:${seconds.toString().padStart(2, '0')}`
+ cycleDuration: cycleDuration.reliable() && cycleDuration.clean() > minimumStrokeTime && cycleDuration.clean() < maximumStrokeTime && cycleLinearVelocity.raw() > 0 && totalNumberOfStrokes > 0 && metricsContext.isMoving === true ? cycleDuration.clean() : undefined, // seconds
+ cycleStrokeRate: cycleDuration.reliable() && cycleDuration.clean() > minimumStrokeTime && cycleDuration.clean() < maximumStrokeTime && cycleLinearVelocity.raw() > 0 && totalNumberOfStrokes > 0 && metricsContext.isMoving === true ? (60.0 / cycleDuration.clean()) : undefined, // strokeRate in SPM
+ cycleDistance: cycleDistance.reliable() && cycleDistance.raw() > 0 && cycleLinearVelocity.raw() > 0 && metricsContext.isMoving === true ? cycleDistance.clean() : undefined, // meters
+ cycleLinearVelocity: cycleLinearVelocity.reliable() && cycleLinearVelocity.clean() > 0 && cycleLinearVelocity.raw() > 0 && metricsContext.isMoving === true ? cycleLinearVelocity.clean() : undefined, // m/s
+ cyclePace: cycleLinearVelocity.reliable() && cycleLinearVelocity.clean() > 0 && metricsContext.isMoving === true ? cyclePace : Infinity, // seconds/500m
+ cyclePower: cyclePower.reliable() && cyclePower.clean() > 0 && cycleLinearVelocity.raw() > 0 && metricsContext.isMoving === true ? cyclePower.clean() : undefined, // watts
+ driveLastStartTime: driveLastStartTime > 0 ? driveLastStartTime : 0,
+ driveDuration: driveDuration.reliable() && driveDuration.clean() >= config.rowerSettings.minimumDriveTime && totalNumberOfStrokes > 0 && metricsContext.isMoving === true ? driveDuration.clean() : undefined, // seconds
+ driveLength: driveLength.reliable() && driveLength.clean() > 0 && metricsContext.isMoving === true ? driveLength.clean() : undefined, // meters of chain movement
+ driveDistance: driveDistance.reliable() && driveDistance.clean() >= 0 && metricsContext.isMoving === true ? driveDistance.clean() : undefined, // meters
+ driveAverageHandleForce: driveAverageHandleForce.clean() > 0 && metricsContext.isMoving === true ? driveAverageHandleForce.clean() : undefined,
+ drivePeakHandleForce: drivePeakHandleForce.clean() > 0 && metricsContext.isMoving === true ? drivePeakHandleForce.clean() : undefined,
+ driveHandleForceCurve: drivePeakHandleForce.clean() > 0 && metricsContext.isMoving === true ? driveHandleForceCurve.lastCompleteCurve() : [],
+ driveHandleVelocityCurve: drivePeakHandleForce.clean() > 0 && metricsContext.isMoving === true ? driveHandleVelocityCurve.lastCompleteCurve() : [],
+ driveHandlePowerCurve: drivePeakHandleForce.clean() > 0 && metricsContext.isMoving === true ? driveHandlePowerCurve.lastCompleteCurve() : [],
+ recoveryDuration: recoveryDuration.reliable() && recoveryDuration.clean() >= config.rowerSettings.minimumRecoveryTime && totalNumberOfStrokes > 0 && metricsContext.isMoving === true ? recoveryDuration.clean() : undefined, // seconds
+ dragFactor: dragFactor > 0 ? dragFactor : undefined, // Dragfactor
+ instantPower: instantPower > 0 && rower.strokeState() === 'Drive' ? instantPower : 0
}
}
+ /* eslint-enable complexity */
function caloriesPerPeriod (periodBegin, periodEnd) {
const beginCalories = calories.projectX(periodBegin)
@@ -352,14 +272,12 @@ function createRowingStatistics (config, session) {
return (endCalories - beginCalories)
}
- return Object.assign(emitter, {
- handleHeartrateMeasurement,
+ return {
handleRotationImpulse,
- pause: pauseTraining,
- stop: stopTraining,
- resume: allowResumeTraining,
- reset: resetTraining
- })
+ allowStartOrResumeTraining,
+ stopTraining,
+ pauseTraining,
+ resetTraining,
+ getMetrics: allMetrics
+ }
}
-
-export { createRowingStatistics }
diff --git a/app/engine/RowingStatistics.test.js b/app/engine/RowingStatistics.test.js
new file mode 100644
index 0000000000..7a39617b4c
--- /dev/null
+++ b/app/engine/RowingStatistics.test.js
@@ -0,0 +1,569 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This test is a test of the Rower object, that tests wether this object fills all fields correctly, given one validated rower, (the
+ * Concept2 RowErg) using a validated cycle of strokes. This thoroughly tests the raw physics of the translation of Angular physics
+ * to Linear physics. The combination with all possible known rowers is tested when testing the above function RowingStatistics, as
+ * these statistics are dependent on these settings as well.
+*/
+// ToDo: test the effects of smoothing parameters
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+import rowerProfiles from '../../config/rowerProfiles.js'
+import { replayRowingSession } from '../recorders/RowingReplayer.js'
+import { deepMerge } from '../tools/Helper.js'
+
+import { createRowingStatistics } from './RowingStatistics.js'
+
+const baseConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: { // Based on Concept 2 settings, as this is the validation system
+ numOfImpulsesPerRevolution: 6,
+ sprocketRadius: 1.4,
+ maximumStrokeTimeBeforePause: 0.3, // Modification to standard settings to shorten test cases
+ dragFactor: 110,
+ autoAdjustDragFactor: true,
+ minimumDragQuality: 0.95,
+ dragFactorSmoothing: 3,
+ minimumTimeBetweenImpulses: 0.005,
+ maximumTimeBetweenImpulses: 0.017,
+ flankLength: 12,
+ smoothing: 1,
+ minimumStrokeQuality: 0.36,
+ minimumForceBeforeStroke: 20, // Modification to standard settings to shorten test cases
+ minimumRecoverySlope: 0.00070,
+ autoAdjustRecoverySlope: false, // Modification to standard settings to shorten test cases
+ autoAdjustRecoverySlopeMargin: 0.04,
+ minimumDriveTime: 0.04, // Modification to standard settings to shorten test cases
+ minimumRecoveryTime: 0.09, // Modification to standard settings to shorten test cases
+ flywheelInertia: 0.10138,
+ magicConstant: 2.8
+ }
+}
+
+// Test behaviour for no datapoints
+test('Correct rower behaviour at initialisation', () => {
+ const rowingStatistics = createRowingStatistics(baseConfig)
+ testStrokeState(rowingStatistics, 'WaitingForDrive')
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testCycleDuration(rowingStatistics, undefined) // Default value
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, undefined)
+ testDriveDistance(rowingStatistics, undefined)
+ testDriveLength(rowingStatistics, undefined)
+ testDriveAverageHandleForce(rowingStatistics, undefined)
+ testDrivePeakHandleForce(rowingStatistics, undefined)
+ testRecoveryDuration(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, undefined)
+ testInstantHandlePower(rowingStatistics, undefined)
+})
+
+// Test behaviour for one datapoint
+
+// Test behaviour for three perfect identical strokes, including settingling behaviour of metrics
+test('Test behaviour for three perfect identical strokes, including settingling behaviour of metrics', () => {
+ const rowingStatistics = createRowingStatistics(baseConfig)
+ testStrokeState(rowingStatistics, 'WaitingForDrive')
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testCycleDuration(rowingStatistics, undefined) // Default value
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, undefined)
+ testDriveDistance(rowingStatistics, undefined)
+ testDriveLength(rowingStatistics, undefined)
+ testDriveAverageHandleForce(rowingStatistics, undefined)
+ testDrivePeakHandleForce(rowingStatistics, undefined)
+ testRecoveryDuration(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, undefined)
+ testInstantHandlePower(rowingStatistics, undefined)
+ // Drive initial stroke starts here
+ rowingStatistics.handleRotationImpulse(0.011221636)
+ rowingStatistics.handleRotationImpulse(0.011175504)
+ rowingStatistics.handleRotationImpulse(0.01116456)
+ rowingStatistics.handleRotationImpulse(0.011130263)
+ rowingStatistics.handleRotationImpulse(0.011082613)
+ rowingStatistics.handleRotationImpulse(0.011081761)
+ rowingStatistics.handleRotationImpulse(0.011062297)
+ rowingStatistics.handleRotationImpulse(0.011051853)
+ rowingStatistics.handleRotationImpulse(0.010973313)
+ rowingStatistics.handleRotationImpulse(0.010919756)
+ rowingStatistics.handleRotationImpulse(0.01086431)
+ rowingStatistics.handleRotationImpulse(0.010800864)
+ rowingStatistics.handleRotationImpulse(0.010956987)
+ rowingStatistics.handleRotationImpulse(0.010653396)
+ rowingStatistics.handleRotationImpulse(0.010648619)
+ rowingStatistics.handleRotationImpulse(0.010536818)
+ rowingStatistics.handleRotationImpulse(0.010526151)
+ rowingStatistics.handleRotationImpulse(0.010511225)
+ rowingStatistics.handleRotationImpulse(0.010386684)
+ testStrokeState(rowingStatistics, 'Drive')
+ testTotalMovingTime(rowingStatistics, 0.077918634)
+ testTotalLinearDistance(rowingStatistics, 0.2491943602992768)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testCycleDuration(rowingStatistics, undefined) // still default value
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined) // This isn't filled after the first drive, as we haven't survived a complete cycle yet
+ testCyclePower(rowingStatistics, undefined) // This isn't filled after the first drive, as we haven't survived a complete cycle yet
+ testDriveDuration(rowingStatistics, undefined) // Shouldn't this one be filled after the first drive?
+ testDriveDistance(rowingStatistics, undefined)
+ testDriveLength(rowingStatistics, undefined) // Shouldn't this one be filled after the first drive?
+ testDriveAverageHandleForce(rowingStatistics, undefined)
+ testDrivePeakHandleForce(rowingStatistics, undefined)
+ testRecoveryDuration(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, undefined)
+ testInstantHandlePower(rowingStatistics, undefined)
+ // Recovery initial stroke starts here
+ rowingStatistics.handleRotationImpulse(0.010769)
+ rowingStatistics.handleRotationImpulse(0.010707554)
+ rowingStatistics.handleRotationImpulse(0.010722165)
+ rowingStatistics.handleRotationImpulse(0.01089567)
+ rowingStatistics.handleRotationImpulse(0.010917504)
+ rowingStatistics.handleRotationImpulse(0.010997969)
+ rowingStatistics.handleRotationImpulse(0.011004655)
+ rowingStatistics.handleRotationImpulse(0.011013618)
+ rowingStatistics.handleRotationImpulse(0.011058193)
+ rowingStatistics.handleRotationImpulse(0.010807149)
+ rowingStatistics.handleRotationImpulse(0.0110626)
+ rowingStatistics.handleRotationImpulse(0.011090787)
+ rowingStatistics.handleRotationImpulse(0.011099509)
+ rowingStatistics.handleRotationImpulse(0.011131862)
+ rowingStatistics.handleRotationImpulse(0.011209919)
+ testStrokeState(rowingStatistics, 'Recovery')
+ testTotalMovingTime(rowingStatistics, 0.23894732900000007)
+ testTotalLinearDistance(rowingStatistics, 0.7831822752262985)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, undefined)
+ testDriveDistance(rowingStatistics, 0.46278952627008546)
+ testDriveLength(rowingStatistics, 0.19058995431778075)
+ testDriveAverageHandleForce(rowingStatistics, 276.20193475035796)
+ testDrivePeakHandleForce(rowingStatistics, 325.1619554833936)
+ testRecoveryDuration(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, undefined)
+ testInstantHandlePower(rowingStatistics, undefined)
+ // Drive second stroke starts here
+ rowingStatistics.handleRotationImpulse(0.011221636)
+ rowingStatistics.handleRotationImpulse(0.011175504)
+ rowingStatistics.handleRotationImpulse(0.01116456)
+ rowingStatistics.handleRotationImpulse(0.011130263)
+ rowingStatistics.handleRotationImpulse(0.011082613)
+ rowingStatistics.handleRotationImpulse(0.011081761)
+ rowingStatistics.handleRotationImpulse(0.011062297)
+ rowingStatistics.handleRotationImpulse(0.011051853)
+ rowingStatistics.handleRotationImpulse(0.010973313)
+ rowingStatistics.handleRotationImpulse(0.010919756)
+ rowingStatistics.handleRotationImpulse(0.01086431)
+ rowingStatistics.handleRotationImpulse(0.010800864)
+ rowingStatistics.handleRotationImpulse(0.010956987)
+ rowingStatistics.handleRotationImpulse(0.010653396)
+ rowingStatistics.handleRotationImpulse(0.010648619)
+ rowingStatistics.handleRotationImpulse(0.010536818)
+ rowingStatistics.handleRotationImpulse(0.010526151)
+ rowingStatistics.handleRotationImpulse(0.010511225)
+ rowingStatistics.handleRotationImpulse(0.010386684)
+ testStrokeState(rowingStatistics, 'Drive')
+ testTotalMovingTime(rowingStatistics, 0.44915539800000004)
+ testTotalLinearDistance(rowingStatistics, 1.828822466846578)
+ testTotalNumberOfStrokes(rowingStatistics, 1)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, 0.143485717)
+ testDriveDistance(rowingStatistics, 0.46278952627008546)
+ testDriveLength(rowingStatistics, 0.19058995431778075)
+ testDriveAverageHandleForce(rowingStatistics, 276.20193475035796)
+ testDrivePeakHandleForce(rowingStatistics, 325.1619554833936)
+ testRecoveryDuration(rowingStatistics, 0.20540926600000003)
+ testDragFactor(rowingStatistics, 283.12720365097886)
+ testInstantHandlePower(rowingStatistics, undefined)
+ // Recovery second stroke starts here
+ rowingStatistics.handleRotationImpulse(0.010769)
+ rowingStatistics.handleRotationImpulse(0.010707554)
+ rowingStatistics.handleRotationImpulse(0.010722165)
+ rowingStatistics.handleRotationImpulse(0.01089567)
+ rowingStatistics.handleRotationImpulse(0.010917504)
+ rowingStatistics.handleRotationImpulse(0.010997969)
+ rowingStatistics.handleRotationImpulse(0.011004655)
+ rowingStatistics.handleRotationImpulse(0.011013618)
+ rowingStatistics.handleRotationImpulse(0.011058193)
+ rowingStatistics.handleRotationImpulse(0.010807149)
+ rowingStatistics.handleRotationImpulse(0.0110626)
+ rowingStatistics.handleRotationImpulse(0.011090787)
+ rowingStatistics.handleRotationImpulse(0.011099509)
+ rowingStatistics.handleRotationImpulse(0.011131862)
+ rowingStatistics.handleRotationImpulse(0.011209919)
+ testStrokeState(rowingStatistics, 'Recovery')
+ testTotalMovingTime(rowingStatistics, 0.6101840930000001)
+ testTotalLinearDistance(rowingStatistics, 2.5606258278697)
+ testTotalNumberOfStrokes(rowingStatistics, 1)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, 0.23985939100000003)
+ testDriveDistance(rowingStatistics, 1.0733115961672441)
+ testDriveLength(rowingStatistics, 0.322536845768552)
+ testDriveAverageHandleForce(rowingStatistics, 285.0923064376231)
+ testDrivePeakHandleForce(rowingStatistics, 439.7407274840117)
+ testRecoveryDuration(rowingStatistics, 0.20540926600000003)
+ testDragFactor(rowingStatistics, 283.12720365097886) // As we decelerate the flywheel quite fast, this is expected
+ testInstantHandlePower(rowingStatistics, undefined)
+ // Drive third stroke starts here
+ rowingStatistics.handleRotationImpulse(0.011221636)
+ rowingStatistics.handleRotationImpulse(0.011175504)
+ rowingStatistics.handleRotationImpulse(0.01116456)
+ rowingStatistics.handleRotationImpulse(0.011130263)
+ rowingStatistics.handleRotationImpulse(0.011082613)
+ rowingStatistics.handleRotationImpulse(0.011081761)
+ rowingStatistics.handleRotationImpulse(0.011062297)
+ rowingStatistics.handleRotationImpulse(0.011051853)
+ rowingStatistics.handleRotationImpulse(0.010973313)
+ rowingStatistics.handleRotationImpulse(0.010919756)
+ rowingStatistics.handleRotationImpulse(0.01086431)
+ rowingStatistics.handleRotationImpulse(0.010800864)
+ rowingStatistics.handleRotationImpulse(0.010956987)
+ rowingStatistics.handleRotationImpulse(0.010653396)
+ rowingStatistics.handleRotationImpulse(0.010648619)
+ rowingStatistics.handleRotationImpulse(0.010536818)
+ rowingStatistics.handleRotationImpulse(0.010526151)
+ rowingStatistics.handleRotationImpulse(0.010511225)
+ rowingStatistics.handleRotationImpulse(0.010386684)
+ testStrokeState(rowingStatistics, 'Drive')
+ testTotalMovingTime(rowingStatistics, 0.8203921620000004)
+ testTotalLinearDistance(rowingStatistics, 3.4875767518323193)
+ testTotalNumberOfStrokes(rowingStatistics, 2)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, 0.23985939100000003)
+ testDriveDistance(rowingStatistics, 1.0733115961672441)
+ testDriveLength(rowingStatistics, 0.322536845768552)
+ testDriveAverageHandleForce(rowingStatistics, 285.0923064376231)
+ testDrivePeakHandleForce(rowingStatistics, 439.7407274840117)
+ testRecoveryDuration(rowingStatistics, 0.09812447700000015)
+ testDragFactor(rowingStatistics, 283.12720365097886)
+ testInstantHandlePower(rowingStatistics, undefined)
+ // Recovery third stroke starts here
+ rowingStatistics.handleRotationImpulse(0.010769)
+ rowingStatistics.handleRotationImpulse(0.010707554)
+ rowingStatistics.handleRotationImpulse(0.010722165)
+ rowingStatistics.handleRotationImpulse(0.01089567)
+ rowingStatistics.handleRotationImpulse(0.010917504)
+ rowingStatistics.handleRotationImpulse(0.010997969)
+ rowingStatistics.handleRotationImpulse(0.011004655)
+ rowingStatistics.handleRotationImpulse(0.011013618)
+ rowingStatistics.handleRotationImpulse(0.011058193)
+ rowingStatistics.handleRotationImpulse(0.010807149)
+ rowingStatistics.handleRotationImpulse(0.0110626)
+ rowingStatistics.handleRotationImpulse(0.011090787)
+ rowingStatistics.handleRotationImpulse(0.011099509)
+ rowingStatistics.handleRotationImpulse(0.011131862)
+ rowingStatistics.handleRotationImpulse(0.011209919)
+ testStrokeState(rowingStatistics, 'Recovery')
+ testTotalMovingTime(rowingStatistics, 0.9814208570000005)
+ testTotalLinearDistance(rowingStatistics, 4.219380112855441)
+ testTotalNumberOfStrokes(rowingStatistics, 2)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, 0.27311228700000023)
+ testDriveDistance(rowingStatistics, 1.2196722683718688)
+ testDriveLength(rowingStatistics, 0.3665191429188092)
+ testDriveAverageHandleForce(rowingStatistics, 254.91449219500532)
+ testDrivePeakHandleForce(rowingStatistics, 439.74072748282515)
+ testRecoveryDuration(rowingStatistics, 0.09812447700000015)
+ testDragFactor(rowingStatistics, 283.12720365097886)
+ testInstantHandlePower(rowingStatistics, undefined)
+ // Dwelling state starts here
+ rowingStatistics.handleRotationImpulse(0.020769)
+ rowingStatistics.handleRotationImpulse(0.020707554)
+ rowingStatistics.handleRotationImpulse(0.020722165)
+ rowingStatistics.handleRotationImpulse(0.02089567)
+ rowingStatistics.handleRotationImpulse(0.020917504)
+ rowingStatistics.handleRotationImpulse(0.020997969)
+ rowingStatistics.handleRotationImpulse(0.021004655)
+ rowingStatistics.handleRotationImpulse(0.021013618)
+ rowingStatistics.handleRotationImpulse(0.021058193)
+ rowingStatistics.handleRotationImpulse(0.020807149)
+ rowingStatistics.handleRotationImpulse(0.0210626)
+ rowingStatistics.handleRotationImpulse(0.021090787)
+ rowingStatistics.handleRotationImpulse(0.021099509)
+ rowingStatistics.handleRotationImpulse(0.021131862)
+ rowingStatistics.handleRotationImpulse(0.021209919)
+ testStrokeState(rowingStatistics, 'WaitingForDrive')
+ testTotalMovingTime(rowingStatistics, 1.1137102920000004)
+ testTotalNumberOfStrokes(rowingStatistics, 2)
+ testTotalLinearDistance(rowingStatistics, 4.804822801673938)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, undefined)
+ testDriveDistance(rowingStatistics, undefined)
+ testDriveLength(rowingStatistics, undefined)
+ testDriveAverageHandleForce(rowingStatistics, undefined)
+ testDrivePeakHandleForce(rowingStatistics, undefined)
+ testRecoveryDuration(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, 283.12720365097886)
+ testInstantHandlePower(rowingStatistics, undefined)
+})
+
+// Test behaviour for noisy upgoing flank
+
+// Test behaviour for noisy downgoing flank
+
+// Test behaviour for noisy stroke
+
+// Test behaviour after reset
+
+// Test behaviour for one datapoint
+
+// Test behaviour for noisy stroke
+
+// Test drag factor calculation
+
+// Test Dynamic stroke detection
+
+// Test behaviour after reset
+
+// Test behaviour with real-life data
+
+test('sample data for Sportstech WRX700 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 46.302522627)
+ testTotalLinearDistance(rowingStatistics, 166.29596716416734)
+ testTotalNumberOfStrokes(rowingStatistics, 15)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(rowingStatistics, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('sample data for DKN R-320 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKN_R320)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/DKNR320.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 21.701535821)
+ testTotalLinearDistance(rowingStatistics, 70.11298001986664)
+ testTotalNumberOfStrokes(rowingStatistics, 9)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(rowingStatistics, rowerProfiles.DKN_R320.dragFactor)
+})
+
+test('sample data for NordicTrack RX800 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 22.259092749999997)
+ testTotalLinearDistance(rowingStatistics, 80.49260485116434)
+ testTotalNumberOfStrokes(rowingStatistics, 9)
+ // As dragFactor is dynamic, it should have changed
+ testDragFactor(rowingStatistics, 491.1395313462149)
+})
+
+test('A full session for SportsTech WRX700 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 2340.0100514160117)
+ testTotalLinearDistance(rowingStatistics, 8406.791871958883)
+ testTotalNumberOfStrokes(rowingStatistics, 845)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(rowingStatistics, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('A full session for a Concept2 Model C should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 181.47141999999985)
+ testTotalLinearDistance(rowingStatistics, 552.0863658667265)
+ testTotalNumberOfStrokes(rowingStatistics, 83)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(rowingStatistics, 123.82587294279575)
+})
+
+test('A full session for a Concept2 RowErg should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 590.111937)
+ testTotalLinearDistance(rowingStatistics, 2027.493082238415)
+ testTotalNumberOfStrokes(rowingStatistics, 205)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(rowingStatistics, 80.60573080009686)
+})
+
+function testStrokeState (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().strokeState === expectedValue, `strokeState should be ${expectedValue} at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().strokeState}`)
+}
+
+function testTotalMovingTime (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().totalMovingTime === expectedValue, `totalMovingTime should be ${expectedValue} sec at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().totalMovingTime}`)
+}
+
+function testTotalNumberOfStrokes (rowingStatistics, expectedValue) {
+ // Please note there is a stroke 0
+ assert.ok(rowingStatistics.getMetrics().totalNumberOfStrokes === expectedValue, `totalNumberOfStrokes should be ${expectedValue} at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().totalNumberOfStrokes}`)
+}
+
+function testTotalLinearDistance (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().totalLinearDistance === expectedValue, `totalLinearDistance should be ${expectedValue} meters at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().totalLinearDistance}`)
+}
+
+function testCycleDuration (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().cycleDuration === expectedValue, `cycleDuration should be ${expectedValue} sec at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().cycleDuration}`)
+}
+
+function testCycleDistance (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().cycleDistance === expectedValue, `cycleDistance should be ${expectedValue} meters at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().cycleDistance}`)
+}
+
+function testCycleLinearVelocity (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().cycleLinearVelocity === expectedValue, `cycleLinearVelocity should be ${expectedValue} m/s at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().cycleLinearVelocity}`)
+}
+
+function testCyclePower (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().cyclePower === expectedValue, `cyclePower should be ${expectedValue} Watt at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().cyclePower}`)
+}
+
+function testDriveDuration (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().driveDuration === expectedValue, `driveDuration should be ${expectedValue} sec at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().driveDuration}`)
+}
+
+function testDriveDistance (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().driveDistance === expectedValue, `DriveDistance should be ${expectedValue} meters at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().driveDistance}`)
+}
+
+function testDriveLength (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().driveLength === expectedValue, `driveLength should be ${expectedValue} meters at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().driveLength}`)
+}
+
+function testDriveAverageHandleForce (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().driveAverageHandleForce === expectedValue, `driveAverageHandleForce should be ${expectedValue} N at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().driveAverageHandleForce}`)
+}
+
+function testDrivePeakHandleForce (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().drivePeakHandleForce === expectedValue, `drivePeakHandleForce should be ${expectedValue} N at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().drivePeakHandleForce}`)
+}
+
+function testRecoveryDuration (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().recoveryDuration === expectedValue, `recoveryDuration should be ${expectedValue} sec at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().recoveryDuration}`)
+}
+
+function testDragFactor (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().dragFactor === expectedValue, `dragFactor should be ${expectedValue} N*m*s^2 at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().dragFactor}`)
+}
+
+function testInstantHandlePower (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().instantHandlePower === expectedValue, `instantHandlePower should be ${expectedValue} Watt at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().instantHandlePower}`)
+}
+
+function reportAll (rowingStatistics) { // eslint-disable-line no-unused-vars
+ assert.ok(0, `time: ${rowingStatistics.getMetrics().totalMovingTime}, state ${rowingStatistics.getMetrics().strokeState}, No Strokes: ${rowingStatistics.getMetrics().totalNumberOfStrokes + 1}, Lin Distance: ${rowingStatistics.getMetrics().totalLinearDistance}, cycle dur: ${rowingStatistics.getMetrics().cycleDuration}, cycle Lin Dist: ${rowingStatistics.getMetrics().cycleLinearDistance}, Lin Velocity: ${rowingStatistics.getMetrics().cycleLinearVelocity}, Power: ${rowingStatistics.getMetrics().cyclePower}, Drive Dur: ${rowingStatistics.getMetrics().driveDuration}, Drive Lin. Dist. ${rowingStatistics.driveDistance}, Drive Length: ${rowingStatistics.getMetrics().driveLength}, Av. Handle Force: ${rowingStatistics.getMetrics().driveAverageHandleForce}, Peak Handle Force: ${rowingStatistics.getMetrics().drivePeakHandleForce}, Rec. Dur: ${rowingStatistics.getMetrics().recoveryDuration}, Dragfactor: ${rowingStatistics.getMetrics().dragFactor}, Inst Handle Power: ${rowingStatistics.getMetrics().instantHandlePower}`)
+}
+
+test.run()
diff --git a/app/engine/SessionManager.js b/app/engine/SessionManager.js
new file mode 100644
index 0000000000..d0def988da
--- /dev/null
+++ b/app/engine/SessionManager.js
@@ -0,0 +1,511 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+/*
+/**
+ * @file This Module calculates the workout, interval and split specific metrics, as well as guards their boundaries
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#sessionmanagerjs|the description}
+ */
+/* eslint-disable max-lines -- This handles quite a complex state machine with three levels of workout segments, not much we can do about it */
+import { EventEmitter } from 'events'
+import { createRowingStatistics } from './RowingStatistics.js'
+import { createWorkoutSegment } from './utils/workoutSegment.js'
+
+import loglevel from 'loglevel'
+const log = loglevel.getLogger('RowingEngine')
+
+export function createSessionManager (config) {
+ const emitter = new EventEmitter()
+ const rowingStatistics = createRowingStatistics(config)
+ const session = createWorkoutSegment(config)
+ const interval = createWorkoutSegment(config)
+ const split = createWorkoutSegment(config)
+ let metrics = {}
+ let lastBroadcastedMetrics = {}
+ let pauseTimer
+ let pauseCountdownTimer = 0
+ let watchdogTimer
+ const watchdogTimout = 1000 * config.rowerSettings.maximumStrokeTimeBeforePause // Pause timeout in miliseconds
+ let sessionState = 'WaitingForStart'
+ let intervalSettings = []
+ let currentIntervalNumber = -1
+ let splitNumber = -1
+ let isUnplannedPause = false
+ let splitRemainder = null
+
+ metrics = refreshMetrics()
+ setIntervalParameters([{ type: 'justrow' }])
+ emitMetrics(metrics)
+ lastBroadcastedMetrics = { ...metrics }
+
+ /**
+ * This function handles all incomming commands. As all commands are broadasted to all managers, we need to filter here what is relevant
+ * for the RowingEngine and what is not
+ *
+ * @param {Command} Name of the command to be executed by the commandhandler
+ * @param {unknown} data for executing the command
+ *
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#command-flow|The command flow documentation}
+ */
+ function handleCommand (commandName, data) {
+ resetMetricsSessionContext(lastBroadcastedMetrics)
+ switch (commandName) {
+ case ('updateIntervalSettings'):
+ if (sessionState !== 'Rowing') {
+ setIntervalParameters(data)
+ } else {
+ log.debug(`SessionManager, time: ${metrics.totalMovingTime}, rejected new interval settings as session was already in progress`)
+ }
+ emitMetrics(lastBroadcastedMetrics)
+ break
+ case ('start'):
+ if (sessionState !== 'Rowing') {
+ clearTimeout(pauseTimer)
+ StartOrResumeTraining()
+ sessionState = 'WaitingForStart'
+ emitMetrics(lastBroadcastedMetrics)
+ }
+ break
+ case ('startOrResume'):
+ if (sessionState !== 'Rowing' && sessionState !== 'WaitingForStart') {
+ clearTimeout(pauseTimer)
+ StartOrResumeTraining()
+ sessionState = 'Paused'
+ emitMetrics(lastBroadcastedMetrics)
+ }
+ break
+ case ('pause'):
+ if (sessionState === 'Rowing') {
+ pauseTraining(lastBroadcastedMetrics)
+ lastBroadcastedMetrics = refreshMetrics() // as the pause button is forced, we need to fetch the zero'ed metrics
+ lastBroadcastedMetrics.metricsContext.isPauseStart = true
+ sessionState = 'Paused'
+ isUnplannedPause = true
+ emitMetrics(lastBroadcastedMetrics)
+ }
+ break
+ case ('stop'):
+ if (sessionState === 'Rowing') {
+ clearTimeout(pauseTimer)
+ stopTraining(lastBroadcastedMetrics)
+ lastBroadcastedMetrics.metricsContext.isSessionStop = true
+ sessionState = 'Stopped'
+ emitMetrics(lastBroadcastedMetrics)
+ }
+ break
+ case ('reset'):
+ clearTimeout(pauseTimer)
+ if (sessionState === 'Rowing') {
+ sessionState = 'Stopped'
+ lastBroadcastedMetrics.metricsContext.isSessionStop = true
+ emitMetrics(lastBroadcastedMetrics)
+ }
+ resetTraining(lastBroadcastedMetrics)
+ lastBroadcastedMetrics = refreshMetrics() // as the engine is reset, we need to fetch the zero'ed metrics
+ sessionState = 'WaitingForStart'
+ emitMetrics(lastBroadcastedMetrics)
+ break
+ case 'switchBlePeripheralMode':
+ break
+ case 'switchAntPeripheralMode':
+ break
+ case 'switchHrmMode':
+ break
+ case 'refreshPeripheralConfig':
+ break
+ case 'upload':
+ break
+ case 'shutdown':
+ clearTimeout(pauseTimer)
+ stopTraining(lastBroadcastedMetrics)
+ if (sessionState === 'Rowing') {
+ lastBroadcastedMetrics.metricsContext.isSessionStop = true
+ sessionState = 'Stopped'
+ emitMetrics(lastBroadcastedMetrics)
+ }
+ break
+ default:
+ log.error(`Recieved unknown command: ${commandName}`)
+ }
+ }
+
+ function refreshMetrics () {
+ const baseMetrics = rowingStatistics.getMetrics()
+ resetMetricsSessionContext(baseMetrics)
+ baseMetrics.timestamp = new Date()
+ return baseMetrics
+ }
+
+ function StartOrResumeTraining () {
+ rowingStatistics.allowStartOrResumeTraining()
+ }
+
+ function stopTraining (baseMetrics) {
+ clearTimeout(watchdogTimer)
+ interval.push(baseMetrics)
+ split.push(baseMetrics)
+ rowingStatistics.stopTraining()
+ }
+
+ // clear the metrics in case the user pauses rowing
+ function pauseTraining (baseMetrics) {
+ clearTimeout(watchdogTimer)
+ session.push(baseMetrics)
+ interval.push(baseMetrics)
+ rowingStatistics.pauseTraining()
+ }
+
+ function resetTraining (baseMetrics) {
+ stopTraining(baseMetrics)
+ rowingStatistics.resetTraining()
+ rowingStatistics.allowStartOrResumeTraining()
+ intervalSettings = null
+ intervalSettings = []
+ currentIntervalNumber = -1
+ pauseCountdownTimer = 0
+ splitNumber = -1
+ metrics = refreshMetrics()
+ lastBroadcastedMetrics = { ...metrics }
+ sessionState = 'WaitingForStart'
+ session.reset()
+ interval.reset()
+ split.reset()
+ setIntervalParameters([{ type: 'justrow' }])
+ isUnplannedPause = false
+ splitRemainder = null
+ emitMetrics(metrics)
+ }
+
+ /**
+ * This function processes the currentDt and guards the session, interval and split boundaries
+ *
+ * @param {float} time between two impulses in seconds
+ *
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#session-interval-and-split-boundaries-in-sessionmanagerjs|The session, interval and split setup}
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#sessionstates-in-sessionmanagerjs|The states maintained}
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#rowing-metrics-flow|the flags set}
+ */
+ /* eslint-disable max-statements, complexity -- This handles quite a complex state machine with three levels of workout segments, not much we can do about it */
+ function handleRotationImpulse (currentDt) {
+ let temporaryDatapoint
+
+ // Clear the watchdog as we got a currentDt, we'll set it at the end again
+ clearTimeout(watchdogTimer)
+
+ // Provide the rower with new data
+ metrics = rowingStatistics.handleRotationImpulse(currentDt)
+ resetMetricsSessionContext(metrics)
+ if (sessionState === 'Rowing' && split.getStartTimestamp() !== undefined && split.timeSinceStart(metrics) >= 0) {
+ // If we are moving, timestamps should be based on movingTime as it is more accurate and consistent for the consumers
+ metrics.timestamp = new Date(split.getStartTimestamp().getTime() + (split.timeSinceStart(metrics) * 1000))
+ } else {
+ metrics.timestamp = new Date()
+ }
+
+ if (metrics.metricsContext.isMoving && (metrics.metricsContext.isDriveStart || metrics.metricsContext.isRecoveryStart)) {
+ session.push(metrics)
+ interval.push(metrics)
+ split.push(metrics)
+ }
+
+ // This is the core of the finite state machine that defines all session state transitions
+ switch (true) {
+ case (sessionState === 'WaitingForStart' && metrics.metricsContext.isMoving === true):
+ StartOrResumeTraining()
+ sessionState = 'Rowing'
+ metrics.metricsContext.isSessionStart = true
+ // eslint-disable-next-line no-case-declarations -- Code clarity outweighs lint rules
+ const startTimestamp = new Date(metrics.timestamp.getTime() - metrics.totalMovingTime * 1000)
+ session.setStartTimestamp(startTimestamp)
+ interval.setStartTimestamp(startTimestamp)
+ split.setStartTimestamp(startTimestamp)
+ emitMetrics(metrics)
+ break
+ case (sessionState === 'WaitingForStart'):
+ // We can't change into the "Rowing" state since we are waiting for a drive phase that didn't come
+ emitMetrics(metrics)
+ break
+ case (sessionState === 'Paused' && metrics.metricsContext.isMoving === true && isUnplannedPause):
+ // It was a spontanuous pause
+ StartOrResumeTraining()
+ sessionState = 'Rowing'
+ metrics.metricsContext.isPauseEnd = true
+ emitMetrics(metrics)
+ isUnplannedPause = false
+ activateNextSplitParameters(metrics)
+ break
+ case (sessionState === 'Paused' && metrics.metricsContext.isMoving === true):
+ // We are leaving a planned rest interval
+ StartOrResumeTraining()
+ sessionState = 'Rowing'
+ metrics.metricsContext.isPauseEnd = true
+ metrics.metricsContext.isIntervalEnd = true
+ emitMetrics(metrics)
+ activateNextIntervalParameters(metrics)
+ break
+ case (sessionState === 'Paused'):
+ // We are in a paused state, and didn't see a drive, so nothing to do here
+ emitMetrics(metrics)
+ break
+ case (sessionState !== 'Stopped' && metrics.strokeState === 'Stopped'):
+ // We do not need to refetch the metrics as RowingStatistics will already have zero-ed the metrics when strokeState = 'Stopped'
+ // This is intended behaviour, as the rower/flywheel indicate the rower has stopped somehow
+ stopTraining(metrics)
+ sessionState = 'Stopped'
+ metrics.metricsContext.isSessionStop = true
+ emitMetrics(metrics)
+ break
+ case (sessionState === 'Stopped'):
+ // We are in a stopped state, and will remain there
+ sessionState = 'Stopped'
+ emitMetrics(metrics)
+ break
+ case (sessionState === 'Rowing' && metrics.strokeState === 'WaitingForDrive'):
+ // This is an unplanned pause
+ // We do not need to refetch the metrics as RowingStatistics will already have zero-ed the metrics when strokeState = 'WaitingForDrive'
+ pauseTraining(metrics)
+ sessionState = 'Paused'
+ isUnplannedPause = true
+ splitRemainder = split.remainder(metrics)
+ metrics.metricsContext.isPauseStart = true
+ metrics.metricsContext.isSplitEnd = true
+ emitMetrics(metrics)
+ activateNextSplitParameters(metrics)
+ break
+ case (sessionState === 'Rowing' && metrics.metricsContext.isMoving && interval.isEndReached(metrics) && isNextIntervalActive()):
+ // The next interval is an active one, so we just keep on going
+ // As we typically overshoot our interval target, we project the intermediate value
+ temporaryDatapoint = interval.interpolateEnd(lastBroadcastedMetrics, metrics)
+ sessionState = 'Rowing'
+ if (temporaryDatapoint.modified) {
+ // The intermediate datapoint is actually different
+ temporaryDatapoint.metricsContext.isIntervalEnd = true
+ temporaryDatapoint.metricsContext.isSplitEnd = true
+ emitMetrics(temporaryDatapoint)
+ activateNextIntervalParameters(temporaryDatapoint)
+ emitMetrics(metrics)
+ } else {
+ metrics.metricsContext.isIntervalEnd = true
+ metrics.metricsContext.isSplitEnd = true
+ emitMetrics(metrics)
+ activateNextIntervalParameters(metrics)
+ }
+ break
+ case (sessionState === 'Rowing' && metrics.metricsContext.isMoving && interval.isEndReached(metrics) && isNextIntervalAvailable()):
+ // There is a next interval, but it is a rest interval, so we forcefully stop the session
+ // As we typically overshoot our interval target, we project the intermediate value
+ sessionState = 'Paused'
+ isUnplannedPause = false
+ temporaryDatapoint = interval.interpolateEnd(lastBroadcastedMetrics, metrics)
+ if (temporaryDatapoint.modified) {
+ // The intermediate datapoint is actually different
+ temporaryDatapoint.metricsContext.isIntervalEnd = true
+ temporaryDatapoint.metricsContext.isSplitEnd = true
+ temporaryDatapoint.metricsContext.isPauseStart = true
+ emitMetrics(temporaryDatapoint)
+ activateNextIntervalParameters(temporaryDatapoint)
+ } else {
+ metrics.metricsContext.isIntervalEnd = true
+ metrics.metricsContext.isSplitEnd = true
+ metrics.metricsContext.isPauseStart = true
+ emitMetrics(metrics)
+ activateNextIntervalParameters(metrics)
+ }
+
+ if (interval.timeToEnd(metrics) > 0) {
+ // If a minimal pause timer has been set, we need to make sure the user obeys that
+ pauseCountdownTimer = interval.timeToEnd(temporaryDatapoint)
+ stopTraining(temporaryDatapoint)
+ pauseTimer = setTimeout(onPauseTimer, 100)
+ } else {
+ // No minimal pause time has been set, so we pause the engine. In this state automatically activates the session again upon the next drive
+ pauseCountdownTimer = 0
+ pauseTraining(temporaryDatapoint)
+ }
+ metrics = refreshMetrics() // Here we want to switch to a zero-ed message as the flywheel has stopped
+ break
+ case (sessionState === 'Rowing' && metrics.metricsContext.isMoving && interval.isEndReached(metrics)):
+ // Here we do NOT want zero the metrics, as we want to keep the metrics we had when we crossed the finishline
+ stopTraining(metrics)
+ sessionState = 'Stopped'
+ temporaryDatapoint = interval.interpolateEnd(lastBroadcastedMetrics, metrics)
+ if (temporaryDatapoint.modified) {
+ temporaryDatapoint.metricsContext.isSessionStop = true
+ emitMetrics(temporaryDatapoint)
+ } else {
+ metrics.metricsContext.isSessionStop = true
+ emitMetrics(metrics)
+ }
+ break
+ case (sessionState === 'Rowing' && metrics.metricsContext.isMoving && split.isEndReached(metrics)):
+ sessionState = 'Rowing'
+ temporaryDatapoint = split.interpolateEnd(lastBroadcastedMetrics, metrics)
+ if (temporaryDatapoint.modified) {
+ temporaryDatapoint.metricsContext.isSplitEnd = true
+ emitMetrics(temporaryDatapoint)
+ activateNextSplitParameters(temporaryDatapoint)
+ emitMetrics(metrics)
+ } else {
+ metrics.metricsContext.isSplitEnd = true
+ emitMetrics(metrics)
+ activateNextSplitParameters(metrics)
+ }
+ break
+ case (sessionState === 'Rowing' && metrics.metricsContext.isMoving):
+ sessionState = 'Rowing'
+ emitMetrics(metrics)
+ break
+ default:
+ log.error(`SessionManager: Time: ${metrics.totalMovingTime}, combination of ${sessionState} and state ${metrics.strokeState} is not captured by Finite State Machine`)
+ }
+
+ if (sessionState === 'Rowing' && metrics.metricsContext.isMoving) {
+ watchdogTimer = setTimeout(onWatchdogTimeout, watchdogTimout)
+ }
+ lastBroadcastedMetrics = { ...metrics }
+ }
+ /* eslint-enable max-statements, complexity */
+
+ // Basic metricContext structure
+ function resetMetricsSessionContext (metricsToReset) {
+ metricsToReset.metricsContext.isSessionStart = false
+ metricsToReset.metricsContext.isIntervalEnd = false
+ metricsToReset.metricsContext.isSplitEnd = false
+ metricsToReset.metricsContext.isPauseStart = false
+ metricsToReset.metricsContext.isPauseEnd = false
+ metricsToReset.metricsContext.isSessionStop = false
+ }
+
+ function setIntervalParameters (intervalParameters) {
+ intervalSettings = null
+ intervalSettings = intervalParameters
+ currentIntervalNumber = -1
+ splitNumber = -1
+ splitRemainder = null
+ if (intervalSettings.length > 0) {
+ log.info(`SessionManager: Workout plan recieved with ${intervalSettings.length} interval(s)`)
+ metrics = refreshMetrics()
+
+ session.setStart(metrics)
+ session.summarize(intervalParameters)
+
+ activateNextIntervalParameters(metrics)
+ emitMetrics(metrics)
+ } else {
+ // intervalParameters were empty, lets log this odd situation
+ log.error('SessionManager: Recieved workout plan containing no intervals')
+ }
+ }
+
+ function isNextIntervalAvailable () {
+ // This function tests whether there is a next interval available
+ if (currentIntervalNumber > -1 && intervalSettings.length > 0 && intervalSettings.length > (currentIntervalNumber + 1)) {
+ return true
+ } else {
+ return false
+ }
+ }
+
+ function isNextIntervalActive () {
+ // This function tests whether there is a next interval available
+ if (currentIntervalNumber > -1 && intervalSettings.length > 0 && intervalSettings.length > (currentIntervalNumber + 1)) {
+ return (intervalSettings[currentIntervalNumber + 1].type !== 'rest')
+ } else {
+ return false
+ }
+ }
+
+ function activateNextIntervalParameters (baseMetrics) {
+ if (intervalSettings.length > 0 && intervalSettings.length > (currentIntervalNumber + 1)) {
+ // This function sets the interval parameters in absolute distances/times
+ // Thus the interval target always is a projected "finishline" from the current position
+ currentIntervalNumber++
+ log.info(`Activating interval settings for interval ${currentIntervalNumber + 1} of ${intervalSettings.length}`)
+ interval.setStart(baseMetrics)
+ interval.setEnd(intervalSettings[currentIntervalNumber])
+
+ // As the interval has changed, we need to reset the split metrics
+ splitRemainder = null
+ activateNextSplitParameters(baseMetrics)
+ } else {
+ log.error('SessionManager: expected a next interval, but did not find one!')
+ }
+ }
+
+ function activateNextSplitParameters (baseMetrics) {
+ splitNumber++
+ log.error(`Activating split settings for split ${splitNumber + 1}`)
+ split.setStart(baseMetrics)
+ if (splitRemainder !== null && sessionState === 'Rowing') {
+ // We have a part of the split still have to complete
+ split.setEnd(splitRemainder)
+ splitRemainder = null
+ } else {
+ split.setEnd(interval.getSplit())
+ }
+ }
+
+ function onPauseTimer () {
+ pauseCountdownTimer = pauseCountdownTimer - 0.1
+ if (pauseCountdownTimer > 0) {
+ // The countdowntimer still has some time left on it
+ pauseTimer = setTimeout(onPauseTimer, 100)
+ lastBroadcastedMetrics.timestamp = new Date()
+ } else {
+ // The timer has run out
+ pauseTraining(lastBroadcastedMetrics)
+ sessionState = 'Paused'
+ lastBroadcastedMetrics = refreshMetrics()
+ pauseCountdownTimer = 0
+ log.debug(`Time: ${lastBroadcastedMetrics.totalMovingTime}, rest interval ended`)
+ }
+ emitMetrics(lastBroadcastedMetrics)
+ }
+
+ function emitMetrics (metricsToEmit) {
+ enrichMetrics(metricsToEmit)
+ emitter.emit('metricsUpdate', metricsToEmit)
+ }
+
+ function enrichMetrics (metricsToEnrich) {
+ metricsToEnrich.sessionState = sessionState
+ metricsToEnrich.pauseCountdownTime = Math.max(pauseCountdownTimer, 0) // Time left on the countdown timer
+ metricsToEnrich.metricsContext.isUnplannedPause = isUnplannedPause // Indication for the PM5 emulator to distinguish between planned and unplanned pauses
+ metricsToEnrich.workout = session.metrics(metricsToEnrich)
+ metricsToEnrich.interval = interval.metrics(metricsToEnrich)
+ metricsToEnrich.interval.workoutStepNumber = Math.max(currentIntervalNumber, 0) // Interval number, to keep in sync with the workout plan
+ metricsToEnrich.split = split.metrics(metricsToEnrich)
+ metricsToEnrich.split.number = splitNumber
+ }
+
+ function onWatchdogTimeout () {
+ pauseTraining(lastBroadcastedMetrics)
+ metrics = refreshMetrics()
+ log.error(`Time: ${metrics.totalMovingTime}, Forced a session pause due to unexpeted flywheel stop, exceeding the maximumStrokeTimeBeforePause (i.e. ${watchdogTimout / 1000} seconds) without new datapoints`)
+ sessionState = 'Paused'
+ isUnplannedPause = true
+ metrics.metricsContext.isPauseStart = true
+ metrics.metricsContext.isSplitEnd = true
+ session.push(metrics)
+ interval.push(metrics)
+ split.push(metrics)
+ emitMetrics(metrics)
+ activateNextSplitParameters(metrics)
+ lastBroadcastedMetrics = { ...metrics }
+ }
+
+ /**
+ * @returns all metrics in the session manager
+ * @remark FOR TESTING PURPOSSES ONLY!
+ */
+ function getMetrics () {
+ enrichMetrics(metrics)
+ return metrics
+ }
+
+ return Object.assign(emitter, {
+ handleCommand,
+ handleRotationImpulse,
+ getMetrics
+ })
+}
diff --git a/app/engine/SessionManager.test.js b/app/engine/SessionManager.test.js
new file mode 100644
index 0000000000..8f2e4c3ea1
--- /dev/null
+++ b/app/engine/SessionManager.test.js
@@ -0,0 +1,563 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This test is a test of the SessionManager, that tests wether this object fills all fields correctly,
+ * and cuts off a session, interval and split decently
+ */
+// ToDo: test the effects of smoothing parameters
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+import rowerProfiles from '../../config/rowerProfiles.js'
+import { replayRowingSession } from '../recorders/RowingReplayer.js'
+import { deepMerge } from '../tools/Helper.js'
+
+import { createSessionManager } from './SessionManager.js'
+
+test('sample data for Sportstech WRX700 should produce plausible results for an unlimited run', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 46.302522627)
+ testTotalLinearDistance(sessionManager, 166.29596716416734)
+ testTotalNumberOfStrokes(sessionManager, 15)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('sample data for Sportstech WRX700 should produce plausible results for a 150 meter session', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 150,
+ targetTime: 0
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 41.734896595)
+ testTotalLinearDistance(sessionManager, 150.02019165448286)
+ testTotalNumberOfStrokes(sessionManager, 14)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('sample data for Sportstech WRX700 should produce plausible results for a 45 seconds session', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 45
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 45.077573161000004)
+ testTotalLinearDistance(sessionManager, 163.46539751030917)
+ testTotalNumberOfStrokes(sessionManager, 15)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('sample data for DKN R-320 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKN_R320)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/DKNR320.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 21.701535821)
+ testTotalLinearDistance(sessionManager, 70.11298001986664)
+ testTotalNumberOfStrokes(sessionManager, 9)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.DKN_R320.dragFactor)
+})
+
+test('sample data for NordicTrack RX800 should produce plausible results without intervalsettings', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 22.259092749999997)
+ testTotalLinearDistance(sessionManager, 80.49260485116434)
+ testTotalNumberOfStrokes(sessionManager, 9)
+ // As dragFactor is dynamic, it should have changed
+ testDragFactor(sessionManager, 491.1395313462149)
+})
+
+test('sample data for NordicTrack RX800 should produce plausible results for a 20 seconds session', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 20
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 20.02496380499998)
+ testTotalLinearDistance(sessionManager, 72.3905525302199)
+ testTotalNumberOfStrokes(sessionManager, 8)
+ // As dragFactor is dynamic, it should have changed
+ testDragFactor(sessionManager, 487.65077394777813)
+})
+
+test('sample data for NordicTrack RX800 should produce plausible results for a 75 meter session', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 75,
+ targetTime: 0
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 20.78640177499998)
+ testTotalLinearDistance(sessionManager, 75.04096463553918)
+ testTotalNumberOfStrokes(sessionManager, 9)
+ // As dragFactor is dynamic, it should have changed
+ testDragFactor(sessionManager, 491.1395313462149)
+})
+
+test('A full unlimited session for SportsTech WRX700 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 2340.0100514160117)
+ testTotalLinearDistance(sessionManager, 8406.791871958883)
+ testTotalNumberOfStrokes(sessionManager, 845)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('A 8000 meter session for SportsTech WRX700 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 8000,
+ targetTime: 0
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 2236.509317727007)
+ testTotalLinearDistance(sessionManager, 8000.605126630236)
+ testTotalNumberOfStrokes(sessionManager, 804)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('A 2300 sec session for SportsTech WRX700 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 2300
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 2300.00695516701)
+ testTotalLinearDistance(sessionManager, 8252.525825823619)
+ testTotalNumberOfStrokes(sessionManager, 830)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('A 2400 sec session for SportsTech WRX700 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 2400
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 2340.0100514160117)
+ testTotalLinearDistance(sessionManager, 8406.791871958883)
+ testTotalNumberOfStrokes(sessionManager, 845)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('A full session for a Concept2 Model C should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 181.47141999999985)
+ testTotalLinearDistance(sessionManager, 552.0863658667265)
+ testTotalNumberOfStrokes(sessionManager, 83)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(sessionManager, 123.82587294279575)
+})
+
+test('A 500 meter session for a Concept2 Model C should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 500,
+ targetTime: 0
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 156.87138200000004)
+ testTotalLinearDistance(sessionManager, 500.03019828253076)
+ testTotalNumberOfStrokes(sessionManager, 73)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(sessionManager, 123.69864738410088)
+})
+
+test('A 3 minute session for a Concept2 Model C should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 180
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 180.96533299999987)
+ testTotalLinearDistance(sessionManager, 551.8641725505744)
+ testTotalNumberOfStrokes(sessionManager, 83)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(sessionManager, 123.82587294279575)
+})
+
+test('A full session for a Concept2 RowErg should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 590.111937)
+ testTotalLinearDistance(sessionManager, 2027.493082238415)
+ testTotalNumberOfStrokes(sessionManager, 205)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(sessionManager, 80.60573080009686)
+})
+
+test('A 2000 meter session for a Concept2 RowErg should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 2000,
+ targetTime: 0
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 582.1907659999988)
+ testTotalLinearDistance(sessionManager, 2000.0158938948496)
+ testTotalNumberOfStrokes(sessionManager, 203)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(sessionManager, 80.55270240035931)
+})
+
+test('A 580 seconds session for a Concept2 RowErg should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 580
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 580.0033639999992)
+ testTotalLinearDistance(sessionManager, 1992.6040191024413)
+ testTotalNumberOfStrokes(sessionManager, 202)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(sessionManager, 80.5946092810885)
+})
+
+function testTotalMovingTime (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().totalMovingTime === expectedValue, `totalMovingTime should be ${expectedValue} sec at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().totalMovingTime}`)
+}
+
+function testTotalNumberOfStrokes (sessionManager, expectedValue) {
+ // Please note there is a stroke 0
+ assert.ok(sessionManager.getMetrics().totalNumberOfStrokes === expectedValue, `totalNumberOfStrokes should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().totalNumberOfStrokes}`)
+}
+
+function testTotalLinearDistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().totalLinearDistance === expectedValue, `totalLinearDistance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().totalLinearDistance}`)
+}
+
+function testDragFactor (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().dragFactor === expectedValue, `dragFactor should be ${expectedValue} N*m*s^2 at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().dragFactor}`)
+}
+
+function reportAll (sessionManager) { // eslint-disable-line no-unused-vars
+ assert.ok(0, `time: ${sessionManager.getMetrics().totalMovingTime}, state ${sessionManager.getMetrics().strokeState}, No Strokes: ${sessionManager.getMetrics().totalNumberOfStrokes}, Lin Distance: ${sessionManager.getMetrics().totalLinearDistance}, cycle dur: ${sessionManager.getMetrics().cycleDuration}, cycle Lin Dist: ${sessionManager.getMetrics().cycleLinearDistance}, Lin Velocity: ${sessionManager.getMetrics().cycleLinearVelocity}, Power: ${sessionManager.getMetrics().cyclePower}, Drive Dur: ${sessionManager.getMetrics().driveDuration}, Drive Lin. Dist. ${sessionManager.driveDistance}, Drive Length: ${sessionManager.getMetrics().driveLength}, Av. Handle Force: ${sessionManager.getMetrics().driveAverageHandleForce}, Peak Handle Force: ${sessionManager.getMetrics().drivePeakHandleForce}, Rec. Dur: ${sessionManager.getMetrics().recoveryDuration}, Dragfactor: ${sessionManager.getMetrics().dragFactor}, Inst Handle Power: ${sessionManager.getMetrics().instantHandlePower}`)
+}
+
+test.run()
diff --git a/app/engine/VO2max.js b/app/engine/VO2max.js
deleted file mode 100644
index 6d15189c2d..0000000000
--- a/app/engine/VO2max.js
+++ /dev/null
@@ -1,165 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This Module calculates the training specific VO2Max metrics. It is based on formula's found on the web (see function definitions).
-*/
-
-import { createBucketedLinearSeries } from './utils/BucketedLinearSeries.js'
-
-import loglevel from 'loglevel'
-const log = loglevel.getLogger('RowingEngine')
-
-function createVO2max (config) {
- const bucketedLinearSeries = createBucketedLinearSeries(config)
- const minimumValidBrackets = 5.0
- const offset = 90
-
- function calculateVO2max (metrics) {
- let projectedVO2max = 0
- let interpolatedVO2max = 0
-
- if (metrics[0].heartrate !== undefined && metrics[metrics.length - 1].heartrate !== undefined && metrics[metrics.length - 1].heartrate >= config.userSettings.restingHR) {
- projectedVO2max = extrapolatedVO2max(metrics)
- }
-
- interpolatedVO2max = calculateInterpolatedVO2max(metrics)
-
- if (projectedVO2max >= 10 && projectedVO2max <= 60 && interpolatedVO2max >= 10 && interpolatedVO2max <= 60) {
- // Both VO2Max calculations have delivered a valid and credible result
- log.debug(`--- VO2Max calculation delivered two credible results Extrapolated VO2Max: ${projectedVO2max.toFixed(1)} and Interpolated VO2Max: ${interpolatedVO2max.toFixed(1)}`)
- return ((projectedVO2max + interpolatedVO2max) / 2)
- } else {
- // One of the calculations has delivered an invalid result
- if (interpolatedVO2max >= 10 && interpolatedVO2max <= 60) {
- // Interpolation has delivered a credible result
- log.debug(`--- VO2Max calculation delivered one credible result, the Interpolated VO2Max: ${interpolatedVO2max.toFixed(1)}. The Extrapolated VO2Max: ${projectedVO2max.toFixed(1)} was unreliable`)
- return interpolatedVO2max
- } else {
- // Interpolation hasn't delivered a credible result
- if (projectedVO2max >= 10 && projectedVO2max <= 60) {
- // Extrapolation did deliver a credible result
- log.debug(`--- VO2Max calculation delivered one credible result, the Extrapolated VO2Max: ${projectedVO2max.toFixed(1)}. Interpolated VO2Max: ${interpolatedVO2max.toFixed(1)} was unreliable`)
- return projectedVO2max
- } else {
- // No credible results at all!
- log.debug(`--- VO2Max calculation did not deliver any credible results Extrapolated VO2Max: ${projectedVO2max.toFixed(1)}, Interpolated VO2Max: ${interpolatedVO2max.toFixed(1)}`)
- return 0
- }
- }
- }
- }
-
- function extrapolatedVO2max (metrics) {
- // This implements the extrapolation-based VO2Max determination
- // Which is based on the extrapolated maximum power output based on the correlation between heartrate and power,
- // Underlying formula's can be found here: https://sportcoaching.co.nz/how-does-garmin-calculate-vo2-max/
- let ProjectedVO2max
- let i = 0
- while (i < metrics.length && metrics[i].totalMovingTime < offset) {
- // We skip the first timeperiod as it only depicts the change from a resting HR to a working HR
- i++
- }
- while (i < metrics.length) {
- if (metrics[i].heartrate !== undefined && metrics[i].heartrate >= config.userSettings.restingHR && metrics[i].heartrate <= config.userSettings.maxHR && metrics[i].cyclePower !== undefined && metrics[i].cyclePower >= config.userSettings.minPower && metrics[i].cyclePower <= config.userSettings.maxPower) {
- // The data looks credible, lets add it
- bucketedLinearSeries.push(metrics[i].heartrate, metrics[i].cyclePower)
- }
- i++
- }
-
- // All Datapoints have been added, now we determine the projected power
- if (bucketedLinearSeries.numberOfSamples() >= minimumValidBrackets) {
- const projectedPower = bucketedLinearSeries.projectX(config.userSettings.maxHR)
- if (projectedPower <= config.userSettings.maxPower && projectedPower >= bucketedLinearSeries.maxEncounteredY()) {
- ProjectedVO2max = ((14.72 * projectedPower) + 250.39) / config.userSettings.weight
- log.debug(`--- VO2Max Goodness of Fit: ${bucketedLinearSeries.goodnessOfFit().toFixed(6)}, projected power ${projectedPower.toFixed(1)} Watt, extrapolated VO2Max: ${ProjectedVO2max.toFixed(1)}`)
- } else {
- ProjectedVO2max = ((14.72 * bucketedLinearSeries.maxEncounteredY()) + 250.39) / config.userSettings.weight
- log.debug(`--- VO2Max maximum encountered power: ${bucketedLinearSeries.maxEncounteredY().toFixed(1)} Watt, extrapolated VO2Max: ${ProjectedVO2max.toFixed(1)}`)
- }
- } else {
- log.debug(`--- VO2Max extrapolation failed as there were not enough valid brackets: ${bucketedLinearSeries.numberOfSamples()}`)
- ProjectedVO2max = 0
- }
- return ProjectedVO2max
- }
-
- function calculateInterpolatedVO2max (metrics) {
- // This is based on research done by concept2, https://www.concept2.com/indoor-rowers/training/calculators/vo2max-calculator,
- // which determines the VO2Max based on the 2K speed
- const distance = metrics[metrics.length - 1].totalLinearDistance
- const time = metrics[metrics.length - 1].totalMovingTime
- const projectedTwoKPace = interpolatePace(time, distance, 2000)
- const projectedTwoKTimeInMinutes = (4 * projectedTwoKPace) / 60
- let Y = 0
-
- log.debug(`--- VO2Max Interpolated 2K pace: ${Math.floor(projectedTwoKPace / 60)}:${(projectedTwoKPace % 60).toFixed(1)}`)
- // This implements the table with formulas found at https://www.concept2.com/indoor-rowers/training/calculators/vo2max-calculator
- if (config.userSettings.highlyTrained) {
- // Highly trained
- if (config.userSettings.sex === 'male') {
- // Highly trained male
- if (config.userSettings.weight > 75) {
- // Highly trained male, above 75 Kg
- Y = 15.7 - (1.5 * projectedTwoKTimeInMinutes)
- } else {
- // Highly trained male, equal or below 75 Kg
- Y = 15.1 - (1.5 * projectedTwoKTimeInMinutes)
- }
- } else {
- // Highly trained female
- if (config.userSettings.weight > 61.36) {
- // Highly trained female, above 61.36 Kg
- Y = 14.9 - (1.5 * projectedTwoKTimeInMinutes)
- } else {
- // Highly trained female, equal or below 61.36 Kg
- Y = 14.6 - (1.5 * projectedTwoKTimeInMinutes)
- }
- }
- } else {
- // Not highly trained
- if (config.userSettings.sex === 'male') {
- // Not highly trained male
- Y = 10.7 - (0.9 * projectedTwoKTimeInMinutes)
- } else {
- // Not highly trained female
- Y = 10.26 - (0.93 * projectedTwoKTimeInMinutes)
- }
- }
- return (Y * 1000) / config.userSettings.weight
- }
-
- function interpolatePace (origintime, origindistance, targetdistance) {
- // We interpolate the 2K speed based on Paul's Law: https://paulergs.weebly.com/blog/a-quick-explainer-on-pauls-law
- let originpace = 0
-
- if (origintime > 0 && origindistance > 0 && targetdistance > 0) {
- originpace = (500 * origintime) / origindistance
- return (originpace + (config.userSettings.distanceCorrectionFactor * Math.log2(targetdistance / origindistance)))
- } else {
- return 0
- }
- }
-
- function averageObservedHR () {
- bucketedLinearSeries.averageEncounteredX()
- }
-
- function maxObservedHR () {
- bucketedLinearSeries.maxEncounteredX()
- }
-
- function reset () {
- bucketedLinearSeries.reset()
- }
-
- return {
- calculateVO2max,
- averageObservedHR,
- maxObservedHR,
- reset
- }
-}
-
-export { createVO2max }
diff --git a/app/engine/WorkoutRecorder.js b/app/engine/WorkoutRecorder.js
deleted file mode 100644
index 5370a1432f..0000000000
--- a/app/engine/WorkoutRecorder.js
+++ /dev/null
@@ -1,319 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This Module captures the metrics of a rowing session and persists them.
-
- Todo: split this into multiple modules
-*/
-import log from 'loglevel'
-import zlib from 'zlib'
-import fs from 'fs/promises'
-import xml2js from 'xml2js'
-import config from '../tools/ConfigManager.js'
-import { createVO2max } from './VO2max.js'
-import { promisify } from 'util'
-const gzip = promisify(zlib.gzip)
-
-function createWorkoutRecorder () {
- let strokes = []
- let rotationImpulses = []
- let postExerciseHR = []
- let startTime
-
- function recordRotationImpulse (impulse) {
- if (startTime === undefined) {
- startTime = new Date()
- }
- // impulse recordings a currently only used to create raw data files, so we can skip it
- // if raw data file creation is disabled
- if (config.createRawDataFiles) {
- rotationImpulses.push(impulse)
- }
- }
-
- function recordStroke (stroke) {
- if (startTime === undefined) {
- startTime = new Date()
- }
- strokes.push(stroke)
- }
-
- async function createRawDataFile () {
- const stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
- const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
- const filename = `${directory}/${stringifiedStartTime}_raw.csv${config.gzipRawDataFiles ? '.gz' : ''}`
- log.info(`saving session as raw data file ${filename}...`)
-
- try {
- await fs.mkdir(directory, { recursive: true })
- } catch (error) {
- if (error.code !== 'EEXIST') {
- log.error(`can not create directory ${directory}`, error)
- }
- }
- await createFile(rotationImpulses.join('\n'), filename, config.gzipRawDataFiles)
- }
-
- async function createRowingDataFile () {
- const stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
- const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
- const filename = `${directory}/${stringifiedStartTime}_rowingData.csv`
- let currentstroke
- let trackPointTime
- let timestamp
- let i
-
- log.info(`saving session as RowingData file ${filename}...`)
-
- // Required file header, please note this includes a typo and odd spaces as the specification demands it!
- let RowingData = ',index, Stroke Number,TimeStamp (sec), ElapsedTime (sec), HRCur (bpm),DistanceMeters, Cadence (stokes/min), Stroke500mPace (sec/500m), Power (watts), StrokeDistance (meters),' +
- ' DriveTime (ms), DriveLength (meters), StrokeRecoveryTime (ms),Speed, Horizontal (meters), Calories (kCal), DragFactor, PeakDriveForce (N), AverageDriveForce (N),' +
- 'Handle_Force_(N),Handle_Velocity_(m/s),Handle_Power_(W)\n'
-
- // Add the strokes
- i = 0
- while (i < strokes.length) {
- currentstroke = strokes[i]
- trackPointTime = new Date(startTime.getTime() + currentstroke.totalMovingTime * 1000)
- timestamp = trackPointTime.getTime() / 1000
-
- RowingData += `${currentstroke.totalNumberOfStrokes.toFixed(0)},${currentstroke.totalNumberOfStrokes.toFixed(0)},${currentstroke.totalNumberOfStrokes.toFixed(0)},${timestamp.toFixed(0)},` +
- `${currentstroke.totalMovingTime.toFixed(2)},${(currentstroke.heartrate > 30 ? currentstroke.heartrate.toFixed(0) : NaN)},${currentstroke.totalLinearDistance.toFixed(1)},` +
- `${currentstroke.cycleStrokeRate.toFixed(1)},${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.cyclePace.toFixed(2) : NaN)},${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.cyclePower.toFixed(0) : NaN)},` +
- `${currentstroke.cycleDistance.toFixed(2)},${(currentstroke.driveDuration * 1000).toFixed(0)},${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.driveLength.toFixed(2) : NaN)},${(currentstroke.recoveryDuration * 1000).toFixed(0)},` +
- `${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.cycleLinearVelocity.toFixed(2) : 0)},${currentstroke.totalLinearDistance.toFixed(1)},${currentstroke.totalCalories.toFixed(1)},${currentstroke.dragFactor.toFixed(1)},` +
- `${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.drivePeakHandleForce.toFixed(1) : NaN)},${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.driveAverageHandleForce.toFixed(1) : 0)},"${currentstroke.driveHandleForceCurve}",` +
- `"${currentstroke.driveHandleVelocityCurve}","${currentstroke.driveHandlePowerCurve}"\n`
- i++
- }
- await createFile(RowingData, `${filename}`, false)
- }
-
- async function createTcxFile () {
- const tcxRecord = await activeWorkoutToTcx()
- if (tcxRecord === undefined) {
- log.error('error creating tcx file')
- return
- }
- const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
- const filename = `${directory}/${tcxRecord.filename}${config.gzipTcxFiles ? '.gz' : ''}`
- log.info(`saving session as tcx file ${filename}...`)
-
- try {
- await fs.mkdir(directory, { recursive: true })
- } catch (error) {
- if (error.code !== 'EEXIST') {
- log.error(`can not create directory ${directory}`, error)
- }
- }
-
- await createFile(tcxRecord.tcx, `${filename}`, config.gzipTcxFiles)
- }
-
- async function activeWorkoutToTcx () {
- // we need at least two strokes to generate a valid tcx file
- if (strokes.length < 5) return
- const stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
- const filename = `${stringifiedStartTime}_rowing.tcx`
-
- const tcx = await workoutToTcx({
- id: startTime.toISOString(),
- startTime,
- strokes
- })
-
- return {
- tcx,
- filename
- }
- }
-
- async function workoutToTcx (workout) {
- let versionArray = process.env.npm_package_version.split('.')
- if (versionArray.length < 3) versionArray = ['0', '0', '0']
- const lastStroke = workout.strokes[strokes.length - 1]
- const drag = workout.strokes.reduce((sum, s) => sum + s.dragFactor, 0) / strokes.length
-
- // VO2Max calculation for the remarks section
- let VO2maxoutput = 'UNDEFINED'
- const VO2max = createVO2max(config)
- const VO2maxResult = VO2max.calculateVO2max(strokes)
- if (VO2maxResult > 10 && VO2maxResult < 60) {
- VO2maxoutput = `${VO2maxResult.toFixed(1)} mL/(kg*min)`
- }
-
- // Addition of HRR data
- let hrrAdittion = ''
- if (postExerciseHR.length > 1 && (postExerciseHR[0] > (0.7 * config.userSettings.maxHR))) {
- // Recovery Heartrate is only defined when the last excercise HR is above 70% of the maximum Heartrate
- if (postExerciseHR.length === 2) {
- hrrAdittion = `, HRR1: ${postExerciseHR[1] - postExerciseHR[0]} (${postExerciseHR[1]} BPM)`
- }
- if (postExerciseHR.length === 3) {
- hrrAdittion = `, HRR1: ${postExerciseHR[1] - postExerciseHR[0]} (${postExerciseHR[1]} BPM), HRR2: ${postExerciseHR[2] - postExerciseHR[1]} (${postExerciseHR[2]} BPM)`
- }
- if (postExerciseHR.length >= 4) {
- hrrAdittion = `, HRR1: ${postExerciseHR[1] - postExerciseHR[0]} (${postExerciseHR[1]} BPM), HRR2: ${postExerciseHR[2] - postExerciseHR[1]} (${postExerciseHR[2]} BPM), HRR3: ${postExerciseHR[3] - postExerciseHR[2]} (${postExerciseHR[3]} BPM)`
- }
- }
-
- const tcxObject = {
- TrainingCenterDatabase: {
- $: { xmlns: 'http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2', 'xmlns:ns2': 'http://www.garmin.com/xmlschemas/ActivityExtension/v2' },
- Activities: {
- Activity: {
- $: { Sport: 'Other' },
- Id: workout.id,
- Lap: [
- {
- $: { StartTime: workout.startTime.toISOString() },
- TotalTimeSeconds: lastStroke.totalMovingTime.toFixed(1),
- DistanceMeters: lastStroke.totalLinearDistance.toFixed(1),
- MaximumSpeed: (workout.strokes.map((stroke) => stroke.cycleLinearVelocity).reduce((acc, cycleLinearVelocity) => Math.max(acc, cycleLinearVelocity))).toFixed(2),
- Calories: Math.round(lastStroke.totalCalories),
- /* ToDo Fix issue with IF-statement not being accepted here?
- if (lastStroke.heartrate !== undefined && lastStroke.heartrate > 30) {
- AverageHeartRateBpm: VO2max.averageObservedHR(),
- MaximumHeartRateBpm: VO2max.maxObservedHR,
- //AverageHeartRateBpm: { Value: (workout.strokes.reduce((sum, s) => sum + s.heartrate, 0) / workout.strokes.length).toFixed(2) },
- //MaximumHeartRateBpm: { Value: Math.round(workout.strokes.map((stroke) => stroke.power).reduce((acc, heartrate) => Math.max(acc, heartrate))) },
- }
- */
- Intensity: 'Active',
- Cadence: Math.round(workout.strokes.reduce((sum, s) => sum + s.cycleStrokeRate, 0) / (workout.strokes.length - 1)),
- TriggerMethod: 'Manual',
- Track: {
- Trackpoint: (() => {
- return workout.strokes.map((stroke) => {
- const trackPointTime = new Date(workout.startTime.getTime() + stroke.totalMovingTime * 1000)
- const trackpoint = {
- Time: trackPointTime.toISOString(),
- DistanceMeters: stroke.totalLinearDistance.toFixed(2),
- Cadence: Math.round(stroke.cycleStrokeRate),
- Extensions: {
- 'ns2:TPX': {
- 'ns2:Speed': stroke.cycleLinearVelocity.toFixed(2),
- 'ns2:Watts': Math.round(stroke.cyclePower)
- }
- }
- }
- if (stroke.heartrate !== undefined && stroke.heartrate > 30) {
- trackpoint.HeartRateBpm = { Value: stroke.heartrate }
- }
- return trackpoint
- })
- })()
- },
- Extensions: {
- 'ns2:LX': {
- 'ns2:Steps': lastStroke.totalNumberOfStrokes.toFixed(0),
- // please note, the -1 is needed as we have a stroke 0, with a speed and power of 0. The - 1 corrects this.
- 'ns2:AvgSpeed': (workout.strokes.reduce((sum, s) => sum + s.cycleLinearVelocity, 0) / (workout.strokes.length - 1)).toFixed(2),
- 'ns2:AvgWatts': (workout.strokes.reduce((sum, s) => sum + s.cyclePower, 0) / (workout.strokes.length - 1)).toFixed(0),
- 'ns2:MaxWatts': Math.round(workout.strokes.map((stroke) => stroke.cyclePower).reduce((acc, cyclePower) => Math.max(acc, cyclePower)))
- }
- }
- }
- ],
- Notes: `Indoor Rowing, Drag factor: ${drag.toFixed(1)} 10-6 N*m*s2, Estimated VO2Max: ${VO2maxoutput}${hrrAdittion}`
- }
- },
- Author: {
- $: { 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', 'xsi:type': 'Application_t' },
- Name: 'Open Rowing Monitor',
- Build: {
- Version: {
- VersionMajor: versionArray[0],
- VersionMinor: versionArray[1],
- BuildMajor: versionArray[2],
- BuildMinor: 0
- },
- LangID: 'en',
- PartNumber: 'OPE-NROWI-NG'
- }
- }
- }
- }
-
- const builder = new xml2js.Builder()
- return builder.buildObject(tcxObject)
- }
-
- async function reset () {
- await createRecordings()
- strokes = []
- rotationImpulses = []
- postExerciseHR = []
- startTime = undefined
- }
-
- async function createFile (content, filename, compress = false) {
- if (compress) {
- const gzipContent = await gzip(content)
- try {
- await fs.writeFile(filename, gzipContent)
- } catch (err) {
- log.error(err)
- }
- } else {
- try {
- await fs.writeFile(filename, content)
- } catch (err) {
- log.error(err)
- }
- }
- }
-
- function handlePause () {
- createRecordings()
- }
-
- async function createRecordings () {
- if (!config.createRawDataFiles && !config.createTcxFiles) {
- return
- }
-
- if (!minimumRecordingTimeHasPassed()) {
- log.debug('workout is shorter than minimum workout time, skipping automatic creation of recordings...')
- return
- }
-
- const parallelCalls = []
-
- if (config.createRawDataFiles) {
- parallelCalls.push(createRawDataFile())
- }
- if (config.createTcxFiles) {
- parallelCalls.push(createTcxFile())
- }
- if (config.createRowingDataFiles) {
- parallelCalls.push(createRowingDataFile())
- }
- await Promise.all(parallelCalls)
- }
-
- async function updateHRRecovery (hrmetrics) {
- postExerciseHR = hrmetrics
- createTcxFile()
- }
-
- function minimumRecordingTimeHasPassed () {
- const minimumRecordingTimeInSeconds = 10
- const rotationImpulseTimeTotal = rotationImpulses.reduce((acc, impulse) => acc + impulse, 0)
- const strokeTimeTotal = strokes[strokes.length - 1].totalMovingTime
- return (Math.max(rotationImpulseTimeTotal, strokeTimeTotal) > minimumRecordingTimeInSeconds)
- }
-
- return {
- recordStroke,
- recordRotationImpulse,
- handlePause,
- activeWorkoutToTcx,
- writeRecordings: createRecordings,
- updateHRRecovery,
- reset
- }
-}
-
-export { createWorkoutRecorder }
diff --git a/app/engine/WorkoutUploader.js b/app/engine/WorkoutUploader.js
deleted file mode 100644
index 1f4e4af3b8..0000000000
--- a/app/engine/WorkoutUploader.js
+++ /dev/null
@@ -1,57 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Handles uploading workout data to different cloud providers
-*/
-import log from 'loglevel'
-import EventEmitter from 'events'
-import { createStravaAPI } from '../tools/StravaAPI.js'
-import config from '../tools/ConfigManager.js'
-
-function createWorkoutUploader (workoutRecorder) {
- const emitter = new EventEmitter()
-
- let stravaAuthorizationCodeResolver
- let requestingClient
-
- function getStravaAuthorizationCode () {
- return new Promise((resolve) => {
- emitter.emit('authorizeStrava', { stravaClientId: config.stravaClientId }, requestingClient)
- stravaAuthorizationCodeResolver = resolve
- })
- }
-
- const stravaAPI = createStravaAPI(getStravaAuthorizationCode)
-
- function stravaAuthorizationCode (stravaAuthorizationCode) {
- if (stravaAuthorizationCodeResolver) {
- stravaAuthorizationCodeResolver(stravaAuthorizationCode)
- stravaAuthorizationCodeResolver = undefined
- }
- }
-
- async function upload (client) {
- log.debug('uploading workout to strava...')
- try {
- requestingClient = client
- // todo: we might signal back to the client whether we had success or not
- const tcxActivity = await workoutRecorder.activeWorkoutToTcx()
- if (tcxActivity !== undefined) {
- await stravaAPI.uploadActivityTcx(tcxActivity)
- emitter.emit('resetWorkout')
- } else {
- log.error('can not upload an empty workout to strava')
- }
- } catch (error) {
- log.error('can not upload workout to strava:', error.message)
- }
- }
-
- return Object.assign(emitter, {
- upload,
- stravaAuthorizationCode
- })
-}
-
-export { createWorkoutUploader }
diff --git a/app/engine/utils/BinarySearchTree.js b/app/engine/utils/BinarySearchTree.js
new file mode 100644
index 0000000000..2fb7c1bc8a
--- /dev/null
+++ b/app/engine/utils/BinarySearchTree.js
@@ -0,0 +1,361 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/jaapvanekris/openrowingmonitor
+
+ This creates an ordered series with labels
+ It allows for efficient determining the Median, Number of Above and Below
+*/
+
+export function createLabelledBinarySearchTree () {
+ let tree = null
+
+ function push (label, value) {
+ if (value === undefined || isNaN(value)) { return }
+ if (tree === null) {
+ tree = newNode(label, value)
+ } else {
+ tree = pushInTree(tree, label, value)
+ }
+ }
+
+ function pushInTree (currentTree, label, value) {
+ if (value <= currentTree.value) {
+ // The value should be on the left side of currentTree
+ if (currentTree.leftNode === null) {
+ currentTree.leftNode = newNode(label, value)
+ } else {
+ currentTree.leftNode = pushInTree(currentTree.leftNode, label, value)
+ }
+ } else {
+ // The value should be on the right side of currentTree
+ if (currentTree.rightNode === null) {
+ currentTree.rightNode = newNode(label, value)
+ } else {
+ currentTree.rightNode = pushInTree(currentTree.rightNode, label, value)
+ }
+ }
+ currentTree.numberOfLeafsAndNodes = currentTree.numberOfLeafsAndNodes + 1
+ return currentTree
+ }
+
+ function newNode (label, value) {
+ return {
+ label,
+ value,
+ leftNode: null,
+ rightNode: null,
+ numberOfLeafsAndNodes: 1
+ }
+ }
+
+ function size () {
+ if (tree !== null) {
+ return tree.numberOfLeafsAndNodes
+ } else {
+ return 0
+ }
+ }
+
+ function minimum () {
+ return minimumValueInTree(tree)
+ }
+
+ function minimumValueInTree (subTree) {
+ if (subTree.leftNode === null) {
+ return subTree.value
+ } else {
+ return minimumValueInTree(subTree.leftNode)
+ }
+ }
+
+ function maximum () {
+ return maximumValueInTree(tree)
+ }
+
+ function maximumValueInTree (subTree) {
+ if (subTree.rightNode === null) {
+ return subTree.value
+ } else {
+ return maximumValueInTree(subTree.rightNode)
+ }
+ }
+
+ function numberOfValuesAbove (testedValue) {
+ return countNumberOfValuesAboveInTree(tree, testedValue)
+ }
+
+ function countNumberOfValuesAboveInTree (currentTree, testedValue) {
+ if (currentTree === null) {
+ return 0
+ } else {
+ // We encounter a filled node
+ if (currentTree.value > testedValue) {
+ // testedValue < currentTree.value, so we can find the tested value in the left and right branch
+ return (countNumberOfValuesAboveInTree(currentTree.leftNode, testedValue) + countNumberOfValuesAboveInTree(currentTree.rightNode, testedValue) + 1)
+ } else {
+ // currentTree.value < testedValue, so we need to find values from the right branch
+ return countNumberOfValuesAboveInTree(currentTree.rightNode, testedValue)
+ }
+ }
+ }
+
+ function numberOfValuesEqualOrBelow (testedValue) {
+ return countNumberOfValuesEqualOrBelowInTree(tree, testedValue)
+ }
+
+ function countNumberOfValuesEqualOrBelowInTree (currentTree, testedValue) {
+ if (currentTree === null) {
+ return 0
+ } else {
+ // We encounter a filled node
+ if (currentTree.value <= testedValue) {
+ // testedValue <= currentTree.value, so we can only find the tested value in the left branch
+ return (countNumberOfValuesEqualOrBelowInTree(currentTree.leftNode, testedValue) + countNumberOfValuesEqualOrBelowInTree(currentTree.rightNode, testedValue) + 1)
+ } else {
+ // currentTree.value > testedValue, so we only need to look at the left branch
+ return countNumberOfValuesEqualOrBelowInTree(currentTree.leftNode, testedValue)
+ }
+ }
+ }
+
+ function remove (label) {
+ if (tree !== null) {
+ tree = removeFromTree(tree, label)
+ }
+ }
+
+ function removeFromTree (currentTree, label) {
+ // Clean up the underlying sub-trees first
+ if (currentTree.leftNode !== null) {
+ currentTree.leftNode = removeFromTree(currentTree.leftNode, label)
+ }
+ if (currentTree.rightNode !== null) {
+ currentTree.rightNode = removeFromTree(currentTree.rightNode, label)
+ }
+
+ // Next, handle the situation when we need to remove the node itself
+ if (currentTree.label === label) {
+ // First we need to remove the current node, then we need to investigate the underlying sub-trees to determine how it is resolved
+ // First, release the memory of the current node before we start to rearrange the tree, as this might cause a memory leak
+ currentTree.label = null
+ currentTree.value = null
+ currentTree.numberOfLeafsAndNodes = null
+ switch (true) {
+ case (currentTree.leftNode === null && currentTree.rightNode === null):
+ // As the underlying sub-trees are empty as well, we return an empty tree
+ currentTree = null
+ break
+ case (currentTree.leftNode !== null && currentTree.rightNode === null):
+ // As only the left node contains data, we can simply replace the removed node with the left sub-tree
+ currentTree = currentTree.leftNode
+ break
+ case (currentTree.leftNode === null && currentTree.rightNode !== null):
+ // As only the right node contains data, we can simply replace the removed node with the right sub-tree
+ currentTree = currentTree.rightNode
+ break
+ case (currentTree.leftNode !== null && currentTree.rightNode !== null):
+ // As all underlying sub-trees are filled, we need to move a leaf to the now empty node. Here, we can be a bit smarter
+ // as there are two potential nodes to use, we try to balance the tree a bit more as this increases performance
+ if (currentTree.leftNode.numberOfLeafsAndNodes > currentTree.rightNode.numberOfLeafsAndNodes) {
+ // The left sub-tree is bigger then the right one, lets use the closest predecessor to restore some balance
+ currentTree.value = clostestPredecessor(currentTree.leftNode).value
+ currentTree.label = clostestPredecessor(currentTree.leftNode).label
+ currentTree.leftNode = destroyClostestPredecessor(currentTree.leftNode)
+ } else {
+ // The right sub-tree is smaller then the right one, lets use the closest successor to restore some balance
+ currentTree.value = clostestSuccesor(currentTree.rightNode).value
+ currentTree.label = clostestSuccesor(currentTree.rightNode).label
+ currentTree.rightNode = destroyClostestSuccessor(currentTree.rightNode)
+ }
+ break
+ // no default
+ }
+ }
+
+ // Recalculate the tree size
+ switch (true) {
+ case (currentTree === null):
+ // We are now an empty leaf, nothing to do here
+ break
+ case (currentTree.leftNode === null && currentTree.rightNode === null):
+ // This is a filled leaf
+ currentTree.numberOfLeafsAndNodes = 1
+ break
+ case (currentTree.leftNode !== null && currentTree.rightNode === null):
+ currentTree.numberOfLeafsAndNodes = currentTree.leftNode.numberOfLeafsAndNodes + 1
+ break
+ case (currentTree.leftNode === null && currentTree.rightNode !== null):
+ currentTree.numberOfLeafsAndNodes = currentTree.rightNode.numberOfLeafsAndNodes + 1
+ break
+ case (currentTree.leftNode !== null && currentTree.rightNode !== null):
+ currentTree.numberOfLeafsAndNodes = currentTree.leftNode.numberOfLeafsAndNodes + currentTree.rightNode.numberOfLeafsAndNodes + 1
+ break
+ // no default
+ }
+ return currentTree
+ }
+
+ function clostestPredecessor (currentTree) {
+ // This function finds the maximum value in a tree
+ if (currentTree.rightNode !== null) {
+ // We haven't reached the end of the tree yet
+ return clostestPredecessor(currentTree.rightNode)
+ } else {
+ // We reached the largest value in the tree
+ return {
+ label: currentTree.label,
+ value: currentTree.value
+ }
+ }
+ }
+
+ function destroyClostestPredecessor (currentTree) {
+ // This function finds the maximum value in a tree
+ if (currentTree.rightNode !== null) {
+ // We haven't reached the end of the tree yet
+ currentTree.rightNode = destroyClostestPredecessor(currentTree.rightNode)
+ currentTree.numberOfLeafsAndNodes = currentTree.numberOfLeafsAndNodes - 1
+ return currentTree
+ } else {
+ // We reached the largest value in the tree
+ // First, release the memory of the current node before we start to rearrange the tree, as this might cause a memory leak
+ currentTree.label = null
+ currentTree.value = null
+ currentTree.numberOfLeafsAndNodes = null
+ return currentTree.leftNode
+ }
+ }
+
+ function clostestSuccesor (currentTree) {
+ // This function finds the maximum value in a tree
+ if (currentTree.leftNode !== null) {
+ // We haven't reached the end of the tree yet
+ return clostestSuccesor(currentTree.leftNode)
+ } else {
+ // We reached the smallest value in the tree
+ return {
+ label: currentTree.label,
+ value: currentTree.value
+ }
+ }
+ }
+
+ function destroyClostestSuccessor (currentTree) {
+ // This function finds the maximum value in a tree
+ if (currentTree.leftNode !== null) {
+ // We haven't reached the end of the tree yet
+ currentTree.leftNode = destroyClostestSuccessor(currentTree.leftNode)
+ currentTree.numberOfLeafsAndNodes = currentTree.numberOfLeafsAndNodes - 1
+ return currentTree
+ } else {
+ // We reached the smallest value in the tree
+ // First, release the memory of the current node before we start to rearrange the tree, as this might cause a memory leak
+ currentTree.label = null
+ currentTree.value = null
+ currentTree.numberOfLeafsAndNodes = null
+ return currentTree.rightNode
+ }
+ }
+
+ function median () {
+ if (tree !== null && tree.numberOfLeafsAndNodes > 0) {
+ // BE AWARE, UNLIKE WITH ARRAYS, THE COUNTING OF THE ELEMENTS STARTS WITH 1 !!!!!!!
+ // THIS LOGIC THUS WORKS DIFFERENT THAN MOST ARRAYS FOUND IN ORM!!!!!!!
+ const mid = Math.floor(tree.numberOfLeafsAndNodes / 2)
+ return tree.numberOfLeafsAndNodes % 2 !== 0 ? valueAtInorderPosition(tree, mid + 1) : (valueAtInorderPosition(tree, mid) + valueAtInorderPosition(tree, mid + 1)) / 2
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @remark: // BE AWARE TESTING PURPOSSES ONLY
+ */
+ function valueAtInorderPos (position) {
+ if (tree !== null && position >= 1) {
+ return valueAtInorderPosition(tree, position)
+ } else {
+ return undefined
+ }
+ }
+
+ function valueAtInorderPosition (currentTree, position) {
+ let currentNodePosition
+ if (currentTree === null) {
+ // We are now an empty tree, this shouldn't happen
+ return undefined
+ }
+
+ // First we need to find out what the InOrder Postion we currently are at
+ if (currentTree.leftNode !== null) {
+ currentNodePosition = currentTree.leftNode.numberOfLeafsAndNodes + 1
+ } else {
+ currentNodePosition = 1
+ }
+
+ switch (true) {
+ case (position === currentNodePosition):
+ // The current position is the one we are looking for
+ return currentTree.value
+ case (currentTree.leftNode === null):
+ // The current node's left side is empty, but position <> currentNodePosition, so we have no choice but to move downwards
+ return valueAtInorderPosition(currentTree.rightNode, (position - 1))
+ case (currentTree.leftNode !== null && currentNodePosition > position):
+ // The position we look for is in the left side of the currentTree
+ return valueAtInorderPosition(currentTree.leftNode, position)
+ case (currentTree.leftNode !== null && currentNodePosition < position && currentTree.rightNode !== null):
+ // The position we look for is in the right side of the currentTree
+ return valueAtInorderPosition(currentTree.rightNode, (position - currentNodePosition))
+ default:
+ return undefined
+ }
+ }
+
+ function orderedSeries () {
+ return orderedTree(tree)
+ }
+
+ function orderedTree (currentTree) {
+ if (currentTree === null) {
+ return []
+ } else {
+ // We encounter a filled node
+ return [...orderedTree(currentTree.leftNode), currentTree.value, ...orderedTree(currentTree.rightNode)]
+ }
+ }
+
+ function reset () {
+ resetTree(tree)
+ tree = null
+ }
+
+ function resetTree (currentTree) {
+ if (currentTree !== null) {
+ currentTree.label = null
+ currentTree.value = null
+ if (currentTree.leftNode !== null) {
+ resetTree(currentTree.leftNode)
+ currentTree.leftNode = null
+ }
+ if (currentTree.rightNode !== null) {
+ resetTree(currentTree.rightNode)
+ currentTree.rightNode = null
+ }
+ currentTree.numberOfLeafsAndNodes = null
+ }
+ }
+
+ return {
+ push,
+ remove,
+ size,
+ numberOfValuesAbove,
+ numberOfValuesEqualOrBelow,
+ minimum,
+ maximum,
+ median,
+ valueAtInorderPos,
+ orderedSeries,
+ reset
+ }
+}
diff --git a/app/engine/utils/BinarySearchTree.test.js b/app/engine/utils/BinarySearchTree.test.js
new file mode 100644
index 0000000000..e7b8e541ae
--- /dev/null
+++ b/app/engine/utils/BinarySearchTree.test.js
@@ -0,0 +1,207 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ As this object is fundamental for most other utility objects, we must test its behaviour quite thoroughly
+*/
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+
+import { createLabelledBinarySearchTree } from './BinarySearchTree.js'
+
+test('Series behaviour with an empty tree', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ testSize(dataTree, 0)
+ testNumberOfValuesAbove(dataTree, 0, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 0)
+ testNumberOfValuesAbove(dataTree, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 0)
+ testMedian(dataTree, 0)
+})
+
+test('Tree behaviour with a single pushed value. Tree = [9]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9)
+ testOrderedSeries(dataTree, [9])
+ testSize(dataTree, 1)
+ testValueAtInorderPos(dataTree, 1, 9)
+ testNumberOfValuesAbove(dataTree, 0, 1)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 0)
+ testNumberOfValuesAbove(dataTree, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 1)
+ testMedian(dataTree, 9)
+})
+
+test('Tree behaviour with a second pushed value. Tree = [9, 3]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9)
+ dataTree.push(2, 3)
+ testOrderedSeries(dataTree, [3, 9])
+ testSize(dataTree, 2)
+ testValueAtInorderPos(dataTree, 1, 3)
+ testValueAtInorderPos(dataTree, 2, 9)
+ testNumberOfValuesAbove(dataTree, 0, 2)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 0)
+ testNumberOfValuesAbove(dataTree, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 2)
+ testMedian(dataTree, 6)
+})
+
+test('Tree behaviour with a third pushed value. Tree = [9, 3, 6]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9)
+ dataTree.push(2, 3)
+ dataTree.push(3, 6)
+ testOrderedSeries(dataTree, [3, 6, 9])
+ testSize(dataTree, 3)
+ testValueAtInorderPos(dataTree, 1, 3)
+ testValueAtInorderPos(dataTree, 2, 6)
+ testValueAtInorderPos(dataTree, 3, 9)
+ testNumberOfValuesAbove(dataTree, 0, 3)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 0)
+ testNumberOfValuesAbove(dataTree, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 3)
+ testMedian(dataTree, 6)
+})
+
+test('Tree behaviour with a fourth pushed value. Tree = [3, 6, 12]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9)
+ dataTree.push(2, 3)
+ dataTree.push(3, 6)
+ dataTree.remove(1)
+ dataTree.push(4, 12)
+ testOrderedSeries(dataTree, [3, 6, 12])
+ testSize(dataTree, 3)
+ testValueAtInorderPos(dataTree, 1, 3)
+ testValueAtInorderPos(dataTree, 2, 6)
+ testValueAtInorderPos(dataTree, 3, 12)
+ testNumberOfValuesAbove(dataTree, 0, 3)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 0)
+ testNumberOfValuesAbove(dataTree, 10, 1)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 2)
+ testMedian(dataTree, 6)
+})
+
+test('Tree behaviour with a fifth pushed value. Series = [6, 12, -3]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9)
+ dataTree.push(2, 3)
+ dataTree.push(3, 6)
+ dataTree.remove(1)
+ dataTree.push(4, 12)
+ dataTree.remove(2)
+ dataTree.push(5, -3)
+ testOrderedSeries(dataTree, [-3, 6, 12])
+ testSize(dataTree, 3)
+ testValueAtInorderPos(dataTree, 1, -3)
+ testValueAtInorderPos(dataTree, 2, 6)
+ testValueAtInorderPos(dataTree, 3, 12)
+ testNumberOfValuesAbove(dataTree, 0, 2)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 1)
+ testNumberOfValuesAbove(dataTree, 10, 1)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 2)
+ testMedian(dataTree, 6)
+})
+
+test('Tree behaviour with complex removals. Series = [9, 6, 5, 8, 7, 9, 12, 10, 11]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9)
+ dataTree.push(2, 6)
+ dataTree.push(3, 5)
+ dataTree.push(4, 8)
+ dataTree.push(5, 7)
+ dataTree.push(6, 9)
+ dataTree.push(7, 12)
+ dataTree.push(8, 10)
+ dataTree.push(9, 11)
+ testOrderedSeries(dataTree, [5, 6, 7, 8, 9, 9, 10, 11, 12])
+ testSize(dataTree, 9)
+ testValueAtInorderPos(dataTree, 5, 9)
+ testMedian(dataTree, 9)
+ dataTree.remove(1)
+ testOrderedSeries(dataTree, [5, 6, 7, 8, 9, 10, 11, 12])
+ testSize(dataTree, 8)
+ testValueAtInorderPos(dataTree, 4, 8)
+ testValueAtInorderPos(dataTree, 5, 9)
+ testMedian(dataTree, 8.5)
+ dataTree.remove(3)
+ testOrderedSeries(dataTree, [6, 7, 8, 9, 10, 11, 12])
+ testSize(dataTree, 7)
+ testValueAtInorderPos(dataTree, 4, 9)
+ testMedian(dataTree, 9)
+})
+
+// Test based on https://levelup.gitconnected.com/deletion-in-binary-search-tree-with-javascript-fded82e1791c
+test('Tree behaviour with complex removals. Series = [50, 30, 70, 20, 40, 60, 80]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 50)
+ dataTree.push(2, 30)
+ dataTree.push(3, 70)
+ dataTree.push(4, 20)
+ dataTree.push(5, 40)
+ dataTree.push(6, 60)
+ dataTree.push(7, 80)
+ testOrderedSeries(dataTree, [20, 30, 40, 50, 60, 70, 80])
+ testSize(dataTree, 7)
+ testValueAtInorderPos(dataTree, 4, 50)
+ dataTree.remove(4)
+ testOrderedSeries(dataTree, [30, 40, 50, 60, 70, 80])
+ testSize(dataTree, 6)
+ testValueAtInorderPos(dataTree, 3, 50)
+ testValueAtInorderPos(dataTree, 4, 60)
+ testMedian(dataTree, 55)
+ dataTree.remove(2)
+ testOrderedSeries(dataTree, [40, 50, 60, 70, 80])
+ testSize(dataTree, 5)
+ testValueAtInorderPos(dataTree, 3, 60)
+ testMedian(dataTree, 60)
+ dataTree.remove(1)
+ testOrderedSeries(dataTree, [40, 60, 70, 80])
+ testSize(dataTree, 4)
+ testValueAtInorderPos(dataTree, 2, 60)
+ testValueAtInorderPos(dataTree, 3, 70)
+ testMedian(dataTree, 65)
+})
+
+test('Tree behaviour with a five pushed values followed by a reset, Tree = []', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9)
+ dataTree.push(2, 3)
+ dataTree.push(3, 6)
+ dataTree.push(4, 12)
+ dataTree.push(5, -3)
+ dataTree.reset()
+ testSize(dataTree, 0)
+ testNumberOfValuesAbove(dataTree, 0, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 0)
+ testNumberOfValuesAbove(dataTree, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 0)
+ testMedian(dataTree, 0)
+})
+
+function testSize (tree, expectedValue) {
+ assert.ok(tree.size() === expectedValue, `Expected size should be ${expectedValue}, encountered ${tree.size()}`)
+}
+
+function testNumberOfValuesAbove (tree, cutoff, expectedValue) {
+ assert.ok(tree.numberOfValuesAbove(cutoff) === expectedValue, `Expected numberOfValuesAbove(${cutoff}) to be ${expectedValue}, encountered ${tree.numberOfValuesAbove(cutoff)}`)
+}
+
+function testNumberOfValuesEqualOrBelow (tree, cutoff, expectedValue) {
+ assert.ok(tree.numberOfValuesEqualOrBelow(cutoff) === expectedValue, `Expected numberOfValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered ${tree.numberOfValuesEqualOrBelow(cutoff)}`)
+}
+
+function testOrderedSeries (tree, expectedValue) {
+ assert.ok(tree.orderedSeries().toString() === expectedValue.toString(), `Expected ordered series to be ${expectedValue}, encountered ${tree.orderedSeries()}`)
+}
+
+function testValueAtInorderPos (tree, position, expectedValue) {
+ assert.ok(tree.valueAtInorderPos(position) === expectedValue, `Expected valueAtInorderPos(${position}) to be ${expectedValue}, encountered ${tree.valueAtInorderPos(position)}`)
+}
+
+function testMedian (tree, expectedValue) {
+ assert.ok(tree.median() === expectedValue, `Expected median to be ${expectedValue}, encountered ${tree.median()}`)
+}
+
+test.run()
diff --git a/app/engine/utils/CurveAligner.js b/app/engine/utils/CurveAligner.js
index adff69c346..11109a8aba 100644
--- a/app/engine/utils/CurveAligner.js
+++ b/app/engine/utils/CurveAligner.js
@@ -1,11 +1,11 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
This keeps an array, for ForceMetrics, and cleans it up
*/
-function createCurveAligner (minimumValue) {
+export function createCurveAligner (minimumValue) {
let _lastCompleteCurve = []
function push (curve) {
@@ -30,7 +30,8 @@ function createCurveAligner (minimumValue) {
}
function reset () {
- _lastCompleteCurve.splice(0, _lastCompleteCurve.length)
+ _lastCompleteCurve = null
+ _lastCompleteCurve = []
}
return {
@@ -39,5 +40,3 @@ function createCurveAligner (minimumValue) {
reset
}
}
-
-export { createCurveAligner }
diff --git a/app/engine/utils/FullTSLinearSeries.js b/app/engine/utils/FullTSLinearSeries.js
index b0e9c3d4b4..3cef184821 100644
--- a/app/engine/utils/FullTSLinearSeries.js
+++ b/app/engine/utils/FullTSLinearSeries.js
@@ -1,17 +1,21 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
The TSLinearSeries is a datatype that represents a Linear Series. It allows
values to be retrieved (like a FiFo buffer, or Queue) but it also includes
- a Theil–Sen estimator Linear Regressor to determine the slope of this timeseries.
+ a Theil-Sen estimator Linear Regressor to determine the slope of this timeseries.
At creation its length is determined. After it is filled, the oldest will be pushed
- out of the queue) automatically.
+ out of the queue) automatically. This is a property of the Series object
A key constraint is to prevent heavy calculations at the end (due to large
array based curve fitting), which might happen on a Pi zero
+ In order to prevent unneccessary calculations, this implementation uses lazy evaluation,
+ so it will calculate the intercept and goodnessOfFit only when needed, as many uses only
+ (first) need the slope.
+
This implementation uses concepts that are described here:
https://en.wikipedia.org/wiki/Theil%E2%80%93Sen_estimator
@@ -19,54 +23,55 @@
*/
import { createSeries } from './Series.js'
+import { createLabelledBinarySearchTree } from './BinarySearchTree.js'
import loglevel from 'loglevel'
const log = loglevel.getLogger('RowingEngine')
-function createTSLinearSeries (maxSeriesLength = 0) {
+export function createTSLinearSeries (maxSeriesLength = 0) {
const X = createSeries(maxSeriesLength)
const Y = createSeries(maxSeriesLength)
- const slopes = []
+ const A = createLabelledBinarySearchTree()
let _A = 0
let _B = 0
let _goodnessOfFit = 0
function push (x, y) {
- X.push(x)
- Y.push(y)
-
- if (maxSeriesLength > 0 && slopes.length >= maxSeriesLength) {
- // The maximum of the array has been reached, we have to create room
- // in the 2D array by removing the first row from the table
- removeFirstRow()
+ // Invariant: A contains all a's (as in the general formula y = a * x + b)
+ // Where the a's are labeled in the Binary Search Tree with their xi when they BEGIN in the point (xi, yi)
+ if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return }
+
+ if (maxSeriesLength > 0 && X.length() >= maxSeriesLength) {
+ // The maximum of the array has been reached, so when pushing the x,y the array gets shifted,
+ // thus we have to remove the a's belonging to the current position X0 as well before this value is trashed
+ A.remove(X.get(0))
}
- // Invariant: the indices of the X and Y array now match up with the
- // row numbers of the slopes array. So, the slope of (X[0],Y[0]) and (X[1],Y[1]
- // will be stored in slopes[0][.].
+ X.push(x)
+ Y.push(y)
- // Calculate the slopes of this new point
+ // Calculate all the slopes of the newly added point
if (X.length() > 1) {
// There are at least two points in the X and Y arrays, so let's add the new datapoint
let i = 0
- let result = 0
- while (i < slopes.length) {
- result = calculateSlope(i, slopes.length)
- slopes[i].push(result)
+ while (i < X.length() - 1) {
+ // Calculate the slope with all preceeding datapoints and X.length() - 1'th datapoint (as the array starts at zero)
+ A.push(X.get(i), calculateSlope(i, X.length() - 1))
i++
}
}
- // Add an empty array at the end to store futurs results for the most recent points
- slopes.push([])
// Calculate the median of the slopes
if (X.length() > 1) {
- _A = median()
+ _A = A.median()
} else {
_A = 0
}
- _B = Y.average() - (_A * X.average())
+
+ // Invalidate the previously calculated intercept and goodnessOfFit. We'll only calculate them if we need them
+ _B = null
+ _goodnessOfFit = null
}
function slope () {
@@ -74,6 +79,7 @@ function createTSLinearSeries (maxSeriesLength = 0) {
}
function intercept () {
+ calculateIntercept()
return _B
}
@@ -84,6 +90,7 @@ function createTSLinearSeries (maxSeriesLength = 0) {
function coefficientB () {
// For testing purposses only!
+ calculateIntercept()
return _B
}
@@ -93,15 +100,43 @@ function createTSLinearSeries (maxSeriesLength = 0) {
function goodnessOfFit () {
// This function returns the R^2 as a goodness of fit indicator
- if (X.length() >= 2) {
- return _goodnessOfFit
- } else {
- return 0
+ // It will automatically recalculate the _goodnessOfFit when it isn't defined
+ // This lazy approach is intended to prevent unneccesary calculations
+ let i = 0
+ let sse = 0
+ let sst = 0
+ if (_goodnessOfFit === null) {
+ if (X.length() >= 2) {
+ while (i < X.length()) {
+ sse += Math.pow((Y.get(i) - projectX(X.get(i))), 2)
+ sst += Math.pow((Y.get(i) - Y.average()), 2)
+ i++
+ }
+ switch (true) {
+ case (sse === 0):
+ _goodnessOfFit = 1
+ break
+ case (sse > sst):
+ // This is a pretty bad fit as the error is bigger than just using the line for the average y as intercept
+ _goodnessOfFit = 0
+ break
+ case (sst !== 0):
+ _goodnessOfFit = 1 - (sse / sst)
+ break
+ default:
+ // When SST = 0, R2 isn't defined
+ _goodnessOfFit = 0
+ }
+ } else {
+ _goodnessOfFit = 0
+ }
}
+ return _goodnessOfFit
}
function projectX (x) {
if (X.length() >= 2) {
+ calculateIntercept()
return (_A * x) + _B
} else {
return 0
@@ -110,64 +145,14 @@ function createTSLinearSeries (maxSeriesLength = 0) {
function projectY (y) {
if (X.length() >= 2 && _A !== 0) {
+ calculateIntercept()
return ((y - _B) / _A)
} else {
+ log.error('TS Linear Regressor, attempted a Y-projection while slope was zero!')
return 0
}
}
- function numberOfXValuesAbove (testedValue) {
- return X.numberOfValuesAbove(testedValue)
- }
-
- function numberOfXValuesEqualOrBelow (testedValue) {
- return X.numberOfValuesEqualOrBelow(testedValue)
- }
-
- function numberOfYValuesAbove (testedValue) {
- return Y.numberOfValuesAbove(testedValue)
- }
-
- function numberOfYValuesEqualOrBelow (testedValue) {
- return Y.numberOfValuesEqualOrBelow(testedValue)
- }
-
- function xAtSeriesBegin () {
- return X.atSeriesBegin()
- }
-
- function xAtSeriesEnd () {
- return X.atSeriesEnd()
- }
-
- function yAtSeriesBegin () {
- return Y.atSeriesBegin()
- }
-
- function yAtSeriesEnd () {
- return Y.atSeriesEnd()
- }
-
- function xSum () {
- return X.sum()
- }
-
- function ySum () {
- return Y.sum()
- }
-
- function xSeries () {
- return X.series()
- }
-
- function ySeries () {
- return Y.series()
- }
-
- function removeFirstRow () {
- slopes.shift()
- }
-
function calculateSlope (pointOne, pointTwo) {
if (pointOne !== pointTwo && X.get(pointOne) !== X.get(pointTwo)) {
return ((Y.get(pointTwo) - Y.get(pointOne)) / (X.get(pointTwo) - X.get(pointOne)))
@@ -177,28 +162,47 @@ function createTSLinearSeries (maxSeriesLength = 0) {
}
}
- function median () {
- if (slopes.length > 1) {
- const sortedArray = [...slopes.flat()].sort((a, b) => a - b)
- const mid = Math.floor(sortedArray.length / 2)
- return (sortedArray.length % 2 !== 0 ? sortedArray[mid] : ((sortedArray[mid - 1] + sortedArray[mid]) / 2))
- } else {
- log.eror('TS Linear Regressor, Median calculation on empty dataset attempted!')
- return 0
+ function calculateIntercept () {
+ // Calculate all the intercepts for the newly added point and the newly calculated A, when needed
+ // This function is only called when an intercept is really needed, as this saves a lot of CPU cycles when only a slope suffices
+ const B = createLabelledBinarySearchTree()
+ if (_B === null) {
+ if (X.length() > 1) {
+ // There are at least two points in the X and Y arrays, so let's calculate the intercept
+ let i = 0
+ while (i < X.length()) {
+ // Please note , as we need to recreate the B-tree for each newly added datapoint anyway, the label i isn't relevant
+ B.push(i, (Y.get(i) - (_A * X.get(i))))
+ i++
+ }
+ _B = B.median()
+ } else {
+ _B = 0
+ }
}
+ B.reset()
+ }
+
+ function reliable () {
+ return (X.length() >= 2)
}
function reset () {
- X.reset()
- Y.reset()
- slopes.splice(0, slopes.length)
- _A = 0
- _B = 0
- _goodnessOfFit = 0
+ if (X.length() > 0) {
+ // There is something to reset
+ X.reset()
+ Y.reset()
+ A.reset()
+ _A = 0
+ _B = 0
+ _goodnessOfFit = 0
+ }
}
return {
push,
+ X,
+ Y,
slope,
intercept,
coefficientA,
@@ -207,20 +211,7 @@ function createTSLinearSeries (maxSeriesLength = 0) {
goodnessOfFit,
projectX,
projectY,
- numberOfXValuesAbove,
- numberOfXValuesEqualOrBelow,
- numberOfYValuesAbove,
- numberOfYValuesEqualOrBelow,
- xAtSeriesBegin,
- xAtSeriesEnd,
- yAtSeriesBegin,
- yAtSeriesEnd,
- xSum,
- ySum,
- xSeries,
- ySeries,
+ reliable,
reset
}
}
-
-export { createTSLinearSeries }
diff --git a/app/engine/utils/FullTSLinearSeries.test.js b/app/engine/utils/FullTSLinearSeries.test.js
new file mode 100644
index 0000000000..b0c29955c2
--- /dev/null
+++ b/app/engine/utils/FullTSLinearSeries.test.js
@@ -0,0 +1,268 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+
+import { createTSLinearSeries } from './FullTSLinearSeries.js'
+
+test('Correct behaviour of a series after initialisation', () => {
+ const dataSeries = createTSLinearSeries(3)
+ testLength(dataSeries, 0)
+ testXAtSeriesBegin(dataSeries, 0)
+ testYAtSeriesBegin(dataSeries, 0)
+ testXAtSeriesEnd(dataSeries, 0)
+ testYAtSeriesEnd(dataSeries, 0)
+ testNumberOfXValuesAbove(dataSeries, 0, 0)
+ testNumberOfYValuesAbove(dataSeries, 0, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 0)
+ testXSum(dataSeries, 0)
+ testYSum(dataSeries, 0)
+ testSlopeEquals(dataSeries, 0)
+ testInterceptEquals(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 1 datapoint', () => {
+ const dataSeries = createTSLinearSeries(3)
+ testLength(dataSeries, 0)
+ dataSeries.push(5, 9)
+ testLength(dataSeries, 1)
+ testXAtSeriesBegin(dataSeries, 5)
+ testYAtSeriesBegin(dataSeries, 9)
+ testXAtSeriesEnd(dataSeries, 5)
+ testYAtSeriesEnd(dataSeries, 9)
+ testNumberOfXValuesAbove(dataSeries, 0, 1)
+ testNumberOfYValuesAbove(dataSeries, 0, 1)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 1)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 1)
+ testXSum(dataSeries, 5)
+ testYSum(dataSeries, 9)
+ testSlopeEquals(dataSeries, 0)
+ testInterceptEquals(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 2 datapoints', () => {
+ const dataSeries = createTSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ testLength(dataSeries, 2)
+ testXAtSeriesBegin(dataSeries, 5)
+ testYAtSeriesBegin(dataSeries, 9)
+ testXAtSeriesEnd(dataSeries, 3)
+ testYAtSeriesEnd(dataSeries, 3)
+ testNumberOfXValuesAbove(dataSeries, 0, 2)
+ testNumberOfYValuesAbove(dataSeries, 0, 2)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 2)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 2)
+ testXSum(dataSeries, 8)
+ testYSum(dataSeries, 12)
+ testSlopeEquals(dataSeries, 3)
+ testInterceptEquals(dataSeries, -6)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 3 datapoints', () => {
+ const dataSeries = createTSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ dataSeries.push(4, 6)
+ testLength(dataSeries, 3)
+ testXAtSeriesBegin(dataSeries, 5)
+ testYAtSeriesBegin(dataSeries, 9)
+ testXAtSeriesEnd(dataSeries, 4)
+ testYAtSeriesEnd(dataSeries, 6)
+ testNumberOfXValuesAbove(dataSeries, 0, 3)
+ testNumberOfYValuesAbove(dataSeries, 0, 3)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 3)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 3)
+ testXSum(dataSeries, 12)
+ testYSum(dataSeries, 18)
+ testSlopeEquals(dataSeries, 3)
+ testInterceptEquals(dataSeries, -6)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints', () => {
+ const dataSeries = createTSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ dataSeries.push(4, 6)
+ dataSeries.push(6, 12)
+ testLength(dataSeries, 3)
+ testXAtSeriesBegin(dataSeries, 3)
+ testYAtSeriesBegin(dataSeries, 3)
+ testXAtSeriesEnd(dataSeries, 6)
+ testYAtSeriesEnd(dataSeries, 12)
+ testNumberOfXValuesAbove(dataSeries, 0, 3)
+ testNumberOfYValuesAbove(dataSeries, 0, 3)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 1)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 3)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 2)
+ testXSum(dataSeries, 13)
+ testYSum(dataSeries, 21)
+ testSlopeEquals(dataSeries, 3)
+ testInterceptEquals(dataSeries, -6)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 5 datapoints', () => {
+ const dataSeries = createTSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ dataSeries.push(4, 6)
+ dataSeries.push(6, 12)
+ dataSeries.push(1, -3)
+ testLength(dataSeries, 3)
+ testXAtSeriesBegin(dataSeries, 4)
+ testYAtSeriesBegin(dataSeries, 6)
+ testXAtSeriesEnd(dataSeries, 1)
+ testYAtSeriesEnd(dataSeries, -3)
+ testNumberOfXValuesAbove(dataSeries, 0, 3)
+ testNumberOfYValuesAbove(dataSeries, 0, 2)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 1)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 1)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 3)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 2)
+ testXSum(dataSeries, 11)
+ testYSum(dataSeries, 15)
+ testSlopeEquals(dataSeries, 3)
+ testInterceptEquals(dataSeries, -6)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints and a reset', () => {
+ const dataSeries = createTSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ dataSeries.push(4, 6)
+ dataSeries.push(6, 12)
+ dataSeries.reset()
+ testLength(dataSeries, 0)
+ testXAtSeriesBegin(dataSeries, 0)
+ testYAtSeriesBegin(dataSeries, 0)
+ testXAtSeriesEnd(dataSeries, 0)
+ testYAtSeriesEnd(dataSeries, 0)
+ testNumberOfXValuesAbove(dataSeries, 0, 0)
+ testNumberOfYValuesAbove(dataSeries, 0, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 0)
+ testXSum(dataSeries, 0)
+ testYSum(dataSeries, 0)
+ testSlopeEquals(dataSeries, 0)
+ testInterceptEquals(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
+})
+
+test('Series with 5 elements, with 2 noisy datapoints', () => {
+ const dataSeries = createTSLinearSeries(5)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 2)
+ dataSeries.push(4, 7)
+ dataSeries.push(6, 12)
+ dataSeries.push(1, -3)
+ testSlopeBetween(dataSeries, 2.9, 3.1)
+ testInterceptBetween(dataSeries, -6.3, -5.8)
+ testGoodnessOfFitBetween(dataSeries, 0.9, 1.0)
+})
+
+function testLength (series, expectedValue) {
+ assert.ok(series.length() === expectedValue, `Expected length should be ${expectedValue}, encountered a ${series.length()}`)
+}
+
+function testXAtSeriesBegin (series, expectedValue) {
+ assert.ok(series.X.atSeriesBegin() === expectedValue, `Expected X.atSeriesBegin to be ${expectedValue}, encountered a ${series.X.atSeriesBegin()}`)
+}
+
+function testYAtSeriesBegin (series, expectedValue) {
+ assert.ok(series.Y.atSeriesBegin() === expectedValue, `Expected Y.atSeriesBegin to be ${expectedValue}, encountered a ${series.Y.atSeriesBegin()}`)
+}
+
+function testXAtSeriesEnd (series, expectedValue) {
+ assert.ok(series.X.atSeriesEnd() === expectedValue, `Expected X.atSeriesEnd to be ${expectedValue}, encountered a ${series.X.atSeriesEnd()}`)
+}
+
+function testYAtSeriesEnd (series, expectedValue) {
+ assert.ok(series.Y.atSeriesEnd() === expectedValue, `Expected Y.atSeriesEnd to be ${expectedValue}, encountered a ${series.Y.atSeriesEnd()}`)
+}
+
+function testNumberOfXValuesAbove (series, cutoff, expectedValue) {
+ assert.ok(series.X.numberOfValuesAbove(cutoff) === expectedValue, `Expected X.numberOfValuesAbove(${cutoff}) to be ${expectedValue}, encountered a ${series.X.numberOfValuesAbove(cutoff)}`)
+}
+
+function testNumberOfYValuesAbove (series, cutoff, expectedValue) {
+ assert.ok(series.Y.numberOfValuesAbove(cutoff) === expectedValue, `Expected Y.numberOfValuesAbove(${cutoff}) to be ${expectedValue}, encountered a ${series.Y.numberOfValuesAbove(cutoff)}`)
+}
+
+function testNumberOfXValuesEqualOrBelow (series, cutoff, expectedValue) {
+ assert.ok(series.X.numberOfValuesEqualOrBelow(cutoff) === expectedValue, `Expected X.numberOfValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered a ${series.X.numberOfValuesEqualOrBelow(cutoff)}`)
+}
+
+function testNumberOfYValuesEqualOrBelow (series, cutoff, expectedValue) {
+ assert.ok(series.Y.numberOfValuesEqualOrBelow(cutoff) === expectedValue, `Expected Y.numberOfValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered a ${series.Y.numberOfValuesEqualOrBelow(cutoff)}`)
+}
+
+function testXSum (series, expectedValue) {
+ assert.ok(series.X.sum() === expectedValue, `Expected X.sum to be ${expectedValue}, encountered a ${series.X.sum()}`)
+}
+
+function testYSum (series, expectedValue) {
+ assert.ok(series.Y.sum() === expectedValue, `Expected Y.sum to be ${expectedValue}, encountered a ${series.Y.sum()}`)
+}
+
+function testSlopeEquals (series, expectedValue) {
+ assert.ok(series.slope() === expectedValue, `Expected slope to be ${expectedValue}, encountered a ${series.slope()}`)
+}
+
+function testSlopeBetween (series, expectedValueAbove, expectedValueBelow) {
+ assert.ok(series.slope() > expectedValueAbove, `Expected slope to be above ${expectedValueAbove}, encountered a ${series.slope()}`)
+ assert.ok(series.slope() < expectedValueBelow, `Expected slope to be below ${expectedValueBelow}, encountered a ${series.slope()}`)
+}
+
+function testInterceptEquals (series, expectedValue) {
+ assert.ok(series.intercept() === expectedValue, `Expected intercept to be ${expectedValue}, encountered ${series.intercept()}`)
+}
+
+function testInterceptBetween (series, expectedValueAbove, expectedValueBelow) {
+ assert.ok(series.intercept() > expectedValueAbove, `Expected intercept to be above ${expectedValueAbove}, encountered ${series.intercept()}`)
+ assert.ok(series.intercept() < expectedValueBelow, `Expected intercept to be below ${expectedValueBelow}, encountered ${series.intercept()}`)
+}
+
+function testGoodnessOfFitEquals (series, expectedValue) {
+ assert.ok(series.goodnessOfFit() === expectedValue, `Expected goodnessOfFit to be ${expectedValue}, encountered ${series.goodnessOfFit()}`)
+}
+
+function testGoodnessOfFitBetween (series, expectedValueAbove, expectedValueBelow) {
+ assert.ok(series.goodnessOfFit() > expectedValueAbove, `Expected goodnessOfFit to be above ${expectedValueAbove}, encountered ${series.goodnessOfFit()}`)
+ assert.ok(series.goodnessOfFit() < expectedValueBelow, `Expected goodnessOfFit to be below ${expectedValueBelow}, encountered ${series.goodnessOfFit()}`)
+}
+
+test.run()
diff --git a/app/engine/utils/FullTSQuadraticSeries.js b/app/engine/utils/FullTSQuadraticSeries.js
index a5a9ede858..138a899715 100644
--- a/app/engine/utils/FullTSQuadraticSeries.js
+++ b/app/engine/utils/FullTSQuadraticSeries.js
@@ -1,85 +1,95 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
- The TSLinearSeries is a datatype that represents a Quadratic Series. It allows
+ The FullTSQuadraticSeries is a datatype that represents a Quadratic Series. It allows
values to be retrieved (like a FiFo buffer, or Queue) but it also includes
a Theil-Sen Quadratic Regressor to determine the coefficients of this dataseries.
At creation its length is determined. After it is filled, the oldest will be pushed
out of the queue) automatically.
- A key constraint is to prevent heavy calculations at the end (due to large
- array based curve fitting), which might be performed on a Pi zero
+ A key constraint is to prevent heavy calculations at the end of a stroke (due to large
+ array based curve fitting), which might be performed on a Pi zero or Zero 2W
+
+ In order to prevent unneccessary calculations, this implementation uses lazy evaluation,
+ so it will calculate the B, C and goodnessOfFit only when needed, as many uses only
+ (first) need the first and second direvative.
The Theil-Senn implementation uses concepts that are described here:
https://stats.stackexchange.com/questions/317777/theil-sen-estimator-for-polynomial,
- The determination of the coefficients is based on the math descirbed here:
+ The determination of the coefficients is based on the Lagrange interpolation, which is descirbed here:
https://www.quora.com/How-do-I-find-a-quadratic-equation-from-points/answer/Robert-Paxson,
https://www.physicsforums.com/threads/quadratic-equation-from-3-points.404174/
*/
import { createSeries } from './Series.js'
import { createTSLinearSeries } from './FullTSLinearSeries.js'
+import { createLabelledBinarySearchTree } from './BinarySearchTree.js'
import loglevel from 'loglevel'
const log = loglevel.getLogger('RowingEngine')
-function createTSQuadraticSeries (maxSeriesLength = 0) {
+export function createTSQuadraticSeries (maxSeriesLength = 0) {
const X = createSeries(maxSeriesLength)
const Y = createSeries(maxSeriesLength)
- const A = []
+ const A = createLabelledBinarySearchTree()
+ const linearResidu = createTSLinearSeries(maxSeriesLength)
let _A = 0
let _B = 0
let _C = 0
+ let _goodnessOfFit = 0
function push (x, y) {
- const linearResidu = createTSLinearSeries(maxSeriesLength)
+ // Invariant: A contains all a's (as in the general formula y = a * x^2 + b * x + c)
+ // Where the a's are labeled in the Binary Search Tree with their Xi when they BEGIN in the point (Xi, Yi)
+ if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return }
+
+ if (maxSeriesLength > 0 && X.length() >= maxSeriesLength) {
+ // The maximum of the array has been reached, so when pushing the new datapoint (x,y), the array will get shifted,
+ // thus we have to remove all the A's that start with the old position X0 BEFORE this value gets thrown away
+ A.remove(X.get(0))
+ }
X.push(x)
Y.push(y)
- if (maxSeriesLength > 0 && A.length >= maxSeriesLength) {
- // The maximum of the array has been reached, we have to create room
- // in the 2D array by removing the first row from the A-table
- A.shift()
- }
-
- // Invariant: the indices of the X and Y array now match up with the
- // row numbers of the A array. So, the A of (X[0],Y[0]) and (X[1],Y[1]
- // will be stored in A[0][.].
-
- // Add an empty array at the end to store futurs results for the most recent points
- A.push([])
-
- // Calculate the coefficients of this new point
- if (X.length() > 2) {
- // There are at least two points in the X and Y arrays, so let's add the new datapoint
- let i = 0
- while (i < X.length() - 2) {
- A[X.length() - 1].push(calculateA(i, X.length() - 1))
- i++
- }
- _A = matrixMedian(A)
-
- i = 0
- linearResidu.reset()
- while (i < X.length() - 1) {
- linearResidu.push(X.get(i), Y.get(i) - (_A * Math.pow(X.get(i), 2)))
- i++
- }
- _B = linearResidu.coefficientA()
- _C = linearResidu.coefficientB()
- } else {
- _A = 0
- _B = 0
- _C = 0
+ // Calculate the coefficient a for the new interval by adding the newly added datapoint
+ let i = 0
+ let j = 0
+
+ switch (true) {
+ case (X.length() >= 3):
+ // There are now at least three datapoints in the X and Y arrays, so let's calculate the A portion belonging for the new datapoint via Quadratic Theil-Sen regression
+ // First we calculate the A for the formula
+ while (i < X.length() - 2) {
+ j = i + 1
+ while (j < X.length() - 1) {
+ A.push(X.get(i), calculateA(i, j, X.length() - 1))
+ j++
+ }
+ i++
+ }
+ _A = A.median()
+
+ // We invalidate the linearResidu, B, C, and goodnessOfFit, as this will trigger a recalculate when they are needed
+ linearResidu.reset()
+ _B = null
+ _C = null
+ _goodnessOfFit = null
+ break
+ default:
+ _A = 0
+ _B = 0
+ _C = 0
+ _goodnessOfFit = 0
}
}
function firstDerivativeAtPosition (position) {
- if (X.length() > 2 && position < X.length()) {
+ if (X.length() >= 3 && position < X.length()) {
+ calculateB()
return ((_A * 2 * X.get(position)) + _B)
} else {
return 0
@@ -87,7 +97,7 @@ function createTSQuadraticSeries (maxSeriesLength = 0) {
}
function secondDerivativeAtPosition (position) {
- if (X.length() > 2 && position < X.length()) {
+ if (X.length() >= 3 && position < X.length()) {
return (_A * 2)
} else {
return 0
@@ -95,7 +105,8 @@ function createTSQuadraticSeries (maxSeriesLength = 0) {
}
function slope (x) {
- if (X.length() > 2) {
+ if (X.length() >= 3) {
+ calculateB()
return ((_A * 2 * x) + _B)
} else {
return 0
@@ -109,16 +120,21 @@ function createTSQuadraticSeries (maxSeriesLength = 0) {
function coefficientB () {
// For testing purposses only!
+ calculateB()
return _B
}
function coefficientC () {
// For testing purposses only!
+ calculateB()
+ calculateC()
return _C
}
function intercept () {
- return coefficientC()
+ calculateB()
+ calculateC()
+ return _C
}
function length () {
@@ -127,118 +143,119 @@ function createTSQuadraticSeries (maxSeriesLength = 0) {
function goodnessOfFit () {
// This function returns the R^2 as a goodness of fit indicator
- // ToDo: calculate the goodness of fit when called
- if (X.length() >= 2) {
- // return _goodnessOfFit
- return 1
- } else {
- return 0
+ let i = 0
+ let sse = 0
+ let sst = 0
+ if (_goodnessOfFit === null) {
+ if (X.length() >= 3) {
+ while (i < X.length()) {
+ sse += Math.pow((Y.get(i) - projectX(X.get(i))), 2)
+ sst += Math.pow((Y.get(i) - Y.average()), 2)
+ i++
+ }
+ switch (true) {
+ case (sse === 0):
+ _goodnessOfFit = 1
+ break
+ case (sse > sst):
+ // This is a pretty bad fit as the error is bigger than just using the line for the average y as intercept
+ _goodnessOfFit = 0
+ break
+ case (sst !== 0):
+ _goodnessOfFit = 1 - (sse / sst)
+ break
+ default:
+ // When SST = 0, R2 isn't defined
+ _goodnessOfFit = 0
+ }
+ } else {
+ _goodnessOfFit = 0
+ }
}
+ return _goodnessOfFit
}
function projectX (x) {
- const _C = coefficientC()
- if (X.length() > 2) {
+ if (X.length() >= 3) {
+ calculateB()
+ calculateC()
return ((_A * x * x) + (_B * x) + _C)
} else {
return 0
}
}
- function numberOfXValuesAbove (testedValue) {
- return X.numberOfValuesAbove(testedValue)
- }
-
- function numberOfXValuesEqualOrBelow (testedValue) {
- return X.numberOfValuesEqualOrBelow(testedValue)
- }
-
- function numberOfYValuesAbove (testedValue) {
- return Y.numberOfValuesAbove(testedValue)
- }
-
- function numberOfYValuesEqualOrBelow (testedValue) {
- return Y.numberOfValuesEqualOrBelow(testedValue)
- }
-
- function xAtSeriesBegin () {
- return X.atSeriesBegin()
- }
-
- function xAtSeriesEnd () {
- return X.atSeriesEnd()
- }
-
- function xAtPosition (position) {
- return X.get(position)
- }
-
- function yAtSeriesBegin () {
- return Y.atSeriesBegin()
- }
-
- function yAtSeriesEnd () {
- return Y.atSeriesEnd()
- }
-
- function yAtPosition (position) {
- return Y.get(position)
- }
-
- function xSum () {
- return X.sum()
- }
-
- function ySum () {
- return Y.sum()
+ function calculateA (pointOne, pointTwo, pointThree) {
+ let result = 0
+ if (X.get(pointOne) !== X.get(pointTwo) && X.get(pointOne) !== X.get(pointThree) && X.get(pointTwo) !== X.get(pointThree)) {
+ // For the underlying math, see https://www.quora.com/How-do-I-find-a-quadratic-equation-from-points/answer/Robert-Paxson
+ result = (X.get(pointOne) * (Y.get(pointThree) - Y.get(pointTwo)) + Y.get(pointOne) * (X.get(pointTwo) - X.get(pointThree)) + (X.get(pointThree) * Y.get(pointTwo) - X.get(pointTwo) * Y.get(pointThree))) / ((X.get(pointOne) - X.get(pointTwo)) * (X.get(pointOne) - X.get(pointThree)) * (X.get(pointTwo) - X.get(pointThree)))
+ return result
+ } else {
+ log.error('TS Quadratic Regressor, Division by zero prevented in CalculateA!')
+ return 0
+ }
}
- function xSeries () {
- return X.series()
+ function calculateB () {
+ // Calculate all the linear slope for the newly added point and the newly calculated A
+ // This function is only called when a linear slope is really needed, as this saves a lot of CPU cycles when only a slope suffices
+ if (_B === null) {
+ if (X.length() >= 3) {
+ fillLinearResidu()
+ _B = linearResidu.slope()
+ } else {
+ _B = 0
+ }
+ }
}
- function ySeries () {
- return Y.series()
+ function calculateC () {
+ // Calculate all the intercept for the newly added point and the newly calculated A
+ // This function is only called when a linear intercept is really needed, as this saves a lot of CPU cycles when only a slope suffices
+ if (_C === null) {
+ if (X.length() >= 3) {
+ fillLinearResidu()
+ _C = linearResidu.intercept()
+ } else {
+ _C = 0
+ }
+ }
}
- function calculateA (pointOne, pointThree) {
- if ((pointOne + 1) < pointThree && X.get(pointOne) !== X.get(pointThree)) {
- const results = createSeries(maxSeriesLength)
- let pointTwo = pointOne + 1
- while (pointOne < pointTwo && pointTwo < pointThree && X.get(pointOne) !== X.get(pointTwo) && X.get(pointTwo) !== X.get(pointThree)) {
- // For the underlying math, see https://www.quora.com/How-do-I-find-a-quadratic-equation-from-points/answer/Robert-Paxson
- results.push((X.get(pointOne) * (Y.get(pointThree) - Y.get(pointTwo)) + Y.get(pointOne) * (X.get(pointTwo) - X.get(pointThree)) + (X.get(pointThree) * Y.get(pointTwo) - X.get(pointTwo) * Y.get(pointThree))) / ((X.get(pointOne) - X.get(pointTwo)) * (X.get(pointOne) - X.get(pointThree)) * (X.get(pointTwo) - X.get(pointThree))))
- pointTwo += 1
+ function fillLinearResidu () {
+ // To calculate the B and C via Linear regression over the residu, we need to fill it if empty
+ if (linearResidu.length() === 0) {
+ let i = 0
+ while (i < X.length()) {
+ linearResidu.push(X.get(i), Y.get(i) - (_A * Math.pow(X.get(i), 2)))
+ i++
}
- return results.median()
- } else {
- log.error('TS Quadratic Regressor, Division by zero prevented in CalculateA!')
- return 0
}
}
- function matrixMedian (inputMatrix) {
- if (inputMatrix.length > 1) {
- const sortedArray = [...inputMatrix.flat()].sort((a, b) => a - b)
- const mid = Math.floor(sortedArray.length / 2)
- return (sortedArray.length % 2 !== 0 ? sortedArray[mid] : ((sortedArray[mid - 1] + sortedArray[mid]) / 2))
- } else {
- log.error('TS Quadratic Regressor, Median calculation on empty matrix attempted!')
- return 0
- }
+ function reliable () {
+ return (X.length() >= 3)
}
function reset () {
- X.reset()
- Y.reset()
- A.splice(0, A.length)
- _A = 0
- _B = 0
- _C = 0
+ if (X.length() > 0) {
+ // There is something to reset
+ X.reset()
+ Y.reset()
+ A.reset()
+ linearResidu.reset()
+ _A = 0
+ _B = 0
+ _C = 0
+ _goodnessOfFit = 0
+ }
}
return {
push,
+ X,
+ Y,
firstDerivativeAtPosition,
secondDerivativeAtPosition,
slope,
@@ -249,22 +266,7 @@ function createTSQuadraticSeries (maxSeriesLength = 0) {
length,
goodnessOfFit,
projectX,
- numberOfXValuesAbove,
- numberOfXValuesEqualOrBelow,
- numberOfYValuesAbove,
- numberOfYValuesEqualOrBelow,
- xAtSeriesBegin,
- xAtSeriesEnd,
- xAtPosition,
- yAtSeriesBegin,
- yAtSeriesEnd,
- yAtPosition,
- xSum,
- ySum,
- xSeries,
- ySeries,
+ reliable,
reset
}
}
-
-export { createTSQuadraticSeries }
diff --git a/app/engine/utils/FullTSQuadraticSeries.test.js b/app/engine/utils/FullTSQuadraticSeries.test.js
index 473b5b0484..211dc1450c 100644
--- a/app/engine/utils/FullTSQuadraticSeries.test.js
+++ b/app/engine/utils/FullTSQuadraticSeries.test.js
@@ -1,10 +1,11 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This tests the Quadratic Theil-Senn Regression algorithm. As regression is an estimation and methods have biasses,
- we need to accept some slack with respect to real-life examples
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
+/**
+ * This tests the Quadratic Theil-Senn Regression algorithm. As regression is an estimation and methods have biasses,
+ * we need to accept some slack with respect to real-life examples
+ */
import { test } from 'uvu'
import * as assert from 'uvu/assert'
@@ -56,6 +57,7 @@ test('Quadratic Approximation on a perfect noisefree function y = 2 * Math.pow(x
testCoefficientA(dataSeries, 2)
testCoefficientB(dataSeries, 2)
testCoefficientC(dataSeries, 2)
+ testGoodnessOfFitEquals(dataSeries, 1)
})
test('Quadratic Approximation on a perfect noisefree function y = 2 * Math.pow(x, 2) + 2 * x + 2, with 10 datapoints and some shifting in the series', () => {
@@ -75,6 +77,7 @@ test('Quadratic Approximation on a perfect noisefree function y = 2 * Math.pow(x
testCoefficientA(dataSeries, 2)
testCoefficientB(dataSeries, 2)
testCoefficientC(dataSeries, 2)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(1, 6)
dataSeries.push(2, 14)
dataSeries.push(3, 26)
@@ -88,6 +91,7 @@ test('Quadratic Approximation on a perfect noisefree function y = 2 * Math.pow(x
testCoefficientA(dataSeries, 2)
testCoefficientB(dataSeries, 2)
testCoefficientC(dataSeries, 2)
+ testGoodnessOfFitEquals(dataSeries, 1)
})
test('Quadratic Approximation on function y = 4 * Math.pow(x, 2) + 4 * x + 4, noisefree', () => {
@@ -99,82 +103,102 @@ test('Quadratic Approximation on function y = 4 * Math.pow(x, 2) + 4 * x + 4, no
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(-8, 228)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(-7, 172)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(-6, 124)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(-5, 84)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(-4, 52)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(-3, 28)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(-2, 12)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(-1, 4)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(0, 4)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(1, 12)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(2, 28)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(3, 52)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(4, 84)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(5, 124)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(6, 172)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(7, 228)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(8, 292)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(9, 364)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(10, 444)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
})
test('Quadratic Approximation on function y = 4 * Math.pow(x, 2) + 4 * x + 4, with some noise (+/- 1)', () => {
@@ -186,82 +210,102 @@ test('Quadratic Approximation on function y = 4 * Math.pow(x, 2) + 4 * x + 4, wi
testCoefficientA(dataSeries, 2)
testCoefficientB(dataSeries, -36)
testCoefficientC(dataSeries, -195)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(-8, 229)
testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 3.6666666666666643) // This is quite acceptable as ORM ignores the C
+ testCoefficientB(dataSeries, 4.333333333333334)
+ testCoefficientC(dataSeries, 7.166666666666671)
+ testGoodnessOfFitEquals(dataSeries, 0.9998746217034155)
dataSeries.push(-7, 171)
- testCoefficientA(dataSeries, 3.666666666666667)
- testCoefficientB(dataSeries, -1.8333333333333335)
- testCoefficientC(dataSeries, -20.916666666666682)
+ testCoefficientA(dataSeries, 3.3333333333333335)
+ testCoefficientB(dataSeries, -7.999999999999991)
+ testCoefficientC(dataSeries, -48.33333333333328)
+ testGoodnessOfFitEquals(dataSeries, 0.9998468647471163)
dataSeries.push(-6, 125)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 3.799999999999997) // This is quite acceptable as ORM ignores the C
- dataSeries.push(-5, 83)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 0.9999165499911914)
+ dataSeries.push(-5, 83)
+ testCoefficientA(dataSeries, 3.8666666666666667)
+ testCoefficientB(dataSeries, 1.8666666666666671)
+ testCoefficientC(dataSeries, -4.333333333333336) // This is quite acceptable as ORM ignores the C
+ testGoodnessOfFitEquals(dataSeries, 0.9999366117119067)
dataSeries.push(-4, 53)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 3.8571428571428577) // This is quite acceptable as ORM ignores the C
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 0.9999402806808002)
dataSeries.push(-3, 27)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9999042318865254)
dataSeries.push(-2, 13)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 3.8888888888888893) // This is quite acceptable as ORM ignores the C
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 0.9999495097395712)
dataSeries.push(-1, 3)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9999117149452151)
dataSeries.push(0, 5)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9998721709098177)
dataSeries.push(1, 11)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9997996371611135)
dataSeries.push(2, 29)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9996545703483187)
dataSeries.push(3, 51)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9993201651380683)
dataSeries.push(4, 85)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9987227718173796)
dataSeries.push(5, 123)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9986961263098004)
dataSeries.push(6, 173)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9993274803746546)
dataSeries.push(7, 227)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9996526505917571)
dataSeries.push(8, 293)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9998002774328024)
dataSeries.push(9, 363)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 3) // We get a 3 instead of 4, which is quite acceptable (especially since ORM ignores the C)
+ testGoodnessOfFitEquals(dataSeries, 0.9998719089295779)
dataSeries.push(10, 444)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 0.9999558104799866)
})
test('Quadratic Approximation on function y = 4 * Math.pow(x, 2) + 4 * x + 4, with some noise (+/- 1) and spikes (+/- 9)', () => {
@@ -277,63 +321,78 @@ test('Quadratic Approximation on function y = 4 * Math.pow(x, 2) + 4 * x + 4, wi
dataSeries.push(-4, 53)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 3.8571428571428577)
- dataSeries.push(-3, 37) // FIRST SPIKE +9
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 0.9999402806808002)
+ dataSeries.push(-3, 37) // FIRST SPIKE +9
+ testCoefficientA(dataSeries, 4.215277777777778)
+ testCoefficientB(dataSeries, 7.694940476190471)
+ testCoefficientC(dataSeries, 18.816964285714235)
+ testGoodnessOfFitEquals(dataSeries, 0.9997971509015441)
dataSeries.push(-2, 3) // SECOND SPIKE -9
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4.142857142857142) // Coefficient B seems to take a hit anyway
- testCoefficientC(dataSeries, 5.9999999999999964) // We get a 5.9999999999999964 instead of 4, which is quite acceptable (especially since ORM ignores the C)
+ testCoefficientA(dataSeries, 3.9714285714285715)
+ testCoefficientB(dataSeries, 3.6000000000000036) // Coefficient B seems to take a hit anyway
+ testCoefficientC(dataSeries, 2.842857142857163) // We get a 2.8 instead of 4, which is quite acceptable (especially since ORM ignores the C)
+ testGoodnessOfFitEquals(dataSeries, 0.9991656951087963)
dataSeries.push(-1, 3)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientA(dataSeries, 3.9555555555555557)
+ testCoefficientB(dataSeries, 3.37777777777778)
+ testCoefficientC(dataSeries, 2.4222222222222243)
+ testGoodnessOfFitEquals(dataSeries, 0.9992769580376006)
dataSeries.push(0, 5)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9988530568930122)
dataSeries.push(1, 11)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9982053643291688)
dataSeries.push(2, 29)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9969166946967148)
dataSeries.push(3, 51)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9939797134586851)
dataSeries.push(4, 85)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9888468297958631)
dataSeries.push(5, 123)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9886212128178015)
dataSeries.push(6, 173)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientA(dataSeries, 4.044444444444444)
+ testCoefficientB(dataSeries, 3.822222222222223)
+ testCoefficientC(dataSeries, 3.577777777777783)
+ testGoodnessOfFitEquals(dataSeries, 0.9945681627011398)
dataSeries.push(7, 227)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9968997006175546)
dataSeries.push(8, 293)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 3) // This is quite acceptable as ORM ignores the C
+ testCoefficientA(dataSeries, 3.9047619047619047)
+ testCoefficientB(dataSeries, 4.888888888888889)
+ testCoefficientC(dataSeries, 2.9682539682539684) // This is quite acceptable as ORM ignores the C
+ testGoodnessOfFitEquals(dataSeries, 0.9995034675221599)
dataSeries.push(9, 363)
- testCoefficientA(dataSeries, 4)
+ testCoefficientA(dataSeries, 4) // These results match up 100% with the previous test, showing that a spike has no carry over effects
testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4) // We get a 3 instead of 4, which is quite acceptable (especially since ORM ignores the C)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9998719089295779)
dataSeries.push(10, 444)
testCoefficientA(dataSeries, 4)
testCoefficientB(dataSeries, 4)
testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 0.9999558104799866)
})
test('Quadratic TS Estimation should be decent for standard real-life example from MathBits with some noise', () => {
@@ -352,9 +411,10 @@ test('Quadratic TS Estimation should be decent for standard real-life example fr
dataSeries.push(58, 244.2)
dataSeries.push(60, 231.4)
dataSeries.push(64, 180.4)
- testCoefficientA(dataSeries, -0.17785023090944152) // In the example, the TI084 results in -0.1737141137, which we consider acceptably close
- testCoefficientB(dataSeries, 15.115602960635863) // In the example, the TI084 results in 14.52117133, which we consider acceptably close
- testCoefficientC(dataSeries, -35.639987946994665) // In the example, the TI084 results in -21.89774466, which we consider acceptably close
+ testCoefficientA(dataSeries, -0.17702838827838824) // In the example, the TI084 results in -0.1737141137, which we consider acceptably close
+ testCoefficientB(dataSeries, 14.929144536019532) // In the example, the TI084 results in 14.52117133, which we consider acceptably close
+ testCoefficientC(dataSeries, -31.325531135531037) // In the example, the TI084 results in -21.89774466, which we consider acceptably close
+ testGoodnessOfFitEquals(dataSeries, 0.9781087883163964)
})
test('Quadratic TS Estimation should be decent for standard real-life example from VarsityTutors with some noise', () => {
@@ -367,9 +427,10 @@ test('Quadratic TS Estimation should be decent for standard real-life example fr
dataSeries.push(1, 3)
dataSeries.push(2, 6)
dataSeries.push(3, 14)
- testCoefficientA(dataSeries, 1.1166666666666667) // The example results in 1.1071 for OLS, which we consider acceptably close
- testCoefficientB(dataSeries, 0.966666666666667) // The example results in 1 for OLS, which we consider acceptably close
- testCoefficientC(dataSeries, 0.44722222222222213) // The example results in 0.5714 for OLS, which we consider acceptably close
+ testCoefficientA(dataSeries, 1.0833333333333333) // The example results in 1.1071 for OLS, which we consider acceptably close
+ testCoefficientB(dataSeries, 1.0833333333333333) // The example results in 1 for OLS, which we consider acceptably close
+ testCoefficientC(dataSeries, 0.8333333333333335) // The example results in 0.5714 for OLS, which we consider acceptably close
+ testGoodnessOfFitEquals(dataSeries, 0.9851153039832286)
})
test('Quadratic TS Estimation should be decent for standard example from VTUPulse with some noise, without the vertex being part of the dataset', () => {
@@ -380,9 +441,10 @@ test('Quadratic TS Estimation should be decent for standard example from VTUPuls
dataSeries.push(5, 3.8)
dataSeries.push(6, 6.5)
dataSeries.push(7, 11.5)
- testCoefficientA(dataSeries, 0.9500000000000005) // The example results in 0.7642857 for OLS, which we consider acceptably close given the small sample size
- testCoefficientB(dataSeries, -7.483333333333338) // The example results in -5.5128571 for OLS, which we consider acceptably close given the small sample size
- testCoefficientC(dataSeries, 17.275000000000006) // The example results in 12.4285714 for OLS, which we consider acceptably close given the small sample size
+ testCoefficientA(dataSeries, 0.8583333333333334) // The example results in 0.7642857 for OLS, which we consider acceptably close given the small sample size
+ testCoefficientB(dataSeries, -6.420833333333334) // The example results in -5.5128571 for OLS, which we consider acceptably close given the small sample size
+ testCoefficientC(dataSeries, 14.387500000000003) // The example results in 12.4285714 for OLS, which we consider acceptably close given the small sample size
+ testGoodnessOfFitEquals(dataSeries, 0.9825283785404673)
})
test('Quadratic TS Estimation should be decent for standard real-life example from Uni Berlin with some noise without the vertex being part of the dataset', () => {
@@ -413,9 +475,10 @@ test('Quadratic TS Estimation should be decent for standard real-life example fr
dataSeries.push(0.102696671, 0.27621694)
dataSeries.push(0.715372314, -1.20379729)
dataSeries.push(0.681745393, -0.83059624)
- testCoefficientA(dataSeries, -3.13052236289358)
- testCoefficientB(dataSeries, 1.5907039702198331)
- testCoefficientC(dataSeries, 0.12896850914578195)
+ testCoefficientA(dataSeries, -2.030477132951317)
+ testCoefficientB(dataSeries, 0.5976858995201227)
+ testCoefficientC(dataSeries, 0.17630021024409503)
+ testGoodnessOfFitEquals(dataSeries, 0.23921110548689295)
})
test('Quadratic TS Estimation should be decent for standard real-life example from Statology.org with some noise and chaotic X values', () => {
@@ -432,9 +495,10 @@ test('Quadratic TS Estimation should be decent for standard real-life example fr
dataSeries.push(51, 59)
dataSeries.push(55, 44)
dataSeries.push(60, 27)
- testCoefficientA(dataSeries, -0.10466531440162272) // The example results in -0.1012 for R after two rounds, which we consider acceptably close
- testCoefficientB(dataSeries, 6.98670916642519) // The example results in 6.7444 for R after two rounds, which we consider acceptably close
- testCoefficientC(dataSeries, -21.826295759683177) // The example results in 18.2536 for R after two rounds, but for ORM, this factor is irrelevant
+ testCoefficientA(dataSeries, -0.10119047619047619) // The example results in -0.1012 for R after two rounds, which we consider acceptably close
+ testCoefficientB(dataSeries, 6.801190476190477) // The example results in 6.7444 for R after two rounds, which we consider acceptably close
+ testCoefficientC(dataSeries, -21.126190476190516) // The example results in 18.2536 for R after two rounds, but for ORM, this factor is irrelevant
+ testGoodnessOfFitEquals(dataSeries, 0.9571127392718894)
})
test('Quadratic TS Estimation should be decent for standard real-life example from StatsDirect.com with some noise and chaotic X values', () => {
@@ -450,9 +514,10 @@ test('Quadratic TS Estimation should be decent for standard real-life example fr
dataSeries.push(2230, 1840)
dataSeries.push(2400, 1956)
dataSeries.push(2930, 1954)
- testCoefficientA(dataSeries, -0.0004480669511301859) // The example results in -0.00045 through QR decomposition by Givens rotations, which we consider acceptably close
- testCoefficientB(dataSeries, 2.373459636061883) // The example results in 2.39893 for QR decomposition by Givens rotations, which we consider acceptably close
- testCoefficientC(dataSeries, -1178.1630473732216) // The example results in -1216.143887 for QR decomposition by Givens rotations, but for ORM, this factor is irrelevant
+ testCoefficientA(dataSeries, -0.00046251263566907585) // The example results in -0.00045 through QR decomposition by Givens rotations, which we consider acceptably close
+ testCoefficientB(dataSeries, 2.441798780934297) // The example results in 2.39893 for QR decomposition by Givens rotations, which we consider acceptably close
+ testCoefficientC(dataSeries, -1235.044997485239) // The example results in -1216.143887 for QR decomposition by Givens rotations, but for ORM, this factor is irrelevant
+ testGoodnessOfFitEquals(dataSeries, 0.9790379024208455)
})
test('Quadratic Approximation with a clean function and a reset', () => {
@@ -467,6 +532,7 @@ test('Quadratic Approximation with a clean function and a reset', () => {
testCoefficientA(dataSeries, 2)
testCoefficientB(dataSeries, 2)
testCoefficientC(dataSeries, 2)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(-4, 26)
dataSeries.push(-3, 14) // Pi ;)
dataSeries.push(-2, 6)
@@ -477,6 +543,7 @@ test('Quadratic Approximation with a clean function and a reset', () => {
testCoefficientA(dataSeries, 2)
testCoefficientB(dataSeries, 2)
testCoefficientC(dataSeries, 2)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.push(3, 26)
dataSeries.push(4, 42)
dataSeries.push(5, 62)
@@ -488,22 +555,27 @@ test('Quadratic Approximation with a clean function and a reset', () => {
testCoefficientA(dataSeries, 2)
testCoefficientB(dataSeries, 2)
testCoefficientC(dataSeries, 2)
+ testGoodnessOfFitEquals(dataSeries, 1)
dataSeries.reset()
testCoefficientA(dataSeries, 0)
testCoefficientB(dataSeries, 0)
testCoefficientC(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
dataSeries.push(-1, 2)
testCoefficientA(dataSeries, 0)
testCoefficientB(dataSeries, 0)
testCoefficientC(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
dataSeries.push(0, 2)
testCoefficientA(dataSeries, 0)
testCoefficientB(dataSeries, 0)
testCoefficientC(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
dataSeries.push(1, 6)
testCoefficientA(dataSeries, 2)
testCoefficientB(dataSeries, 2)
testCoefficientC(dataSeries, 2)
+ testGoodnessOfFitEquals(dataSeries, 1)
})
test('Quadratic TS Estimation should result in a straight line for function y = x', () => {
@@ -519,28 +591,36 @@ test('Quadratic TS Estimation should result in a straight line for function y =
testCoefficientA(dataSeries, 0)
testCoefficientB(dataSeries, 1)
testCoefficientC(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 1)
})
function testCoefficientA (series, expectedValue) {
- assert.ok(series.coefficientA() === expectedValue, `Expected value for coefficientA at X-position ${series.xAtSeriesEnd()} is ${expectedValue}, encountered a ${series.coefficientA()}`)
+ assert.ok(series.coefficientA() === expectedValue, `Expected value for coefficientA at X-position ${series.X.atSeriesEnd()} is ${expectedValue}, encountered a ${series.coefficientA()}`)
}
function testCoefficientB (series, expectedValue) {
- assert.ok(series.coefficientB() === expectedValue, `Expected value for coefficientB at X-position ${series.xAtSeriesEnd()} is ${expectedValue}, encountered a ${series.coefficientB()}`)
+ assert.ok(series.coefficientB() === expectedValue, `Expected value for coefficientB at X-position ${series.X.atSeriesEnd()} is ${expectedValue}, encountered a ${series.coefficientB()}`)
}
function testCoefficientC (series, expectedValue) {
- assert.ok(series.coefficientC() === expectedValue, `Expected value for coefficientC at X-position ${series.xAtSeriesEnd()} is ${expectedValue}, encountered a ${series.coefficientC()}`)
+ assert.ok(series.coefficientC() === expectedValue, `Expected value for coefficientC at X-position ${series.X.atSeriesEnd()} is ${expectedValue}, encountered a ${series.coefficientC()}`)
}
-/*
-function testSlope (series, position, expectedValue) {
- assert.ok(series.slope(position) === expectedValue, `Expected value for Slope-${position} at X-position ${series.xAtSeriesEnd()} (slope at X-position ${series.xAtPosition(position)}) is ${expectedValue}, encountered a ${series.slope(position)}`)
+function testGoodnessOfFitEquals (series, expectedValue) {
+ assert.ok(series.goodnessOfFit() === expectedValue, `Expected goodnessOfFit at X-position ${series.X.atSeriesEnd()} is ${expectedValue}, encountered ${series.goodnessOfFit()}`)
}
-function reportAll (series) {
- assert.ok(series.coefficientA() === 99, `time: ${series.xAtSeriesEnd()}, coefficientA: ${series.coefficientA()}, coefficientB: ${series.coefficientB()}, coefficientC: ${series.coefficientC()}, Slope-10: ${series.slope(10)}, Slope-9: ${series.slope(9)}, Slope-8: ${series.slope(8)}, Slope-7: ${series.slope(7)}, Slope-6: ${series.slope(6)}, Slope-5: ${series.slope(5)}, Slope-4: ${series.slope(4)}, Slope-3: ${series.slope(3)}, Slope-2: ${series.slope(2)}, Slope-1: ${series.slope(1)}, Slope-0: ${series.slope(0)}`)
+function testGoodnessOfFitBetween (series, expectedValueAbove, expectedValueBelow) { // eslint-disable-line no-unused-vars
+ assert.ok(series.goodnessOfFit() > expectedValueAbove, `Expected goodnessOfFit at X-position ${series.X.atSeriesEnd()} above ${expectedValueAbove}, encountered ${series.goodnessOfFit()}`)
+ assert.ok(series.goodnessOfFit() < expectedValueBelow, `Expected goodnessOfFit at X-position ${series.X.atSeriesEnd()} below ${expectedValueBelow}, encountered ${series.goodnessOfFit()}`)
+}
+
+function testSlope (series, position, expectedValue) { // eslint-disable-line no-unused-vars
+ assert.ok(series.slope(position) === expectedValue, `Expected value for Slope-${position} at X-position ${series.X.atSeriesEnd()} (slope at X-position ${series.X.atPosition(position)}) is ${expectedValue}, encountered a ${series.slope(position)}`)
+}
+
+function reportAll (series) { // eslint-disable-line no-unused-vars
+ assert.ok(series.coefficientA() === 99, `time: ${series.X.atSeriesEnd()}, coefficientA: ${series.coefficientA()}, coefficientB: ${series.coefficientB()}, coefficientC: ${series.coefficientC()}, Slope-10: ${series.slope(10)}, Slope-9: ${series.slope(9)}, Slope-8: ${series.slope(8)}, Slope-7: ${series.slope(7)}, Slope-6: ${series.slope(6)}, Slope-5: ${series.slope(5)}, Slope-4: ${series.slope(4)}, Slope-3: ${series.slope(3)}, Slope-2: ${series.slope(2)}, Slope-1: ${series.slope(1)}, Slope-0: ${series.slope(0)}`)
}
-*/
test.run()
diff --git a/app/engine/utils/OLSLinearSeries.js b/app/engine/utils/OLSLinearSeries.js
index cc382f6234..6d0c26541e 100644
--- a/app/engine/utils/OLSLinearSeries.js
+++ b/app/engine/utils/OLSLinearSeries.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
The LinearSeries is a datatype that represents a Linear Series. It allows
values to be retrieved (like a FiFo buffer, or Queue) but it also includes
@@ -28,18 +28,18 @@ import { createSeries } from './Series.js'
import loglevel from 'loglevel'
const log = loglevel.getLogger('RowingEngine')
-function createOLSLinearSeries (maxSeriesLength = 0) {
+export function createOLSLinearSeries (maxSeriesLength = 0) {
const X = createSeries(maxSeriesLength)
const XX = createSeries(maxSeriesLength)
const Y = createSeries(maxSeriesLength)
const YY = createSeries(maxSeriesLength)
const XY = createSeries(maxSeriesLength)
- const trend = createSeries(maxSeriesLength)
let _slope = 0
let _intercept = 0
let _goodnessOfFit = 0
function push (x, y) {
+ if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return }
X.push(x)
XX.push(x * x)
Y.push(y)
@@ -53,7 +53,6 @@ function createOLSLinearSeries (maxSeriesLength = 0) {
const sse = YY.sum() - (_intercept * Y.sum()) - (_slope * XY.sum())
const sst = YY.sum() - (Math.pow(Y.sum(), 2) / X.length())
_goodnessOfFit = 1 - (sse / sst)
- trend.push(determineTrend(X.length() - 2, X.length() - 1))
} else {
_slope = 0
_intercept = 0
@@ -94,73 +93,13 @@ function createOLSLinearSeries (maxSeriesLength = 0) {
if (X.length() >= 2 && _slope !== 0) {
return ((y - _intercept) / _slope)
} else {
+ log.error('OLS Regressor, attempted a Y-projection while slope was zero!')
return 0
}
}
- function numberOfXValuesAbove (testedValue) {
- return X.numberOfValuesAbove(testedValue)
- }
-
- function numberOfXValuesEqualOrBelow (testedValue) {
- return X.numberOfValuesEqualOrBelow(testedValue)
- }
-
- function numberOfYValuesAbove (testedValue) {
- return Y.numberOfValuesAbove(testedValue)
- }
-
- function numberOfYValuesEqualOrBelow (testedValue) {
- return Y.numberOfValuesEqualOrBelow(testedValue)
- }
-
- function numberOfUpwardTrend () {
- return trend.numberOfValuesAbove(0)
- }
-
- function numberOfFlatOrDownwardTrend () {
- return trend.numberOfValuesEqualOrBelow(0)
- }
-
- function xAtSeriesBegin () {
- return X.atSeriesBegin()
- }
-
- function xAtSeriesEnd () {
- return X.atSeriesEnd()
- }
-
- function yAtSeriesBegin () {
- return Y.atSeriesBegin()
- }
-
- function yAtSeriesEnd () {
- return Y.atSeriesEnd()
- }
-
- function xSum () {
- return X.sum()
- }
-
- function ySum () {
- return Y.sum()
- }
-
- function xSeries () {
- return X.series()
- }
-
- function ySeries () {
- return Y.series()
- }
-
- function determineTrend (pointOne, pointTwo) {
- if (pointOne !== pointTwo) {
- return (Y.get(pointTwo) - Y.get(pointOne))
- } else {
- log.error('OLS Linear Regressor, trend determination, trend can not be applied to one point!')
- return 0
- }
+ function reliable () {
+ return (X.length() >= 2 && _slope !== 0)
}
function reset () {
@@ -176,28 +115,15 @@ function createOLSLinearSeries (maxSeriesLength = 0) {
return {
push,
+ X,
+ Y,
slope,
intercept,
length,
goodnessOfFit,
projectX,
projectY,
- numberOfXValuesAbove,
- numberOfXValuesEqualOrBelow,
- numberOfYValuesAbove,
- numberOfYValuesEqualOrBelow,
- numberOfUpwardTrend,
- numberOfFlatOrDownwardTrend,
- xAtSeriesBegin,
- xAtSeriesEnd,
- yAtSeriesBegin,
- yAtSeriesEnd,
- xSum,
- ySum,
- xSeries,
- ySeries,
+ reliable,
reset
}
}
-
-export { createOLSLinearSeries }
diff --git a/app/engine/utils/OLSLinearSeries.test.js b/app/engine/utils/OLSLinearSeries.test.js
index 92e6445eb0..9bf25cc3c0 100644
--- a/app/engine/utils/OLSLinearSeries.test.js
+++ b/app/engine/utils/OLSLinearSeries.test.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
import { test } from 'uvu'
import * as assert from 'uvu/assert'
@@ -199,43 +199,43 @@ function testLength (series, expectedValue) {
}
function testXAtSeriesBegin (series, expectedValue) {
- assert.ok(series.xAtSeriesBegin() === expectedValue, `Expected xAtSeriesBegin to be ${expectedValue}, encountered a ${series.xAtSeriesBegin()}`)
+ assert.ok(series.X.atSeriesBegin() === expectedValue, `Expected X.atSeriesBegin to be ${expectedValue}, encountered a ${series.X.atSeriesBegin()}`)
}
function testYAtSeriesBegin (series, expectedValue) {
- assert.ok(series.yAtSeriesBegin() === expectedValue, `Expected yAtSeriesBegin to be ${expectedValue}, encountered a ${series.yAtSeriesBegin()}`)
+ assert.ok(series.Y.atSeriesBegin() === expectedValue, `Expected Y.atSeriesBegin to be ${expectedValue}, encountered a ${series.Y.atSeriesBegin()}`)
}
function testXAtSeriesEnd (series, expectedValue) {
- assert.ok(series.xAtSeriesEnd() === expectedValue, `Expected xAtSeriesEnd to be ${expectedValue}, encountered a ${series.xAtSeriesEnd()}`)
+ assert.ok(series.X.atSeriesEnd() === expectedValue, `Expected X.atSeriesEnd to be ${expectedValue}, encountered a ${series.X.atSeriesEnd()}`)
}
function testYAtSeriesEnd (series, expectedValue) {
- assert.ok(series.yAtSeriesEnd() === expectedValue, `Expected yAtSeriesEnd to be ${expectedValue}, encountered a ${series.yAtSeriesEnd()}`)
+ assert.ok(series.Y.atSeriesEnd() === expectedValue, `Expected Y.atSeriesEnd to be ${expectedValue}, encountered a ${series.Y.atSeriesEnd()}`)
}
function testNumberOfXValuesAbove (series, cutoff, expectedValue) {
- assert.ok(series.numberOfXValuesAbove(cutoff) === expectedValue, `Expected numberOfXValuesAbove(${cutoff}) to be ${expectedValue}, encountered a ${series.numberOfXValuesAbove(cutoff)}`)
+ assert.ok(series.X.numberOfValuesAbove(cutoff) === expectedValue, `Expected X.numberOfValuesAbove(${cutoff}) to be ${expectedValue}, encountered a ${series.X.numberOfValuesAbove(cutoff)}`)
}
function testNumberOfYValuesAbove (series, cutoff, expectedValue) {
- assert.ok(series.numberOfYValuesAbove(cutoff) === expectedValue, `Expected numberOfYValuesAbove(${cutoff}) to be ${expectedValue}, encountered a ${series.numberOfYValuesAbove(cutoff)}`)
+ assert.ok(series.Y.numberOfValuesAbove(cutoff) === expectedValue, `Expected Y.numberOfValuesAbove(${cutoff}) to be ${expectedValue}, encountered a ${series.Y.numberOfValuesAbove(cutoff)}`)
}
function testNumberOfXValuesEqualOrBelow (series, cutoff, expectedValue) {
- assert.ok(series.numberOfXValuesEqualOrBelow(cutoff) === expectedValue, `Expected numberOfXValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered a ${series.numberOfXValuesEqualOrBelow(cutoff)}`)
+ assert.ok(series.X.numberOfValuesEqualOrBelow(cutoff) === expectedValue, `Expected X.numberOfValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered a ${series.X.numberOfValuesEqualOrBelow(cutoff)}`)
}
function testNumberOfYValuesEqualOrBelow (series, cutoff, expectedValue) {
- assert.ok(series.numberOfYValuesEqualOrBelow(cutoff) === expectedValue, `Expected numberOfYValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered a ${series.numberOfYValuesEqualOrBelow(cutoff)}`)
+ assert.ok(series.Y.numberOfValuesEqualOrBelow(cutoff) === expectedValue, `Expected Y.numberOfValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered a ${series.Y.numberOfValuesEqualOrBelow(cutoff)}`)
}
function testXSum (series, expectedValue) {
- assert.ok(series.xSum() === expectedValue, `Expected xSum to be ${expectedValue}, encountered a ${series.xSum()}`)
+ assert.ok(series.X.sum() === expectedValue, `Expected X.sum to be ${expectedValue}, encountered a ${series.X.sum()}`)
}
function testYSum (series, expectedValue) {
- assert.ok(series.ySum() === expectedValue, `Expected ySum to be ${expectedValue}, encountered a ${series.ySum()}`)
+ assert.ok(series.Y.sum() === expectedValue, `Expected y.Sum to be ${expectedValue}, encountered a ${series.Y.sum()}`)
}
function testSlopeEquals (series, expectedValue) {
diff --git a/app/engine/utils/Series.js b/app/engine/utils/Series.js
index 2bc189578c..15a67fd9d0 100644
--- a/app/engine/utils/Series.js
+++ b/app/engine/utils/Series.js
@@ -1,18 +1,33 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This creates a series with a maximum number of values
- It allows for determining the Average, Median, Number of Positive, number of Negative
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
-
-function createSeries (maxSeriesLength) {
- const seriesArray = []
+/**
+ * This creates a series with a maximum number of values. It allows for determining the Average, Median, Number of Positive, number of Negative
+ * @remark BE AWARE: The median function is extremely CPU intensive for larger series. Use the BinarySearchTree for that situation instead!
+ *
+ * @param {number} [maxSeriesLength] The maximum length of the series (0 for unlimited)
+ */
+export function createSeries (maxSeriesLength = 0) {
+ /**
+ * @type {Array}
+ */
+ let seriesArray = []
let seriesSum = 0
let numPos = 0
let numNeg = 0
+ let min = undefined
+ let max = undefined
+ /**
+ * @param {float} value to be added to the series
+ */
function push (value) {
+ if (value === undefined || isNaN(value)) { return }
+
+ if (min !== undefined) { min = Math.min(min, value) }
+ if (max !== undefined) { max = Math.max(max, value) }
+
if (maxSeriesLength > 0 && seriesArray.length >= maxSeriesLength) {
// The maximum of the array has been reached, we have to create room by removing the first
// value from the array
@@ -22,6 +37,12 @@ function createSeries (maxSeriesLength) {
} else {
numNeg--
}
+ if (min === seriesArray[0]) {
+ min = undefined
+ }
+ if (max === seriesArray[0]) {
+ max = undefined
+ }
seriesArray.shift()
}
seriesArray.push(value)
@@ -33,10 +54,16 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @output {number} length of the series
+ */
function length () {
return seriesArray.length
}
+ /**
+ * @output {float} value at the head of the series (i.e. the one first added)
+ */
function atSeriesBegin () {
if (seriesArray.length > 0) {
return seriesArray[0]
@@ -45,6 +72,9 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @output {float} value at the tail of the series (i.e. the one last added)
+ */
function atSeriesEnd () {
if (seriesArray.length > 0) {
return seriesArray[seriesArray.length - 1]
@@ -53,6 +83,10 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @param {number} position
+ * @output {float} value at a specific postion, starting at 0
+ */
function get (position) {
if (position >= 0 && position < seriesArray.length) {
return seriesArray[position]
@@ -61,6 +95,10 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @param {number} testedValue
+ * @output {number} number of values in the series above the tested value
+ */
function numberOfValuesAbove (testedValue) {
if (testedValue === 0) {
return numPos
@@ -77,6 +115,10 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @param {number} testedValue
+ * @output {number} number of values in the series below or equal to the tested value
+ */
function numberOfValuesEqualOrBelow (testedValue) {
if (testedValue === 0) {
return numNeg
@@ -93,10 +135,16 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @output {float} sum of the entire series
+ */
function sum () {
return seriesSum
}
+ /**
+ * @output {float} average of the entire series
+ */
function average () {
if (seriesArray.length > 0) {
return seriesSum / seriesArray.length
@@ -105,6 +153,33 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @output {float} smallest element in the series
+ */
+ function minimum () {
+ if (seriesArray.length > 0) {
+ if (isNaN(min)) { min = Math.min(...seriesArray) }
+ return min
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @output {float} largest value in the series
+ */
+ function maximum () {
+ if (seriesArray.length > 0) {
+ if (isNaN(max)) { max = Math.max(...seriesArray) }
+ return max
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @output {float} median of the series (DO NOT USE FOR LARGE SERIES!)
+ */
function median () {
if (seriesArray.length > 0) {
const mid = Math.floor(seriesArray.length / 2)
@@ -115,6 +190,9 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @output {array} returns the entire series
+ */
function series () {
if (seriesArray.length > 0) {
return seriesArray
@@ -123,11 +201,17 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * Resets the series to its initial state
+ */
function reset () {
- seriesArray.splice(0, seriesArray.length)
+ seriesArray = /** @type {Array} */(/** @type {unknown} */(null))
+ seriesArray = []
seriesSum = 0
numPos = 0
numNeg = 0
+ min = undefined
+ max = undefined
}
return {
@@ -140,10 +224,10 @@ function createSeries (maxSeriesLength) {
numberOfValuesEqualOrBelow,
sum,
average,
+ minimum,
+ maximum,
median,
series,
reset
}
}
-
-export { createSeries }
diff --git a/app/engine/utils/Series.test.js b/app/engine/utils/Series.test.js
index 8df93334a7..1d9962c3ec 100644
--- a/app/engine/utils/Series.test.js
+++ b/app/engine/utils/Series.test.js
@@ -1,9 +1,10 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- As this object is fundamental for most other utility objects, we must test its behaviour quite thoroughly
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
+/**
+ * As this object is fundamental for most other utility objects, we must test its behaviour quite thoroughly
+ */
import { test } from 'uvu'
import * as assert from 'uvu/assert'
@@ -21,6 +22,8 @@ test('Series behaviour with an empty series', () => {
testSum(dataSeries, 0)
testAverage(dataSeries, 0)
testMedian(dataSeries, 0)
+ testMinimum(dataSeries, 0)
+ testMaximum(dataSeries, 0)
})
test('Series behaviour with a single pushed value. Series = [9]', () => {
@@ -36,6 +39,8 @@ test('Series behaviour with a single pushed value. Series = [9]', () => {
testSum(dataSeries, 9)
testAverage(dataSeries, 9)
testMedian(dataSeries, 9)
+ testMinimum(dataSeries, 9)
+ testMaximum(dataSeries, 9)
})
test('Series behaviour with a second pushed value. Series = [9, 3]', () => {
@@ -52,6 +57,8 @@ test('Series behaviour with a second pushed value. Series = [9, 3]', () => {
testSum(dataSeries, 12)
testAverage(dataSeries, 6)
testMedian(dataSeries, 6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
})
test('Series behaviour with a third pushed value. Series = [9, 3, 6]', () => {
@@ -69,6 +76,8 @@ test('Series behaviour with a third pushed value. Series = [9, 3, 6]', () => {
testSum(dataSeries, 18)
testAverage(dataSeries, 6)
testMedian(dataSeries, 6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
})
test('Series behaviour with a fourth pushed value. Series = [3, 6, 12]', () => {
@@ -87,6 +96,8 @@ test('Series behaviour with a fourth pushed value. Series = [3, 6, 12]', () => {
testSum(dataSeries, 21)
testAverage(dataSeries, 7)
testMedian(dataSeries, 6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 12)
})
test('Series behaviour with a fifth pushed value. Series = [6, 12, -3]', () => {
@@ -106,6 +117,38 @@ test('Series behaviour with a fifth pushed value. Series = [6, 12, -3]', () => {
testSum(dataSeries, 15)
testAverage(dataSeries, 5)
testMedian(dataSeries, 6)
+ testMinimum(dataSeries, -3)
+ testMaximum(dataSeries, 12)
+})
+
+test('Series behaviour pushing out the min and max value and forcing a recalculate of min/max via the array.', () => {
+ const dataSeries = createSeries(3)
+ dataSeries.push(9)
+ dataSeries.push(3)
+ dataSeries.push(6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
+ dataSeries.push(6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 6)
+ dataSeries.push(6)
+ testMinimum(dataSeries, 6)
+ testMaximum(dataSeries, 6)
+})
+
+test('Series behaviour pushing out the min and max value, replacing them just in time.', () => {
+ const dataSeries = createSeries(3)
+ dataSeries.push(9)
+ dataSeries.push(3)
+ dataSeries.push(6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
+ dataSeries.push(12)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 12)
+ dataSeries.push(1)
+ testMinimum(dataSeries, 1)
+ testMaximum(dataSeries, 12)
})
test('Series behaviour with a five pushed values followed by a reset, Series = []', () => {
@@ -160,4 +203,12 @@ function testMedian (series, expectedValue) {
assert.ok(series.median() === expectedValue, `Expected median to be ${expectedValue}, encountered ${series.median()}`)
}
+function testMinimum (series, expectedValue) {
+ assert.ok(series.minimum() === expectedValue, `Expected minimum to be ${expectedValue}, encountered ${series.minimum()}`)
+}
+
+function testMaximum (series, expectedValue) {
+ assert.ok(series.maximum() === expectedValue, `Expected maximum to be ${expectedValue}, encountered ${series.maximum()}`)
+}
+
test.run()
diff --git a/app/engine/utils/StreamFilter.js b/app/engine/utils/StreamFilter.js
index f22aea991b..6f77c68664 100644
--- a/app/engine/utils/StreamFilter.js
+++ b/app/engine/utils/StreamFilter.js
@@ -1,23 +1,31 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This keeps an array, which we can ask for an moving average
-
- Please note: The array contains maxLength values
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
+/**
+ * This keeps a series of specified length, which we can ask for an moving median
+ *
+ */
+import { createLabelledBinarySearchTree } from './BinarySearchTree.js'
-import { createSeries } from './Series.js'
-
-function createStreamFilter (maxLength, defaultValue) {
- const dataPoints = createSeries(maxLength)
+export function createStreamFilter (maxLength, defaultValue) {
let lastRawDatapoint = defaultValue
let cleanDatapoint = defaultValue
+ let position = 0
+ let bst = createLabelledBinarySearchTree()
function push (dataPoint) {
- lastRawDatapoint = dataPoint
- dataPoints.push(dataPoint)
- cleanDatapoint = dataPoints.median()
+ if (dataPoint !== undefined && !isNaN(dataPoint)) {
+ lastRawDatapoint = dataPoint
+ if (maxLength > 0) {
+ position = (position + 1) % maxLength
+ bst.remove(position)
+ bst.push(position, dataPoint)
+ } else {
+ bst.push(position, dataPoint)
+ }
+ cleanDatapoint = bst.median()
+ }
}
function raw () {
@@ -25,7 +33,7 @@ function createStreamFilter (maxLength, defaultValue) {
}
function clean () {
- if (dataPoints.length() > 0) {
+ if (bst.size() > 0) {
// The series contains sufficient values to be valid
return cleanDatapoint
} else {
@@ -35,11 +43,11 @@ function createStreamFilter (maxLength, defaultValue) {
}
function reliable () {
- return dataPoints.length() > 0
+ return bst.size() > 0
}
function reset () {
- dataPoints.reset()
+ bst.reset()
lastRawDatapoint = defaultValue
cleanDatapoint = defaultValue
}
@@ -52,5 +60,3 @@ function createStreamFilter (maxLength, defaultValue) {
reset
}
}
-
-export { createStreamFilter }
diff --git a/app/engine/utils/StreamFilter.test.js b/app/engine/utils/StreamFilter.test.js
index 05c0d4fea4..a5dbc9c8e9 100644
--- a/app/engine/utils/StreamFilter.test.js
+++ b/app/engine/utils/StreamFilter.test.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
import { test } from 'uvu'
import * as assert from 'uvu/assert'
diff --git a/app/engine/utils/WeighedSeries.js b/app/engine/utils/WeighedSeries.js
new file mode 100644
index 0000000000..8581597f5b
--- /dev/null
+++ b/app/engine/utils/WeighedSeries.js
@@ -0,0 +1,113 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ This creates a series with a maximum number of values
+ It allows for determining the Average, Median, Number of Positive, number of Negative
+*/
+
+import { createSeries } from './Series.js'
+
+export function createWeighedSeries (maxSeriesLength, defaultValue) {
+ const dataArray = createSeries(maxSeriesLength)
+ const weightArray = createSeries(maxSeriesLength)
+ const weightedArray = createSeries(maxSeriesLength)
+
+ function push (value, weight) {
+ if (value === undefined || isNaN(value) || weight === undefined || isNaN(weight)) { return }
+ dataArray.push(value)
+ weightArray.push(weight)
+ weightedArray.push(value * weight)
+ }
+
+ function length () {
+ return dataArray.length()
+ }
+
+ function atSeriesBegin () {
+ return dataArray.atSeriesBegin()
+ }
+
+ function atSeriesEnd () {
+ return dataArray.atSeriesEnd()
+ }
+
+ function get (position) {
+ return dataArray.get(position)
+ }
+
+ function numberOfValuesAbove (testedValue) {
+ return dataArray.numberOfValuesAbove(testedValue)
+ }
+
+ function numberOfValuesEqualOrBelow (testedValue) {
+ return dataArray.numberOfValuesEqualOrBelow(testedValue)
+ }
+
+ function sum () {
+ return dataArray.sum()
+ }
+
+ function average () {
+ if (dataArray.length() > 0) {
+ // The series contains sufficient values to be valid
+ return dataArray.average()
+ } else {
+ // The array isn't sufficiently filled
+ return defaultValue
+ }
+ }
+
+ function weighedAverage () {
+ if (dataArray.length() > 0 && weightArray.sum() !== 0) {
+ return (weightedArray.sum() / weightArray.sum())
+ } else {
+ return defaultValue
+ }
+ }
+
+ function minimum () {
+ return dataArray.minimum()
+ }
+
+ function maximum () {
+ return dataArray.maximum()
+ }
+
+ function median () {
+ return dataArray.median()
+ }
+
+ function reliable () {
+ return dataArray.length() > 0
+ }
+
+ function series () {
+ return dataArray.series()
+ }
+
+ function reset () {
+ dataArray.reset()
+ weightArray.reset()
+ weightedArray.reset()
+ }
+
+ return {
+ push,
+ length,
+ atSeriesBegin,
+ atSeriesEnd,
+ get,
+ numberOfValuesAbove,
+ numberOfValuesEqualOrBelow,
+ sum,
+ average,
+ weighedAverage,
+ minimum,
+ maximum,
+ median,
+ series,
+ reliable,
+ reset
+ }
+}
diff --git a/app/engine/utils/curveMetrics.js b/app/engine/utils/curveMetrics.js
index 4725ae74c7..746a15562c 100644
--- a/app/engine/utils/curveMetrics.js
+++ b/app/engine/utils/curveMetrics.js
@@ -1,12 +1,12 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
This keeps an array, for all in-stroke metrics
*/
import { createSeries } from './Series.js'
-function createCurveMetrics (precission = 0) {
+export function createCurveMetrics () {
const _curve = createSeries()
let _max = 0
let totalInputXTime = 0
@@ -15,7 +15,7 @@ function createCurveMetrics (precission = 0) {
function push (deltaTime, inputValue) {
// add the new dataPoint to the array, we have to move datapoints starting at the oldst ones
if (inputValue > 0) {
- _curve.push(inputValue.toFixed(precission))
+ _curve.push(inputValue)
_max = Math.max(_max, inputValue)
totalInputXTime += deltaTime * inputValue
totaltime += deltaTime
@@ -69,5 +69,3 @@ function createCurveMetrics (precission = 0) {
reset
}
}
-
-export { createCurveMetrics }
diff --git a/app/engine/utils/metrics.interface.js b/app/engine/utils/metrics.interface.js
new file mode 100644
index 0000000000..b95d8786a6
--- /dev/null
+++ b/app/engine/utils/metrics.interface.js
@@ -0,0 +1,77 @@
+/**
+ * @typedef {{isMoving: boolean,
+ * isDriveStart: boolean,
+ * isRecoveryStart: boolean,
+ * isSessionStart: boolean,
+ * isIntervalStart: boolean,
+ * isSplitEnd: boolean,
+ * isPauseStart: boolean,
+ * isPauseEnd: boolean,
+ * isSessionStop: boolean
+ * }} MetricsContext
+ */
+/**
+ * @typedef {'justrow'|
+ * 'time'|
+ * 'distance'|
+ * 'calories'|
+ * 'rest'
+ * } SessionType
+ */
+/**
+ * @typedef {'WaitingForStart'|
+ * 'Rowing'|
+ * 'Paused'|
+ * 'Stopped'
+ * } SessionState
+ */
+/**
+ * @typedef {'WaitingForDrive'|
+ * 'Drive'|
+ * 'Recovery'|
+ * 'Stopped'
+ * } StrokeState
+ */
+/**
+ * @typedef {{
+ * metricsContext: MetricsContext,
+ * sessionStatus: SessionState,
+ * strokeState: StrokeState,
+ * timestamp: number,
+ * cyclePower: number,
+ * totalLinearDistance: number,
+ * totalMovingTime: number,
+ * totalNumberOfStrokes: number,
+ * driveLastStartTime: number,
+ * driveLength: number,
+ * driveDuration: number,
+ * driveHandleForceCurve: Array,
+ * driveHandleVelocityCurve: Array,
+ * driveHandlePowerCurve: Array,
+ * drivePeakHandleForce: number,
+ * driveAverageHandleForce: number,
+ * cycleStrokeRate: number,
+ * cyclePace: number,
+ * cycleLinearVelocity: number,
+ * cycleDistance: number,
+ * cycleDuration: number,
+ * cycleProjectedEndTime: number,
+ * cycleProjectedEndLinearDistance: number
+ * recoveryDuration: number,
+ * strokeCalories: number,
+ * totalCalories: number,
+ * totalCaloriesPerHour: number,
+ * totalCaloriesPerMinute: number,
+ * strokeWork: number,
+ * dragFactor: number,
+ * heartrate?: number,
+ * heartRateBatteryLevel?: number
+ * splitNumber: number
+ * }} Metrics
+ */
+/**
+ * @typedef {{
+ * totalMovingTime: number,
+ * totalLinearDistance: number
+ * }} SplitTimeDistanceData
+ */
diff --git a/app/engine/utils/workoutSegment.js b/app/engine/utils/workoutSegment.js
new file mode 100644
index 0000000000..ce62d82665
--- /dev/null
+++ b/app/engine/utils/workoutSegment.js
@@ -0,0 +1,611 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This Module supports the creation and use of workoutSegment
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#session-interval-and-split-boundaries-in-sessionmanagerjs|the description of the concepts used}
+ */
+/* eslint-disable max-lines -- This contains a lot of defensive programming, so it is long */
+import { createOLSLinearSeries } from './OLSLinearSeries.js'
+import { createSeries } from './Series.js'
+import loglevel from 'loglevel'
+const log = loglevel.getLogger('RowingEngine')
+
+export function createWorkoutSegment (config) {
+ const numOfDataPointsForAveraging = config.numOfPhasesForAveragingScreenData
+ const distanceOverTime = createOLSLinearSeries(Math.min(4, numOfDataPointsForAveraging))
+ const _power = createSeries()
+ const _linearVelocity = createSeries()
+ const _strokerate = createSeries()
+ const _strokedistance = createSeries()
+ const _caloriesPerHour = createSeries()
+ const _dragFactor = createSeries()
+ let _type = 'justrow'
+ let _startTimestamp
+ let _startMovingTime = 0
+ let _startLinearDistance = 0
+ let _startStrokeNumber = 0
+ let _startCalories = 0
+ let _targetTime = 0
+ let _targetDistance = 0
+ let _endMovingTime = 0
+ let _endLinearDistance = 0
+ let _totalNumberIntervals = 0
+ let _split = {
+ type: 'justrow',
+ targetDistance: 0,
+ targetTime: 0
+ }
+
+ function setStart (baseMetrics) {
+ resetSegmentMetrics()
+ _startMovingTime = (baseMetrics.totalMovingTime !== undefined && baseMetrics.totalMovingTime > 0 ? baseMetrics.totalMovingTime : 0)
+ _startLinearDistance = (baseMetrics.totalLinearDistance !== undefined && baseMetrics.totalLinearDistance > 0 ? baseMetrics.totalLinearDistance : 0)
+ _startTimestamp = baseMetrics.timestamp
+ _startCalories = baseMetrics.totalCalories
+ _startStrokeNumber = baseMetrics.totalNumberOfStrokes
+ }
+
+ function setStartTimestamp (timestamp) {
+ _startTimestamp = timestamp
+ }
+
+ function getStartTimestamp () {
+ return _startTimestamp
+ }
+
+ /**
+ * This function summarizes a group of intervals into a single workout
+ */
+ function summarize (intervals) {
+ let intervalNumber = 0
+ let totalDistance = 0
+ let totalTime = 0
+ let containsJustRow = false
+ _totalNumberIntervals = Math.max(intervals.length, 1)
+ switch (true) {
+ case (intervals.length === 0):
+ setEnd({ type: 'justrow' })
+ break
+ case (intervals.length === 1):
+ setEnd(intervals[0])
+ break
+ case (intervals.length > 1):
+ while (intervalNumber < intervals.length) {
+ switch (true) {
+ case (intervals[intervalNumber].type === 'rest' && intervals[intervalNumber].targetTime > 0):
+ // As a rest has no impact on the (target) total moving time and distance, there is nothing to do here
+ break
+ case (intervals[intervalNumber].type === 'distance' && intervals[intervalNumber].targetDistance > 0):
+ totalDistance = totalDistance + Number(intervals[intervalNumber].targetDistance)
+ break
+ case (intervals[intervalNumber].type === 'time' && intervals[intervalNumber].targetTime > 0):
+ totalTime = totalTime + Number(intervals[intervalNumber].targetTime)
+ break
+ case (intervals[intervalNumber].type === 'justrow'):
+ containsJustRow = true
+ break
+ default:
+ containsJustRow = true
+ }
+ intervalNumber++
+ }
+ switch (true) {
+ case (containsJustRow):
+ setEnd({ type: 'justrow' })
+ break
+ case (totalDistance > 0 && totalTime === 0):
+ setEnd({ type: 'distance', targetDistance: totalDistance })
+ break
+ case (totalTime > 0 && totalDistance === 0):
+ setEnd({ type: 'time', targetTime: totalTime })
+ break
+ case (totalTime > 0 && totalDistance > 0):
+ setEnd({ type: 'justrow' })
+ break
+ default:
+ setEnd({ type: 'justrow' })
+ }
+ break
+ default:
+ setEnd({ type: 'justrow' })
+ }
+ }
+
+ /**
+ * This function sets the segment parameters used
+ */
+ function setEnd (intervalSettings) {
+ // Set the primairy parameters
+ switch (true) {
+ case (intervalSettings.type === 'rest' && Number(intervalSettings.targetTime) > 0):
+ // A target time is set for a rest interval
+ _type = 'rest'
+ _targetTime = Number(intervalSettings.targetTime)
+ _targetDistance = 0
+ _endMovingTime = _startMovingTime + Number(intervalSettings.targetTime)
+ _endLinearDistance = 0
+ log.debug(` Workout parser, recognised ${_type} interval/split, ${_targetTime} seconds`)
+ break
+ case (intervalSettings.type === 'rest'):
+ // An undefined rest interval
+ _type = 'rest'
+ _targetTime = 0
+ _targetDistance = 0
+ _endMovingTime = _startMovingTime
+ _endLinearDistance = 0
+ log.debug(` Workout parser, recognised undetermined ${_type} interval`)
+ break
+ case (intervalSettings.type === 'distance' && Number(intervalSettings.targetDistance) > 0):
+ // A target distance is set
+ _type = 'distance'
+ _targetTime = 0
+ _targetDistance = Number(intervalSettings.targetDistance)
+ _endMovingTime = 0
+ _endLinearDistance = _startLinearDistance + Number(intervalSettings.targetDistance)
+ log.debug(` Workout parser, recognised ${_type} interval/split, ${_targetDistance} meters`)
+ break
+ case (intervalSettings.type === 'time' && Number(intervalSettings.targetTime) > 0):
+ // A target time is set
+ _type = 'time'
+ _targetTime = Number(intervalSettings.targetTime)
+ _targetDistance = 0
+ _endMovingTime = _startMovingTime + Number(intervalSettings.targetTime)
+ _endLinearDistance = 0
+ log.debug(` Workout parser, recognised ${_type} interval/split, ${_targetTime} seconds`)
+ break
+ case (intervalSettings.type === 'justrow'):
+ _type = 'justrow'
+ _targetTime = 0
+ _targetDistance = 0
+ _endMovingTime = 0
+ _endLinearDistance = 0
+ log.debug(` Workout parser, recognised ${_type} interval/split`)
+ break
+ default:
+ log.error(`Workout parser, unknown interval type '${intervalSettings.type}', defaulting to a 'justrow' interval`)
+ _type = 'justrow'
+ _targetTime = 0
+ _targetDistance = 0
+ _endMovingTime = 0
+ _endLinearDistance = 0
+ }
+
+ // Set the split parameters
+ switch (true) {
+ case (intervalSettings.type === 'rest'):
+ // A rest interval has no split defined
+ _split = {
+ type: 'rest',
+ targetDistance: 0,
+ targetTime: _targetTime
+ }
+ break
+ case (!!intervalSettings.split && intervalSettings.split !== undefined && intervalSettings.split.type === 'distance' && Number(intervalSettings.split.targetDistance) > 0):
+ // A target distance is set
+ _split = {
+ type: 'distance',
+ targetDistance: Number(intervalSettings.split.targetDistance),
+ targetTime: 0
+ }
+ break
+ case (!!intervalSettings.split && intervalSettings.split !== undefined && intervalSettings.split.type === 'time' && Number(intervalSettings.split.targetTime) > 0):
+ // A target time is set
+ _split = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: Number(intervalSettings.split.targetTime)
+ }
+ break
+ case (!!intervalSettings.split && intervalSettings.split !== undefined && intervalSettings.split.type === 'justrow'):
+ _split = {
+ type: _type,
+ targetDistance: _targetDistance,
+ targetTime: _targetTime
+ }
+ break
+ case (!intervalSettings.split):
+ // Split is left empty, we default to the entire interval
+ _split = {
+ type: _type,
+ targetDistance: _targetDistance,
+ targetTime: _targetTime
+ }
+ break
+ default:
+ log.error(`Workout parser, unknown split type '${intervalSettings.split.type}', defaulting to copying interval type`)
+ _split = {
+ type: _type,
+ targetDistance: _targetDistance,
+ targetTime: _targetTime
+ }
+ }
+ }
+
+ /**
+ * Updates projectiondata and segment metrics
+ */
+ function push (baseMetrics) {
+ distanceOverTime.push(baseMetrics.totalMovingTime, baseMetrics.totalLinearDistance)
+ if (!!baseMetrics.cyclePower && !isNaN(baseMetrics.cyclePower) && baseMetrics.cyclePower > 0) { _power.push(baseMetrics.cyclePower) }
+ if (!!baseMetrics.cycleLinearVelocity && !isNaN(baseMetrics.cycleLinearVelocity) && baseMetrics.cycleLinearVelocity > 0) { _linearVelocity.push(baseMetrics.cycleLinearVelocity) }
+ if (!!baseMetrics.cycleStrokeRate && !isNaN(baseMetrics.cycleStrokeRate) && baseMetrics.cycleStrokeRate > 0) { _strokerate.push(baseMetrics.cycleStrokeRate) }
+ if (!!baseMetrics.cycleDistance && !isNaN(baseMetrics.cycleDistance) && baseMetrics.cycleDistance > 0) { _strokedistance.push(baseMetrics.cycleDistance) }
+ if (!!baseMetrics.totalCaloriesPerHour && !isNaN(baseMetrics.totalCaloriesPerHour) && baseMetrics.totalCaloriesPerHour > 0) { _caloriesPerHour.push(baseMetrics.totalCaloriesPerHour) }
+ if (!!baseMetrics.dragFactor && !isNaN(baseMetrics.dragFactor) && baseMetrics.dragFactor > 0) { _dragFactor.push(baseMetrics.dragFactor) }
+ }
+
+ /**
+ * @returns {float} the distance from te start of the workoutsegment
+ */
+ function distanceFromStart (baseMetrics) {
+ if (!isNaN(_startLinearDistance) && _startLinearDistance >= 0 && !isNaN(baseMetrics.totalLinearDistance) && baseMetrics.totalLinearDistance > _startLinearDistance) {
+ return baseMetrics.totalLinearDistance - _startLinearDistance
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @returns {float} the remaining distance to the end of the workoutsegment
+ */
+ function distanceToEnd (baseMetrics) {
+ if (_type === 'distance' && _endLinearDistance > 0) {
+ // We have set a distance boundary
+ return _endLinearDistance - baseMetrics.totalLinearDistance
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @returns {float} the moving time since the start of the workoutsegment
+ */
+ function timeSinceStart (baseMetrics) {
+ if (!isNaN(_startMovingTime) && _startMovingTime >= 0 && !isNaN(baseMetrics.totalMovingTime) && baseMetrics.totalMovingTime > _startMovingTime) {
+ return baseMetrics.totalMovingTime - _startMovingTime
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @returns {float} the projected time to the end of the workoutsegment
+ */
+ function projectedEndTime () {
+ switch (true) {
+ case (_type === 'distance' && _endLinearDistance > 0 && distanceOverTime.reliable()):
+ // We are in a distance based interval, so we need to project
+ return (distanceOverTime.projectY(_endLinearDistance) - _startMovingTime)
+ case (_type === 'time' && _endMovingTime > 0):
+ return _targetTime
+ default:
+ return undefined
+ }
+ }
+
+ /**
+ * @returns {float} the projected time to the end of the workoutsegment
+ */
+ function projectedEndDistance () {
+ switch (true) {
+ case (_type === 'distance' && _endLinearDistance > 0):
+ return _targetDistance
+ case (_type === 'time' && _endMovingTime > 0 && distanceOverTime.reliable()):
+ // We are in a time based interval, so we need to project
+ return (distanceOverTime.projectX(_endMovingTime) - _startLinearDistance)
+ default:
+ return undefined
+ }
+ }
+
+ /**
+ * @returns {float} the remaining time to the end of the workoutsegment
+ */
+ function timeToEnd (baseMetrics) {
+ if ((_type === 'time' || _type === 'rest') && _endMovingTime > 0) {
+ // We are in a time based interval
+ return _endMovingTime - baseMetrics.totalMovingTime
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @returns {float} the total time since start of the workoutsegment
+ */
+ function totalTime (baseMetrics) {
+ if (!isNaN(_startTimestamp) && _startTimestamp >= 0 && !isNaN(baseMetrics.timestamp) && baseMetrics.timestamp > _startTimestamp) {
+ return Math.max((baseMetrics.timestamp.getTime() - _startTimestamp.getTime()) / 1000, (baseMetrics.totalMovingTime - _startMovingTime))
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @returns {float} the time spent not moving since start of the workoutsegment
+ */
+ function restTime (baseMetrics) {
+ if (!isNaN(_startMovingTime) && !isNaN(_startTimestamp) && _startTimestamp >= 0 && !isNaN(baseMetrics.totalMovingTime) && !isNaN(baseMetrics.timestamp) && baseMetrics.timestamp > _startTimestamp) {
+ return (Math.max(baseMetrics.timestamp.getTime() - _startTimestamp.getTime(), 0) / 1000) - Math.max(baseMetrics.totalMovingTime - _startMovingTime, 0)
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @returns {float} the time spent not moving since the start of the workoutsgment
+ */
+ function averageLinearVelocity (baseMetrics) {
+ if (!isNaN(_startMovingTime) && _startMovingTime >= 0 && !isNaN(_startLinearDistance) && _startLinearDistance >= 0 && !isNaN(baseMetrics.totalMovingTime) && baseMetrics.totalMovingTime > _startMovingTime && !isNaN(baseMetrics.totalLinearDistance) && baseMetrics.totalLinearDistance > _startLinearDistance) {
+ return (baseMetrics.totalLinearDistance - _startLinearDistance) / (baseMetrics.totalMovingTime - _startMovingTime)
+ } else {
+ return _linearVelocity.average()
+ }
+ }
+
+ /**
+ * @param {float} linear velocity
+ * @returns {float} pace per 500 meters
+ */
+ function linearVelocityToPace (linearVel) {
+ if (!isNaN(linearVel) && linearVel > 0) {
+ return (500.0 / linearVel)
+ } else {
+ return Infinity
+ }
+ }
+
+ /**
+ * @returns {number} the number of strokes since the start of the segment
+ */
+ function numberOfStrokes (baseMetrics) {
+ if (!isNaN(_startStrokeNumber) && _startStrokeNumber >= 0 && !isNaN(baseMetrics.totalNumberOfStrokes) && baseMetrics.totalNumberOfStrokes > _startStrokeNumber) {
+ return baseMetrics.totalNumberOfStrokes - _startStrokeNumber
+ } else {
+ return 0
+ }
+ }
+
+ function spentCalories (baseMetrics) {
+ if (!isNaN(_startCalories) && _startCalories >= 0 && !isNaN(baseMetrics.totalCalories) && baseMetrics.totalCalories > _startCalories) {
+ return baseMetrics.totalCalories - _startCalories
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @returns {boolean} If the boundary of the planned segment has been reached
+ */
+ function isEndReached (baseMetrics) {
+ if ((_type === 'distance' && _endLinearDistance > 0 && baseMetrics.totalLinearDistance >= _endLinearDistance) || (_type === 'time' && _endMovingTime > 0 && baseMetrics.totalMovingTime >= _endMovingTime)) {
+ // We have exceeded the boundary
+ return true
+ } else {
+ return false
+ }
+ }
+
+ /*
+ * This function is used to precisely calculate the end of a workout segment after the sessionManager conlcudes it has passed the workoutSegment's boundary
+ */
+ function interpolateEnd (prevMetrics, currMetrics) {
+ const projectedMetrics = { ...prevMetrics }
+ projectedMetrics.modified = false
+ switch (true) {
+ case (_type === 'distance' && _endLinearDistance > 0 && currMetrics.totalLinearDistance > _endLinearDistance):
+ // We are in a distance based interval, and overshot the targetDistance
+ projectedMetrics.totalMovingTime = interpolatedTime(prevMetrics, currMetrics, _endLinearDistance)
+ projectedMetrics.timestamp = new Date(currMetrics.timestamp.getTime() - ((currMetrics.totalMovingTime - projectedMetrics.totalMovingTime) * 1000))
+ projectedMetrics.totalLinearDistance = _endLinearDistance
+ projectedMetrics.timestamp = currMetrics.timestamp - ((currMetrics.totalMovingTime - projectedMetrics.totalMovingTime) * 1000)
+ projectedMetrics.modified = true
+ break
+ case (_type === 'time' && _endMovingTime > 0 && currMetrics.totalMovingTime > _endMovingTime):
+ // We are in a time based interval, and overshot the targetTime
+ projectedMetrics.totalLinearDistance = interpolatedDistance(prevMetrics, currMetrics, _endMovingTime)
+ projectedMetrics.totalMovingTime = _endMovingTime
+ projectedMetrics.timestamp = new Date(_startTimestamp.getTime() + (_targetTime * 1000))
+ projectedMetrics.modified = true
+ break
+ default:
+ // Nothing to do
+ }
+ projectedMetrics.timestamp = new Date(currMetrics.timestamp.getTime() - ((currMetrics.totalMovingTime - projectedMetrics.totalMovingTime) * 1000))
+ // Prevent the edge case where we trigger two strokes at milliseconds apart when using the interpolation function
+ projectedMetrics.metricsContext.isDriveStart = false
+ projectedMetrics.metricsContext.isRecoveryStart = false
+ projectedMetrics.metricsContext.isSessionStart = false
+ projectedMetrics.metricsContext.isIntervalEnd = false
+ projectedMetrics.metricsContext.isSplitEnd = false
+ projectedMetrics.metricsContext.isPauseStart = false
+ projectedMetrics.metricsContext.isPauseEnd = false
+ projectedMetrics.metricsContext.isSessionStop = false
+ return projectedMetrics
+ }
+
+ function interpolatedTime (prevMetrics, currMetrics, targetDistance) {
+ if (prevMetrics.totalLinearDistance < targetDistance && targetDistance < currMetrics.totalLinearDistance) {
+ // See https://en.wikipedia.org/wiki/Linear_interpolation
+ return (prevMetrics.totalMovingTime + ((currMetrics.totalMovingTime - prevMetrics.totalMovingTime) * ((targetDistance - prevMetrics.totalLinearDistance) / (currMetrics.totalLinearDistance - prevMetrics.totalLinearDistance))))
+ } else {
+ return currMetrics.totalMovingTime
+ }
+ }
+
+ function interpolatedDistance (prevMetrics, currMetrics, targetTime) {
+ if (prevMetrics.totalMovingTime < targetTime && targetTime < currMetrics.totalMovingTime) {
+ // See https://en.wikipedia.org/wiki/Linear_interpolation
+ return (prevMetrics.totalLinearDistance + ((currMetrics.totalLinearDistance - prevMetrics.totalLinearDistance) * ((targetTime - prevMetrics.totalMovingTime) / (currMetrics.totalMovingTime - prevMetrics.totalMovingTime))))
+ } else {
+ return currMetrics.totalLinearDistance
+ }
+ }
+
+ function getSplit () {
+ return _split
+ }
+
+ function targetDistance () {
+ if (_type === 'distance' && _endLinearDistance > 0) {
+ return _targetDistance
+ } else {
+ return undefined
+ }
+ }
+
+ function targetTime () {
+ if (_type === 'time' && _endMovingTime > 0) {
+ // We have a distance boundary
+ return _targetTime
+ } else {
+ return undefined
+ }
+ }
+
+ function type () {
+ return _type
+ }
+
+ /**
+ * This function returns all the workoutSegment metrics for the current workoutSegment
+ */
+ function metrics (baseMetrics) {
+ return {
+ type: _type,
+ ...(_totalNumberIntervals > 0 ? { numberOfIntervals: _totalNumberIntervals } : {}),
+ numberOfStrokes: numberOfStrokes(baseMetrics),
+ distance: {
+ absoluteStart: _startLinearDistance,
+ fromStart: distanceFromStart(baseMetrics),
+ target: targetDistance(),
+ toEnd: distanceToEnd(baseMetrics),
+ projectedEnd: projectedEndDistance()
+ },
+ movingTime: {
+ absoluteStart: _startMovingTime,
+ sinceStart: timeSinceStart(baseMetrics),
+ target: targetTime(),
+ toEnd: timeToEnd(baseMetrics),
+ projectedEnd: projectedEndTime()
+ },
+ timeSpent: {
+ total: totalTime(baseMetrics),
+ moving: timeSinceStart(baseMetrics),
+ rest: restTime(baseMetrics)
+ },
+ linearVelocity: {
+ average: averageLinearVelocity(baseMetrics),
+ minimum: _linearVelocity.minimum(),
+ maximum: _linearVelocity.maximum()
+ },
+ pace: {
+ average: linearVelocityToPace(averageLinearVelocity(baseMetrics)),
+ minimum: linearVelocityToPace(_linearVelocity.minimum()),
+ maximum: linearVelocityToPace(_linearVelocity.maximum())
+ },
+ power: {
+ average: _power.average(),
+ minimum: _power.minimum(),
+ maximum: _power.maximum()
+ },
+ strokeDistance: {
+ average: _strokedistance.average(),
+ minimum: _strokedistance.minimum(),
+ maximum: _strokedistance.maximum()
+ },
+ strokerate: {
+ average: _strokerate.average(),
+ minimum: _strokerate.minimum(),
+ maximum: _strokerate.maximum()
+ },
+ dragfactor: {
+ average: _dragFactor.average(),
+ minimum: _dragFactor.minimum(),
+ maximum: _dragFactor.maximum()
+ },
+ calories: {
+ totalSpent: spentCalories(baseMetrics),
+ averagePerHour: _caloriesPerHour.average()
+ }
+ }
+ }
+
+ /**
+ * This function returns the remaining split (used for managing unplanned pausesremainder (baseMetrics)
+ */
+ function remainder (baseMetrics) {
+ switch (_type) {
+ case ('distance'):
+ return {
+ type: _type,
+ targetDistance: distanceToEnd(baseMetrics)
+ }
+ case ('time'):
+ return {
+ type: _type,
+ targetTime: timeToEnd(baseMetrics)
+ }
+ default:
+ return {
+ type: _type,
+ targetTime: 0
+ }
+ }
+ }
+
+ /**
+ * This internal function resets the metrics of the segment, this is called after setting a new target
+ */
+ function resetSegmentMetrics () {
+ _linearVelocity.reset()
+ _strokerate.reset()
+ _strokedistance.reset()
+ _caloriesPerHour.reset()
+ _power.reset()
+ _dragFactor.reset()
+ _type = 'justrow'
+ _startTimestamp = undefined
+ _startMovingTime = 0
+ _startLinearDistance = 0
+ _startStrokeNumber = 0
+ _startCalories = 0
+ _targetTime = 0
+ _targetDistance = 0
+ _endMovingTime = 0
+ _endLinearDistance = 0
+ _split = {
+ type: 'justrow',
+ targetDistance: 0,
+ targetTime: 0
+ }
+ }
+
+ /**
+ * This externally exposed function resets all data from a workoutsegment, including the regressor used for projections
+ */
+ function reset () {
+ resetSegmentMetrics()
+ distanceOverTime.reset()
+ }
+
+ return {
+ setStart,
+ setStartTimestamp,
+ getStartTimestamp,
+ summarize,
+ setEnd,
+ isEndReached,
+ interpolateEnd,
+ metrics,
+ timeSinceStart,
+ timeToEnd,
+ type,
+ push,
+ getSplit,
+ remainder,
+ reset
+ }
+}
diff --git a/app/engine/utils/workoutSegment.test.js b/app/engine/utils/workoutSegment.test.js
new file mode 100644
index 0000000000..bdc193f89e
--- /dev/null
+++ b/app/engine/utils/workoutSegment.test.js
@@ -0,0 +1,478 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This Module tests the behaviour of the workout segments
+ */
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+
+import { createWorkoutSegment } from './workoutSegment.js'
+
+const basicConfig = {
+ numOfPhasesForAveragingScreenData: 4
+}
+
+test('Test workoutSegment initialisation behaviour without setting an interval', () => {
+ const startingPoint = {
+ timestamp: new Date(),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ metricsContext: {}
+ }
+
+ const testSegment = createWorkoutSegment(basicConfig)
+ testDistanceFromStart(testSegment, startingPoint, 0)
+ testTimeSinceStart(testSegment, startingPoint, 0)
+ testdistanceToEnd(testSegment, startingPoint, undefined)
+ testTimeToEnd(testSegment, startingPoint, undefined)
+ testTargetTime(testSegment, startingPoint, undefined)
+ testTargetDistance(testSegment, startingPoint, undefined)
+ testIsEndReached(testSegment, startingPoint, false)
+})
+
+test('Test workoutSegment initialisation behaviour without setting an interval, after 2050 meters', () => {
+ const startingPoint = {
+ timestamp: new Date(),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ cyclePower: 0,
+ cycleLinearVelocity: 0,
+ cycleStrokeRate: 0,
+ cycleDistance: 0,
+ totalCaloriesPerHour: 0,
+ dragFactor: 0,
+ metricsContext: {}
+ }
+
+ const endPoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 490 * 1000),
+ totalMovingTime: 490,
+ totalLinearDistance: 2050,
+ cyclePower: 200,
+ cycleLinearVelocity: 4.16666,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const testSegment = createWorkoutSegment(basicConfig)
+ testDistanceFromStart(testSegment, startingPoint, 0)
+ testTimeSinceStart(testSegment, startingPoint, 0)
+ testdistanceToEnd(testSegment, startingPoint, undefined)
+ testTimeToEnd(testSegment, startingPoint, undefined)
+ testTargetTime(testSegment, startingPoint, undefined)
+ testTargetDistance(testSegment, startingPoint, undefined)
+ testIsEndReached(testSegment, startingPoint, false)
+ testDistanceFromStart(testSegment, endPoint, 2050)
+ testTimeSinceStart(testSegment, endPoint, 490)
+ testdistanceToEnd(testSegment, endPoint, undefined)
+ testTimeToEnd(testSegment, endPoint, undefined)
+ testIsEndReached(testSegment, endPoint, false)
+ testSegment.push(endPoint)
+ testAverageLinearVelocity (testSegment, endPoint, 4.183673469387755)
+ testMaximumLinearVelocity (testSegment, endPoint, 4.16666)
+ testMinimumLinearVelocity (testSegment, endPoint, 4.16666)
+ testAveragePace (testSegment, endPoint, 119.51219512195122)
+ testMaximumPace (testSegment, endPoint, 120.0001920003072)
+ testMinimumPace (testSegment, endPoint, 120.0001920003072)
+ testAveragePower (testSegment, endPoint, 200)
+ testMaximumPower (testSegment, endPoint, 200)
+ testMinimumPower (testSegment, endPoint, 200)
+})
+
+test('Test workoutSegment behaviour with setting a distance interval', () => {
+ const distanceInterval = {
+ type: 'distance',
+ targetDistance: 2025,
+ targetTime: 0,
+ split: {
+ type: 'distance',
+ targetDistance: 500,
+ targetTime: 0
+ }
+ }
+
+ const startingPoint = {
+ timestamp: new Date(),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ cyclePower: 0,
+ cycleLinearVelocity: 0,
+ cycleStrokeRate: 0,
+ cycleDistance: 0,
+ totalCaloriesPerHour: 0,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const middlePoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 480 * 1000),
+ totalMovingTime: 480,
+ totalLinearDistance: 2000,
+ cyclePower: 200,
+ cycleLinearVelocity: 4.16666,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const endPoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 490 * 1000),
+ totalMovingTime: 490,
+ totalLinearDistance: 2050,
+ cyclePower: 200,
+ cycleLinearVelocity: 4.16666,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const testSegment = createWorkoutSegment(basicConfig)
+ testSegment.setStart(startingPoint)
+ testSegment.setEnd(distanceInterval)
+ testDistanceFromStart(testSegment, startingPoint, 0)
+ testTimeSinceStart(testSegment, startingPoint, 0)
+ testdistanceToEnd(testSegment, startingPoint, 2025)
+ testTimeToEnd(testSegment, startingPoint, undefined)
+ testIsEndReached(testSegment, startingPoint, false)
+ testDistanceFromStart(testSegment, middlePoint, 2000)
+ testTimeSinceStart(testSegment, middlePoint, 480)
+ testdistanceToEnd(testSegment, middlePoint, 25)
+ testTimeToEnd(testSegment, middlePoint, undefined)
+ testIsEndReached(testSegment, middlePoint, false)
+ testDistanceFromStart(testSegment, endPoint, 2050)
+ testTimeSinceStart(testSegment, endPoint, 490)
+ testdistanceToEnd(testSegment, endPoint, -25)
+ testTimeToEnd(testSegment, endPoint, undefined)
+ testIsEndReached(testSegment, endPoint, true)
+ testInterpolation(testSegment, middlePoint, endPoint, 485, 2025)
+ testSegment.push(middlePoint)
+ testSegment.push(endPoint)
+ testAverageLinearVelocity (testSegment, endPoint, 4.183673469387755)
+ testMaximumLinearVelocity (testSegment, endPoint, 4.16666)
+ testMinimumLinearVelocity (testSegment, endPoint, 4.16666)
+ testAveragePace (testSegment, endPoint, 119.51219512195122)
+ testMaximumPace (testSegment, endPoint, 120.0001920003072)
+ testMinimumPace (testSegment, endPoint, 120.0001920003072)
+ testAveragePower (testSegment, endPoint, 200)
+ testMaximumPower (testSegment, endPoint, 200)
+ testMinimumPower (testSegment, endPoint, 200)
+})
+
+test('Test workoutSegment behaviour with setting a time interval', () => {
+ const distanceInterval = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 485,
+ split: {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 60
+ }
+ }
+
+ const startingPoint = {
+ timestamp: new Date(),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ metricsContext: {}
+ }
+
+ const middlePoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 480 * 1000),
+ totalMovingTime: 480,
+ totalLinearDistance: 2000,
+ cyclePower: 200,
+ cycleLinearVelocity: 4.16666,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const endPoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 490 * 1000),
+ totalMovingTime: 490,
+ totalLinearDistance: 2050,
+ cyclePower: 200,
+ cycleLinearVelocity: 4.16666,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const testSegment = createWorkoutSegment(basicConfig)
+ testSegment.setStart(startingPoint)
+ testSegment.setEnd(distanceInterval)
+ testDistanceFromStart(testSegment, startingPoint, 0)
+ testTimeSinceStart(testSegment, startingPoint, 0)
+ testdistanceToEnd(testSegment, startingPoint, undefined)
+ testTimeToEnd(testSegment, startingPoint, 485)
+ testIsEndReached(testSegment, startingPoint, false)
+ testDistanceFromStart(testSegment, middlePoint, 2000)
+ testTimeSinceStart(testSegment, middlePoint, 480)
+ testdistanceToEnd(testSegment, middlePoint, undefined)
+ testTimeToEnd(testSegment, middlePoint, 5)
+ testIsEndReached(testSegment, middlePoint, false)
+ testDistanceFromStart(testSegment, endPoint, 2050)
+ testTimeSinceStart(testSegment, endPoint, 490)
+ testdistanceToEnd(testSegment, endPoint, undefined)
+ testTimeToEnd(testSegment, endPoint, -5)
+ testIsEndReached(testSegment, endPoint, true)
+ testInterpolation(testSegment, middlePoint, endPoint, 485, 2025)
+ testSegment.push(middlePoint)
+ testSegment.push(endPoint)
+ testAverageLinearVelocity (testSegment, middlePoint, 4.166666666666667)
+ testMaximumLinearVelocity (testSegment, middlePoint, 4.16666)
+ testMinimumLinearVelocity (testSegment, middlePoint, 4.16666)
+ testAveragePace (testSegment, middlePoint, 119.99999999999999)
+ testMaximumPace (testSegment, middlePoint, 120.0001920003072)
+ testMinimumPace (testSegment, middlePoint, 120.0001920003072)
+ testAveragePower (testSegment, middlePoint, 200)
+ testMaximumPower (testSegment, middlePoint, 200)
+ testMinimumPower (testSegment, middlePoint, 200)
+})
+
+test('Test split behaviour when setting a distance interval', () => {
+ const distanceInterval = {
+ type: 'distance',
+ targetDistance: 2025,
+ targetTime: 0,
+ split: {
+ type: 'distance',
+ targetDistance: 500,
+ targetTime: 0
+ }
+ }
+
+ const startingPoint = {
+ timestamp: new Date(),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ metricsContext: {}
+ }
+
+ const middlePoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 118 * 1000),
+ totalMovingTime: 118,
+ totalLinearDistance: 490,
+ cyclePower: 180,
+ cycleLinearVelocity: 4.1,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const endPoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 122 * 1000),
+ totalMovingTime: 122,
+ totalLinearDistance: 510,
+ cyclePower: 220,
+ cycleLinearVelocity: 4.3,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const testSegment = createWorkoutSegment(basicConfig)
+ const testSplit = createWorkoutSegment(basicConfig)
+ testSegment.setStart(startingPoint)
+ testSegment.setEnd(distanceInterval)
+ testSplit.setStart(startingPoint)
+ testSplit.setEnd(testSegment.getSplit())
+ testDistanceFromStart(testSplit, startingPoint, 0)
+ testTimeSinceStart(testSplit, startingPoint, 0)
+ testdistanceToEnd(testSplit, startingPoint, 500)
+ testTimeToEnd(testSplit, startingPoint, undefined)
+ testIsEndReached(testSplit, startingPoint, false)
+ testDistanceFromStart(testSplit, middlePoint, 490)
+ testTimeSinceStart(testSplit, middlePoint, 118)
+ testdistanceToEnd(testSplit, middlePoint, 10)
+ testTimeToEnd(testSplit, middlePoint, undefined)
+ testIsEndReached(testSplit, middlePoint, false)
+ testDistanceFromStart(testSplit, endPoint, 510)
+ testTimeSinceStart(testSplit, endPoint, 122)
+ testdistanceToEnd(testSplit, endPoint, -10)
+ testTimeToEnd(testSplit, endPoint, undefined)
+ testIsEndReached(testSplit, endPoint, true)
+ testInterpolation(testSplit, middlePoint, endPoint, 120, 500)
+ testSegment.push(middlePoint)
+ testSegment.push(endPoint)
+ testAverageLinearVelocity (testSegment, middlePoint, 4.1525423728813555)
+ testMaximumLinearVelocity (testSegment, middlePoint, 4.3)
+ testMinimumLinearVelocity (testSegment, middlePoint, 4.1)
+ testAveragePace (testSegment, middlePoint, 120.40816326530613)
+ testMaximumPace (testSegment, middlePoint, 116.27906976744187)
+ testMinimumPace (testSegment, middlePoint, 121.95121951219514)
+ testAveragePower (testSegment, middlePoint, 200)
+ testMaximumPower (testSegment, middlePoint, 220)
+ testMinimumPower (testSegment, middlePoint, 180)
+})
+
+test('Test split behaviour with setting a time interval', () => {
+ const distanceInterval = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 485,
+ split: {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 120
+ }
+ }
+
+ const startingPoint = {
+ timestamp: new Date(),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ metricsContext: {}
+ }
+
+ const middlePoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 118 * 1000),
+ totalMovingTime: 118,
+ totalLinearDistance: 490,
+ cyclePower: 200,
+ cycleLinearVelocity: 4.16666,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const endPoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 122 * 1000),
+ totalMovingTime: 122,
+ totalLinearDistance: 510,
+ cyclePower: 200,
+ cycleLinearVelocity: 4.16666,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const testSegment = createWorkoutSegment(basicConfig)
+ const testSplit = createWorkoutSegment(basicConfig)
+ testSegment.setStart(startingPoint)
+ testSegment.setEnd(distanceInterval)
+ testSplit.setStart(startingPoint)
+ testSplit.setEnd(testSegment.getSplit())
+ testDistanceFromStart(testSplit, startingPoint, 0)
+ testTimeSinceStart(testSplit, startingPoint, 0)
+ testdistanceToEnd(testSplit, startingPoint, undefined)
+ testTimeToEnd(testSplit, startingPoint, 120)
+ testIsEndReached(testSplit, startingPoint, false)
+ testDistanceFromStart(testSplit, middlePoint, 490)
+ testTimeSinceStart(testSplit, middlePoint, 118)
+ testdistanceToEnd(testSplit, middlePoint, undefined)
+ testTimeToEnd(testSplit, middlePoint, 2)
+ testIsEndReached(testSplit, middlePoint, false)
+ testDistanceFromStart(testSplit, endPoint, 510)
+ testTimeSinceStart(testSplit, endPoint, 122)
+ testdistanceToEnd(testSplit, endPoint, undefined)
+ testTimeToEnd(testSplit, endPoint, -2)
+ testIsEndReached(testSplit, endPoint, true)
+ testInterpolation(testSplit, middlePoint, endPoint, 120, 500)
+ testSegment.push(middlePoint)
+ testSegment.push(endPoint)
+ testAverageLinearVelocity (testSegment, middlePoint, 4.1525423728813555)
+ testMaximumLinearVelocity (testSegment, middlePoint, 4.16666)
+ testMinimumLinearVelocity (testSegment, middlePoint, 4.16666)
+ testAveragePace (testSegment, middlePoint, 120.40816326530613)
+ testMaximumPace (testSegment, middlePoint, 120.0001920003072)
+ testMinimumPace (testSegment, middlePoint, 120.0001920003072)
+ testAveragePower (testSegment, middlePoint, 200)
+ testMaximumPower (testSegment, middlePoint, 200)
+ testMinimumPower (testSegment, middlePoint, 200)
+})
+
+// ToDo: Test the project EndTime and project EndDistance functions
+
+function testDistanceFromStart (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).distance.fromStart === expectedValue, `Expected distance from the start should be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).distance.fromStart}`)
+}
+
+function testTimeSinceStart (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).movingTime.sinceStart === expectedValue, `Expected time since start should be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).movingTime.sinceStart}`)
+}
+
+function testdistanceToEnd (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).distance.toEnd === expectedValue, `Expected distance from the end to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).distance.toEnd}`)
+}
+
+function testTimeToEnd (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).movingTime.toEnd === expectedValue, `Expected time to end to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).movingTime.toEnd}`)
+}
+
+function testIsEndReached (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.isEndReached(testedDatapoint) === expectedValue, `Expected time to end to be ${expectedValue}, encountered ${testedSegment.isEndReached(testedDatapoint)}`)
+}
+
+function testTargetTime (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).movingTime.target === expectedValue, `Expected time to end to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).movingTime.target}`)
+}
+
+function testTargetDistance (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).distance.target === expectedValue, `Expected time to end to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).distance.target}`)
+}
+
+function testInterpolation (testedSegment, dataPointOne, dataPointTwo, ExpectedTime, ExpectedDistance) {
+ assert.ok(testedSegment.interpolateEnd(dataPointOne, dataPointTwo).totalMovingTime === ExpectedTime, `Expected extrapolated time be ${ExpectedTime}, encountered ${testedSegment.interpolateEnd(dataPointOne, dataPointTwo).totalMovingTime}`)
+ assert.ok(testedSegment.interpolateEnd(dataPointOne, dataPointTwo).totalLinearDistance === ExpectedDistance, `Expected time to end to be ${ExpectedDistance}, encountered ${testedSegment.interpolateEnd(dataPointOne, dataPointTwo).totalLinearDistance}`)
+}
+
+function testAverageLinearVelocity (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).linearVelocity.average === expectedValue, `Expected average linear velocity to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).linearVelocity.average}`)
+}
+
+function testMaximumLinearVelocity (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).linearVelocity.maximum === expectedValue, `Expected maximum linear velocity to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).linearVelocity.maximum}`)
+}
+
+function testMinimumLinearVelocity (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).linearVelocity.minimum === expectedValue, `Expected minimum linear velocity to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).linearVelocity.minimum}`)
+}
+
+function testAveragePace (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).pace.average === expectedValue, `Expected average pace to end to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).pace.average}`)
+}
+
+function testMaximumPace (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).pace.maximum === expectedValue, `Expected maximum pace to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).pace.maximum}`)
+}
+
+function testMinimumPace (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).pace.minimum === expectedValue, `Expected minimum pace to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).pace.minimum}`)
+}
+
+function testAveragePower (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).power.average === expectedValue, `Expected average power to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).power.average}`)
+}
+
+function testMaximumPower (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).power.maximum === expectedValue, `Expected maximum power to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).power.maximum}`)
+}
+
+function testMinimumPower (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).power.minimum === expectedValue, `Expected minimum power to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).power.minimum}`)
+}
+
+test.run()
diff --git a/app/gpio/GpioTimerService.js b/app/gpio/GpioTimerService.js
index 368ab7ca73..61dbd29527 100644
--- a/app/gpio/GpioTimerService.js
+++ b/app/gpio/GpioTimerService.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Measures the time between impulses on the GPIO pin. Started in a
separate thread, since we want the measured time to be as close as
@@ -27,7 +27,7 @@ export function createGpioTimerService () {
// setting priority of current process
os.setPriority(config.gpioPriority)
} catch (err) {
- log.debug('Gpio-service: FAILED to set priority of Gpio-Thread, are root permissions granted?')
+ log.debug(`Gpio-service: FAILED to set priority of Gpio-Thread, error ${err}, are root permissions granted?`)
}
}
@@ -52,9 +52,17 @@ export function createGpioTimerService () {
let previousTick = 0
// Define the alert handler
- sensor.on('alert', (level, currentTick) => {
+ sensor.on('alert', (level, rawCurrentTick) => {
if ((triggeredFlank === 'Both') || (triggeredFlank === 'Down' && level === 0) || (triggeredFlank === 'Up' && level === 1)) {
- const currentDt = ((currentTick >> 0) - (previousTick >> 0)) / 1e6
+ const currentTick = (rawCurrentTick >> 0) / 1e6
+ let currentDt
+ if (currentTick > previousTick) {
+ currentDt = currentTick - previousTick
+ } else {
+ // We had a rollover of the tick, so the current tick misses 4,294,967,295 us
+ log.debug('Gpio-service: tick rollover detected and corrected')
+ currentDt = (currentTick + 4294.967295) - previousTick
+ }
previousTick = currentTick
process.send(currentDt)
}
diff --git a/app/peripherals/PeripheralConstants.js b/app/peripherals/PeripheralConstants.js
new file mode 100644
index 0000000000..a5f9066b74
--- /dev/null
+++ b/app/peripherals/PeripheralConstants.js
@@ -0,0 +1,22 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * @file Some constants used by the peripherals and especially the PM5 interface
+ *
+ * @remark please note: hardware versions exclude a software version, and thus might confuse the client
+ * See https://www.concept2.com/service/monitors/pm5/firmware for available versions
+ * For ErgZone, it is crucial to set the manufacturer to the appname to correctly handle our data
+ */
+export const PeripheralConstants = {
+ serial: '431099999',
+ model: 'PM5',
+ name: 'PM5 431099999',
+ hardwareRevision: '634',
+ firmwareRevision: '8200-000372-176.000',
+ manufacturer: `${process.env.npm_package_name || ''}`
+}
+
+export const bleBroadcastInterval = 1000
+export const bleMinimumKnowDataUpdateInterval = 4000
diff --git a/app/peripherals/PeripheralManager.js b/app/peripherals/PeripheralManager.js
new file mode 100644
index 0000000000..97a97eed66
--- /dev/null
+++ b/app/peripherals/PeripheralManager.js
@@ -0,0 +1,473 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This manager creates the different Bluetooth Low Energy (BLE), ANT+ and MQTT Peripherals and allows
+ * switching between them
+ */
+/* eslint-disable max-lines -- This handles quite a lot of peripherals, can't do that with less code */
+import EventEmitter from 'node:events'
+
+import log from 'loglevel'
+
+import AntManager from './ant/AntManager.js'
+import { BleManager } from './ble/BleManager.js'
+
+import { createAntHrmPeripheral } from './ant/HrmPeripheral.js'
+import { createBleHrmPeripheral } from './ble/HrmPeripheral.js'
+import { createCpsPeripheral } from './ble/CpsPeripheral.js'
+import { createCscPeripheral } from './ble/CscPeripheral.js'
+import { createFEPeripheral } from './ant/FEPeripheral.js'
+import { createFtmsPeripheral } from './ble/FtmsPeripheral.js'
+import { createMQTTPeripheral } from './mqtt/mqtt.js'
+import { createPm5Peripheral } from './ble/Pm5Peripheral.js'
+
+/**
+ * @type {Array}
+ */
+const bleModes = ['FTMS', 'FTMSBIKE', 'PM5', 'CSC', 'CPS', 'OFF']
+/**
+ * @type {Array}
+ */
+const antModes = ['FE', 'OFF']
+/**
+ * @type {Array}
+ */
+const hrmModes = ['ANT', 'BLE', 'OFF']
+
+/**
+ * @param {Config} config
+ */
+export function createPeripheralManager (config) {
+ /**
+ * @type {EventEmitter<{heartRateMeasurement: Array>, control: Array}>}
+ */
+ const emitter = new EventEmitter()
+ const mqttEnabled = (config.mqtt.mqttBroker !== '') && (config.mqtt.username !== '') && (config.mqtt.password !== '') && (config.mqtt.machineName !== '')
+ /**
+ * @type {AntManager}
+ */
+ let _antManager
+ /**
+ * @type {BleManager}
+ */
+ let _bleManager
+
+ /**
+ * @type {ReturnType | undefined}
+ */
+ let blePeripheral
+ /**
+ * @type {BluetoothModes}
+ */
+ let bleMode
+
+ /**
+ * @type {ReturnType | undefined}
+ */
+ let antPeripheral
+ /**
+ * @type {AntPlusModes}
+ */
+ let antMode
+
+ /**
+ * @type {ReturnType | undefined}
+ */
+ let mqttPeripheral
+ if (mqttEnabled) {
+ mqttPeripheral = createMQTTPeripheral(config)
+
+ mqttPeripheral.on('control', (req) => {
+ emitter.emit('control', req)
+ })
+ }
+
+ /**
+ * @type {ReturnType | ReturnType | undefined}
+ */
+ let hrmPeripheral
+ /**
+ * @type {HeartRateModes}
+ */
+ let hrmMode
+ /**
+ * @type {NodeJS.Timeout}
+ */
+ let hrmWatchdogTimer
+ /**
+ * @type {Omit & {heartRateBatteryLevel?: number }}
+ */
+ let lastHrmData = {
+ heartrate: undefined,
+ heartRateBatteryLevel: undefined,
+ rrIntervals: []
+ }
+
+ let isPeripheralChangeInProgress = false
+
+ setupPeripherals()
+
+ async function setupPeripherals () {
+ // The order is important, starting with the BLEs causes EBUSY error on the HCI socket on switching. I was not able to find the cause - its probably the order within the async initialization of the BleManager, but cannot find a proper fix
+ await createAntPeripheral(config.antPlusMode)
+ await createHrmPeripheral(config.heartRateMode)
+ await createBlePeripheral(config.bluetoothMode)
+ }
+
+ /**
+ * This function handles all incomming commands. As all commands are broadasted to all managers, we need to filter here what is relevant
+ * for the peripherals and what is not
+ *
+ * @param {Command} Name of the command to be executed by the commandhandler
+ * @param {unknown} data for executing the command
+ *
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#command-flow|The command flow documentation}
+ */
+ /* eslint-disable-next-line no-unused-vars -- data is irrelevant here, but it is a standardised interface */
+ async function handleCommand (commandName, data) {
+ switch (commandName) {
+ case ('updateIntervalSettings'):
+ break
+ case ('start'):
+ break
+ case ('startOrResume'):
+ notifyStatus({ name: 'startedOrResumedByUser' })
+ break
+ case ('pause'):
+ notifyStatus({ name: 'stoppedOrPausedByUser' })
+ break
+ case ('stop'):
+ notifyStatus({ name: 'stoppedOrPausedByUser' })
+ break
+ case ('reset'):
+ notifyStatus({ name: 'reset' })
+ break
+ case 'switchBlePeripheralMode':
+ switchBlePeripheralMode()
+ break
+ case 'switchAntPeripheralMode':
+ switchAntPeripheralMode()
+ break
+ case 'switchHrmMode':
+ switchHrmMode()
+ break
+ case 'refreshPeripheralConfig':
+ break
+ case 'upload':
+ break
+ case 'shutdown':
+ await shutdownAllPeripherals()
+ break
+ default:
+ log.error(`PeripheralManager: Received unknown command: ${commandName}`)
+ }
+ }
+
+ /**
+ * @param {BluetoothModes} [newMode]
+ */
+ async function switchBlePeripheralMode (newMode) {
+ if (isPeripheralChangeInProgress) { return }
+ isPeripheralChangeInProgress = true
+ // if no mode was passed, select the next one from the list
+ if (newMode === undefined) {
+ newMode = bleModes[(bleModes.indexOf(bleMode) + 1) % bleModes.length]
+ }
+ config.bluetoothMode = newMode
+ await createBlePeripheral(newMode)
+ isPeripheralChangeInProgress = false
+ }
+
+ /**
+ * @param {Metrics} metrics
+ */
+ function notifyMetrics (metrics) {
+ addHeartRateToMetrics(metrics)
+ if (bleMode !== 'OFF') { blePeripheral?.notifyData(metrics) }
+ if (antMode !== 'OFF') { antPeripheral?.notifyData(metrics) }
+ if (mqttEnabled) { mqttPeripheral?.notifyData(metrics) }
+ }
+
+ /**
+ * @param {{name: string}} status
+ */
+ function notifyStatus (status) {
+ if (bleMode !== 'OFF') { blePeripheral?.notifyStatus(status) }
+ if (antMode !== 'OFF') { antPeripheral?.notifyStatus(status) }
+ }
+
+ /**
+ * @param {BluetoothModes} newMode
+ */
+ async function createBlePeripheral (newMode) {
+ try {
+ if (_bleManager === undefined && newMode !== 'OFF') {
+ _bleManager = new BleManager()
+ }
+ } catch (error) {
+ log.error('BleManager creation error: ', error)
+ return
+ }
+
+ if (blePeripheral) {
+ await blePeripheral?.destroy()
+ blePeripheral = undefined
+ }
+
+ switch (newMode) {
+ case 'PM5':
+ log.info('bluetooth profile: Concept2 PM5')
+ blePeripheral = createPm5Peripheral(_bleManager, config, controlCallback)
+ bleMode = 'PM5'
+ break
+ case 'FTMSBIKE':
+ log.info('bluetooth profile: FTMS Indoor Bike')
+ blePeripheral = createFtmsPeripheral(_bleManager, controlCallback, config, true)
+ bleMode = 'FTMSBIKE'
+ break
+ case 'CSC':
+ log.info('bluetooth profile: Cycling Speed and Cadence')
+ blePeripheral = createCscPeripheral(_bleManager, config)
+ bleMode = 'CSC'
+ break
+ case 'CPS':
+ log.info('bluetooth profile: Cycling Power Meter')
+ blePeripheral = createCpsPeripheral(_bleManager, config)
+ bleMode = 'CPS'
+ break
+ case 'FTMS':
+ log.info('bluetooth profile: FTMS Rower')
+ blePeripheral = createFtmsPeripheral(_bleManager, controlCallback, config, false)
+ bleMode = 'FTMS'
+ break
+ default:
+ log.info('bluetooth profile: Off')
+ bleMode = 'OFF'
+ try {
+ if (_bleManager && hrmMode !== 'BLE') {
+ _bleManager.close()
+ }
+ } catch (error) {
+ log.error(error)
+ return
+ }
+ }
+
+ emitter.emit('control', {
+ req: {
+ name: 'refreshPeripheralConfig',
+ data: {}
+ }
+ })
+ }
+
+ /**
+ * @param {AntPlusModes} [newMode]
+ */
+ async function switchAntPeripheralMode (newMode) {
+ if (isPeripheralChangeInProgress) { return }
+ isPeripheralChangeInProgress = true
+ if (newMode === undefined) {
+ newMode = antModes[(antModes.indexOf(antMode) + 1) % antModes.length]
+ }
+ config.antPlusMode = newMode
+ await createAntPeripheral(newMode)
+ isPeripheralChangeInProgress = false
+ }
+
+ /**
+ * @param {AntPlusModes} newMode
+ */
+ async function createAntPeripheral (newMode) {
+ if (antPeripheral) {
+ await antPeripheral?.destroy()
+ antPeripheral = undefined
+ }
+
+ switch (newMode) {
+ case 'FE':
+ log.info('ant plus profile: FE')
+ if (_antManager === undefined) {
+ _antManager = new AntManager()
+ }
+
+ try {
+ antPeripheral = createFEPeripheral(_antManager)
+ antMode = 'FE'
+ await antPeripheral.attach()
+ } catch (error) {
+ log.error(error)
+ return
+ }
+ break
+
+ default:
+ log.info('ant plus profile: Off')
+ antMode = 'OFF'
+ try {
+ if (_antManager && hrmMode !== 'ANT') { await _antManager.closeAntStick() }
+ } catch (error) {
+ log.error(error)
+ return
+ }
+ }
+
+ emitter.emit('control', {
+ req: {
+ name: 'refreshPeripheralConfig',
+ data: {}
+ }
+ })
+ }
+
+ /**
+ * @param {HeartRateModes} [newMode]
+ */
+ async function switchHrmMode (newMode) {
+ if (isPeripheralChangeInProgress) { return }
+ isPeripheralChangeInProgress = true
+ if (newMode === undefined) {
+ newMode = hrmModes[(hrmModes.indexOf(hrmMode) + 1) % hrmModes.length]
+ }
+ config.heartRateMode = newMode
+ await createHrmPeripheral(newMode)
+ isPeripheralChangeInProgress = false
+ }
+
+ /**
+ * @param {HeartRateModes} newMode
+ */
+ async function createHrmPeripheral (newMode) {
+ if (hrmPeripheral) {
+ await hrmPeripheral?.destroy()
+ hrmPeripheral?.removeAllListeners()
+ hrmPeripheral = undefined
+ try {
+ if (_antManager && newMode !== 'ANT' && antMode === 'OFF') { await _antManager.closeAntStick() }
+ if (_bleManager && newMode !== 'BLE' && bleMode === 'OFF') { _bleManager.close() }
+ } catch (error) {
+ log.error(error)
+ return
+ }
+ }
+
+ switch (newMode) {
+ case 'ANT':
+ log.info('heart rate profile: ANT')
+ if (_antManager === undefined) {
+ _antManager = new AntManager()
+ }
+
+ try {
+ hrmPeripheral = createAntHrmPeripheral(_antManager)
+ hrmMode = 'ANT'
+ await hrmPeripheral.attach()
+ } catch (error) {
+ log.error(error)
+ return
+ }
+ break
+
+ case 'BLE':
+ log.info('heart rate profile: BLE')
+ try {
+ if (_bleManager === undefined) {
+ _bleManager = new BleManager()
+ }
+ } catch (error) {
+ log.error('BleManager creation error: ', error)
+ return
+ }
+ hrmPeripheral = createBleHrmPeripheral(_bleManager)
+ hrmMode = 'BLE'
+ await hrmPeripheral.attach()
+ break
+
+ default:
+ log.info('heart rate profile: Off')
+ hrmMode = 'OFF'
+ }
+
+ if (hrmPeripheral && hrmMode.toLocaleLowerCase() !== 'OFF'.toLocaleLowerCase()) {
+ hrmPeripheral.on('heartRateMeasurement', (heartRateMeasurement) => {
+ // Clear the HRM watchdog as new HRM data has been received
+ clearTimeout(hrmWatchdogTimer)
+ // Make sure we check the HRM validity here, so the rest of the app doesn't have to
+ if (heartRateMeasurement.heartrate !== undefined && config.userSettings.restingHR <= heartRateMeasurement.heartrate && heartRateMeasurement.heartrate <= config.userSettings.maxHR) {
+ lastHrmData = { ...heartRateMeasurement, heartRateBatteryLevel: heartRateMeasurement.batteryLevel }
+ emitter.emit('heartRateMeasurement', heartRateMeasurement)
+ } else {
+ log.info(`PeripheralManager: Heartrate value of ${heartRateMeasurement.heartrate} was outside valid range, setting it to undefined`)
+ heartRateMeasurement.heartrate = undefined
+ heartRateMeasurement.batteryLevel = undefined
+ emitter.emit('heartRateMeasurement', heartRateMeasurement)
+ }
+ // Re-arm the HRM watchdog to guarantee failsafe behaviour: after 6 seconds of no new HRM data, it will be invalidated
+ hrmWatchdogTimer = setTimeout(onHRMWatchdogTimeout, 6000)
+ })
+ }
+
+ emitter.emit('control', {
+ req: {
+ name: 'refreshPeripheralConfig',
+ data: {}
+ }
+ })
+ }
+
+ function onHRMWatchdogTimeout () {
+ lastHrmData.heartrate = undefined
+ lastHrmData.heartRateBatteryLevel = undefined
+ log.info('PeripheralManager: Heartrate data has not been updated in 6 seconds, setting it to undefined')
+ emitter.emit('heartRateMeasurement', lastHrmData)
+ }
+
+ /**
+ * @param {Metrics} metrics
+ */
+ function addHeartRateToMetrics (metrics) {
+ if (lastHrmData.heartrate !== undefined) {
+ metrics.heartrate = lastHrmData.heartrate
+ } else {
+ metrics.heartrate = undefined
+ }
+ // So far battery level is not used by any of the peripherals adding it for completeness sake
+ if (lastHrmData.heartRateBatteryLevel !== undefined) {
+ metrics.heartRateBatteryLevel = lastHrmData.heartRateBatteryLevel
+ } else {
+ metrics.heartRateBatteryLevel = undefined
+ }
+ }
+
+ /**
+ * @param {ControlPointEvent} event
+ */
+ function controlCallback (event) {
+ emitter.emit('control', event)
+
+ return true
+ }
+
+ async function shutdownAllPeripherals () {
+ log.debug('shutting down all peripherals')
+
+ try {
+ await blePeripheral?.destroy()
+ await antPeripheral?.destroy()
+ await hrmPeripheral?.destroy()
+ await _antManager?.closeAntStick()
+ _bleManager?.close()
+ if (mqttEnabled) { await mqttPeripheral?.destroy() }
+ } catch (error) {
+ log.error('peripheral shutdown was unsuccessful, restart of Pi may required', error)
+ }
+ }
+
+ return Object.assign(emitter, {
+ handleCommand,
+ notifyMetrics,
+ notifyStatus
+ })
+}
diff --git a/app/peripherals/ant/AntManager.js b/app/peripherals/ant/AntManager.js
new file mode 100644
index 0000000000..9bc479e00c
--- /dev/null
+++ b/app/peripherals/ant/AntManager.js
@@ -0,0 +1,45 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This manager creates a module to listen to ANT+ devices.
+ * This currently can be used to get the heart rate from ANT+ heart rate sensors.
+ *
+ * For this to work, you need an ANT+ USB stick, the following models might work:
+ * - Garmin USB or USB2 ANT+ or an off-brand clone of it (ID 0x1008)
+ * - Garmin mini ANT+ (ID 0x1009)
+ */
+import log from 'loglevel'
+
+import { AntDevice } from 'incyclist-ant-plus/lib/ant-device.js'
+
+export default class AntManager {
+ _isStickOpen = false
+ _stick = new AntDevice({ startupTimeout: 2000 })
+
+ async openAntStick () {
+ if (this._isStickOpen) { return }
+ if (!(await this._stick.open())) { throw (new Error('Error opening Ant Stick')) }
+
+ log.info('ANT+ stick found')
+ this._isStickOpen = true
+ }
+
+ async closeAntStick () {
+ if (!this._isStickOpen) { return }
+
+ if (!(await this._stick.close())) { throw (new Error('Error closing Ant Stick')) }
+
+ log.info('ANT+ stick is closed')
+ this._isStickOpen = false
+ }
+
+ isStickOpen () {
+ return this._isStickOpen
+ }
+
+ getAntStick () {
+ return this._stick
+ }
+}
diff --git a/app/peripherals/ant/FEPeripheral.js b/app/peripherals/ant/FEPeripheral.js
new file mode 100644
index 0000000000..24975b61fb
--- /dev/null
+++ b/app/peripherals/ant/FEPeripheral.js
@@ -0,0 +1,290 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * Creates a ANT+ Peripheral with all the datapages that are required for an indoor rower
+ */
+import log from 'loglevel'
+
+import { PeripheralConstants } from '../PeripheralConstants.js'
+
+import { Messages } from 'incyclist-ant-plus'
+
+/**
+ * @param {import('./AntManager').default} antManager
+ */
+function createFEPeripheral (antManager) {
+ const antStick = antManager.getAntStick()
+ const deviceType = 0x11 // Ant FE-C device
+ const deviceNumber = 1
+ const deviceId = parseInt(PeripheralConstants.serial, 10) & 0xFFFF
+ const channel = 1
+ const broadcastPeriod = 8192 // 8192/32768 ~4hz
+ const broadcastInterval = broadcastPeriod / 32768 * 1000 // millisecond
+ const rfChannel = 57 // 2457 MHz
+ let dataPageCount = 0
+ let commonPageCount = 0
+ let accumulatedTime = 0
+ let accumulatedDistance = 0
+ let accumulatedStrokes = 0
+ /**
+ * @type {NodeJS.Timeout}
+ */
+ let timer
+
+ let sessionData = {
+ accumulatedStrokes: 0,
+ accumulatedDistance: 0,
+ accumulatedTime: 0,
+ accumulatedPower: 0,
+ cycleLinearVelocity: 0,
+ strokeRate: 0,
+ instantaneousPower: 0,
+ distancePerStroke: 0,
+ fitnessEquipmentState: fitnessEquipmentStates.ready,
+ sessionState: 'WaitingForStart'
+ }
+
+ async function attach () {
+ if (!antManager.isStickOpen()) { await antManager.openAntStick() }
+
+ const messages = [
+ Messages.assignChannel(channel, 'transmit'),
+ Messages.setDevice(channel, deviceId, deviceType, deviceNumber),
+ Messages.setFrequency(channel, rfChannel),
+ Messages.setPeriod(channel, broadcastPeriod),
+ Messages.openChannel(channel)
+ ]
+
+ log.info(`ANT+ FE server start [deviceId=${deviceId} channel=${channel}]`)
+ for (const message of messages) {
+ antStick.write(message)
+ }
+
+ timer = setTimeout(onBroadcastInterval, broadcastInterval)
+ }
+
+ function destroy () {
+ return new Promise((/** @type {(value: void) => void} */resolve) => {
+ clearInterval(timer)
+ log.info(`ANT+ FE server stopped [deviceId=${deviceId} channel=${channel}]`)
+
+ const messages = [
+ Messages.closeChannel(channel),
+ Messages.unassignChannel(channel)
+ ]
+ for (const message of messages) {
+ antStick.write(message)
+ }
+ resolve()
+ })
+ }
+
+ function onBroadcastInterval () {
+ dataPageCount++
+ let /** @type {Array} */data = []
+
+ switch (true) {
+ case dataPageCount === 65 || dataPageCount === 66:
+ if (commonPageCount % 2 === 0) { // 0x50 - Common Page for Manufacturers Identification (approx twice a minute)
+ data = [
+ channel,
+ 0x50, // Page 80
+ 0xFF, // Reserved
+ 0xFF, // Reserved
+ parseInt(PeripheralConstants.hardwareRevision, 10) & 0xFF, // Hardware Revision
+ ...Messages.intToLEHexArray(40, 2), // Manufacturer ID (value 255 = Development ID, value 40 = concept2)
+ 0x0001 // Model Number
+ ]
+ }
+ if (commonPageCount % 2 === 1) { // 0x51 - Common Page for Product Information (approx twice a minute)
+ data = [
+ channel,
+ 0x51, // Page 81
+ 0xFF, // Reserved
+ parseInt(PeripheralConstants.firmwareRevision.slice(-2), 10), // SW Revision (Supplemental)
+ parseInt(PeripheralConstants.firmwareRevision[0], 10), // SW Version
+ ...Messages.intToLEHexArray(parseInt(PeripheralConstants.serial, 10), 4) // Serial Number (None)
+ ]
+ }
+
+ if (dataPageCount === 66) {
+ commonPageCount++
+ dataPageCount = 0
+ }
+ break
+ case dataPageCount % 8 === 4: // 0x11 - General Settings Page (once a second)
+ case dataPageCount % 8 === 7:
+ data = [
+ channel,
+ 0x11, // Page 17
+ 0xFF, // Reserved
+ 0xFF, // Reserved
+ ...Messages.intToLEHexArray(sessionData.distancePerStroke, 1), // Stroke Length in 0.01 m
+ 0x7FFF, // Incline (Not Used)
+ 0x00, // Resistance (DF may be reported if conversion to the % is worked out (value in % with a resolution of 0.5%).
+ ...Messages.intToLEHexArray(feCapabilitiesBitField, 1)
+ ]
+ if (sessionData.sessionState === 'Rowing') {
+ log.trace(`Page 17 Data Sent. Event=${dataPageCount}. Stroke Length=${sessionData.distancePerStroke}.`)
+ log.trace(`Hex Stroke Length=0x${sessionData.distancePerStroke.toString(16)}.`)
+ }
+ break
+ case dataPageCount % 8 === 3: // 0x16 - Specific Rower Data (once a second)
+ case dataPageCount % 8 === 0:
+ data = [
+ channel,
+ 0x16, // Page 22
+ 0xFF, // Reserved
+ 0xFF, // Reserved
+ ...Messages.intToLEHexArray(sessionData.accumulatedStrokes, 1), // Stroke Count
+ ...Messages.intToLEHexArray(sessionData.strokeRate, 1), // Cadence / Stroke Rate
+ ...Messages.intToLEHexArray(sessionData.instantaneousPower, 2), // Instant Power (2 bytes)
+ ...Messages.intToLEHexArray((sessionData.fitnessEquipmentState + rowingCapabilitiesBitField), 1)
+ ]
+ if (sessionData.sessionState === 'Rowing') {
+ log.trace(`Page 22 Data Sent. Event=${dataPageCount}. Strokes=${sessionData.accumulatedStrokes}. Stroke Rate=${sessionData.strokeRate}. Power=${sessionData.instantaneousPower}`)
+ log.trace(`Hex Strokes=0x${sessionData.accumulatedStrokes.toString(16)}. Hex Stroke Rate=0x${sessionData.strokeRate.toString(16)}. Hex Power=0x${Messages.intToLEHexArray(sessionData.instantaneousPower, 2)}.`)
+ }
+ break
+ case dataPageCount % 4 === 2: // 0x10 - General FE Data (twice a second)
+ default:
+ data = [
+ channel,
+ 0x10, // Page 16
+ 0x16, // Rowing Machine (22)
+ ...Messages.intToLEHexArray(sessionData.accumulatedTime, 1), // elapsed time
+ ...Messages.intToLEHexArray(sessionData.accumulatedDistance, 1), // distance travelled
+ ...Messages.intToLEHexArray(sessionData.cycleLinearVelocity, 2), // speed in 0.001 m/s
+ 0xFF, // heart rate not being sent
+ ...Messages.intToLEHexArray((sessionData.fitnessEquipmentState + feCapabilitiesBitField), 1)
+ ]
+ if (sessionData.sessionState === 'Rowing') {
+ log.trace(`Page 16 Data Sent. Event=${dataPageCount}. Time=${sessionData.accumulatedTime}. Distance=${sessionData.accumulatedDistance}. Speed=${sessionData.cycleLinearVelocity}.`)
+ log.trace(`Hex Time=0x${sessionData.accumulatedTime.toString(16)}. Hex Distance=0x${sessionData.accumulatedDistance.toString(16)}. Hex Speed=0x${Messages.intToLEHexArray(sessionData.cycleLinearVelocity, 2)}.`)
+ }
+ break
+ }
+
+ const message = Messages.broadcastData(data)
+ antStick.write(message)
+ timer = setTimeout(onBroadcastInterval, broadcastInterval)
+ }
+
+ /**
+ * @remark Be aware: time, distance and strokes must always count upwards as small changes trigger a rollover at the watch side. So we must force this
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/100|this bugreport}
+ * @param {Metrics} data
+ */
+ function notifyData (data) {
+ accumulatedTime = Math.max(data.workout.timeSpent, sessionData.accumulatedTime)
+ accumulatedDistance = Math.max(data.workout.distance.fromStart, accumulatedDistance)
+ accumulatedStrokes = Math.max(data.workout.numberOfStrokes, accumulatedStrokes)
+ sessionData = {
+ ...sessionData,
+ accumulatedTime: (accumulatedTime > 0 ? Math.round(accumulatedTime * 4) : 0) & 0xFF,
+ accumulatedDistance: (accumulatedDistance > 0 ? Math.round(accumulatedDistance) : 0) & 0xFF,
+ accumulatedStrokes: (accumulatedStrokes > 0 ? Math.round(accumulatedStrokes) : 0) & 0xFF,
+ cycleLinearVelocity: (data.metricsContext.isMoving && data.cycleLinearVelocity > 0 ? Math.round(data.cycleLinearVelocity * 1000) : 0),
+ strokeRate: (data.metricsContext.isMoving && data.cycleStrokeRate > 0 ? Math.round(data.cycleStrokeRate) : 0) & 0xFF,
+ instantaneousPower: (data.metricsContext.isMoving && data.cyclePower > 0 ? Math.round(data.cyclePower) : 0) & 0xFFFF,
+ distancePerStroke: (data.metricsContext.isMoving && data.cycleDistance > 0 ? Math.round(data.cycleDistance * 100) : 0),
+ sessionState: data.sessionState
+ }
+
+ /**
+ * @See {@link https://c2usa.fogbugz.com/default.asp?W119| states description}
+ * - when machine is on and radio active, but have not yet begun a session -> status set to "ready", speed, etc. are all 0 (as forced by above requirement for data.metricsContext.isMoving)
+ * - first stroke -> status = 3 (in use)
+ * - end of wokrout -> status = 4 (finished)
+ * - Pause: go to 4 (finished, if data.metricsContext.isMoving = false); back to inUse if rowing starts coming back.
+ * every time move from "ready" to "inUse" it will create a new piece on the watch.
+ */
+ // ToDo: if cross split; raise LAP Toggle
+ switch (true) {
+ case (data.sessionState === 'Rowing'):
+ sessionData.fitnessEquipmentState = fitnessEquipmentStates.inUse
+ break
+ case (data.sessionState === 'Stopped'):
+ sessionData.fitnessEquipmentState = fitnessEquipmentStates.finished
+ break
+ case (data.sessionState === 'Paused'):
+ sessionData.fitnessEquipmentState = fitnessEquipmentStates.finished
+ break
+ case (data.sessionState === 'WaitingForStart'):
+ sessionData.fitnessEquipmentState = fitnessEquipmentStates.ready
+ break
+ default:
+ sessionData.fitnessEquipmentState = fitnessEquipmentStates.ready
+ }
+ }
+
+ /**
+ * FE does not have status characteristic, but is notified of a reset, which should be handled
+ * @param {{name: string}} status
+ */
+ function notifyStatus (status) {
+ switch (status?.name) {
+ case ('reset'):
+ reset()
+ break
+ default:
+ // Do nothing
+ }
+ }
+
+ function reset () {
+ dataPageCount = 0
+ commonPageCount = 0
+ accumulatedTime = 0
+ accumulatedDistance = 0
+ accumulatedStrokes = 0
+ sessionData = {
+ accumulatedStrokes: 0,
+ accumulatedDistance: 0,
+ accumulatedTime: 0,
+ accumulatedPower: 0,
+ cycleLinearVelocity: 0,
+ strokeRate: 0,
+ instantaneousPower: 0,
+ distancePerStroke: 0,
+ fitnessEquipmentState: fitnessEquipmentStates.ready,
+ sessionState: 'WaitingForStart'
+ }
+ }
+
+ return {
+ notifyData,
+ notifyStatus,
+ attach,
+ destroy
+ }
+}
+
+const fitnessEquipmentStates = {
+ asleep: (1 << 0x04),
+ ready: (2 << 0x04),
+ inUse: (3 << 0x04),
+ finished: (4 << 0x04),
+ lapToggleBit: (8 << 0x04)
+}
+
+const fitnessEquipmentCapabilities = {
+ hrDataSourceHandContactSensors: (0x03 << 0),
+ hrDataSourceEmSensors: (0x02 << 0),
+ hrDataSourceAntSensors: (0x01 << 0),
+ hrDataSourceInvalid: (0x00 << 0),
+ distanceTraveledEnabled: (0x01 << 2),
+ virtualSpeed: (0x01 << 3),
+ realSpeed: (0x00 << 3)
+}
+
+const rowingMachineCapabilities = {
+ accumulatedStrokesEnabled: (0x01 << 0)
+}
+
+const feCapabilitiesBitField = fitnessEquipmentCapabilities.hrDataSourceInvalid | fitnessEquipmentCapabilities.distanceTraveledEnabled | fitnessEquipmentCapabilities.realSpeed
+const rowingCapabilitiesBitField = rowingMachineCapabilities.accumulatedStrokesEnabled
+
+export { createFEPeripheral }
diff --git a/app/peripherals/ant/HrmPeripheral.js b/app/peripherals/ant/HrmPeripheral.js
new file mode 100644
index 0000000000..43e21a1a65
--- /dev/null
+++ b/app/peripherals/ant/HrmPeripheral.js
@@ -0,0 +1,121 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Creates a ANT+ peripheral to recieve heartrate data from a HRM belt
+*/
+import EventEmitter from 'node:events'
+import log from 'loglevel'
+
+import { HeartRateSensor } from 'incyclist-ant-plus'
+
+/**
+ * @event createAntHrmPeripheral#heartRateMeasurement
+ * @type {HeartRateMeasurementEvent}
+ */
+/**
+ * @typedef {import('incyclist-ant-plus').IChannel} IChannel
+ */
+
+/**
+ * @param {import('./AntManager.js').default} antManager
+ * @fires createAntHrmPeripheral#heartRateMeasurement
+ */
+function createAntHrmPeripheral (antManager) {
+ /**
+ * @type {EventEmitter<{heartRateMeasurement: Array}>}
+ */
+ const emitter = new EventEmitter()
+ const antStick = antManager.getAntStick()
+ const heartRateSensor = new HeartRateSensor(0)
+ let lastBeatCount = 0
+ let lastBeatTime = 0
+
+ /**
+ * The RR interval in seconds
+ * @type {Array}
+ */
+ let rrIntervals = []
+ /**
+ * @type {number | undefined}
+ */
+ let batteryLevel
+ /** @type {IChannel & EventEmitter | undefined} */
+ let channel
+
+ async function attach () {
+ if (!antManager.isStickOpen()) { await antManager.openAntStick() }
+ channel = /** @type {IChannel & EventEmitter} */(antStick.getChannel())
+
+ channel.on('data', (profile, deviceID, /** @type {import('incyclist-ant-plus').HeartRateSensorState} */data) => {
+ switch (data.BatteryStatus) {
+ case 'New':
+ batteryLevel = 100
+ break
+ case 'Good':
+ batteryLevel = 80
+ break
+ case 'Ok':
+ batteryLevel = 60
+ break
+ case 'Low':
+ batteryLevel = 40
+ break
+ case 'Critical':
+ batteryLevel = 20
+ break
+ default:
+ batteryLevel = undefined
+ }
+
+ if (data.BatteryLevel && data.BatteryLevel > 0) {
+ batteryLevel = data.BatteryLevel
+ }
+
+ if (data.BeatCount !== lastBeatCount) {
+ /**
+ * @type {number | undefined}
+ */
+ let beatTimeDiff
+ if (data.PreviousBeat !== undefined) {
+ // Logic using previousBeatTime and also saving last beat time is seemingly redundant, but the specs prescribes that firstly the previousBeatTime should be used and only if that is not available should be the difference between two successive message be used when the beat count difference is one.
+ beatTimeDiff = data.PreviousBeat > data.BeatTime ? 65535 - (data.PreviousBeat - data.BeatTime) : data.BeatTime - data.PreviousBeat
+ } else if (data.BeatCount - lastBeatCount === 1) {
+ beatTimeDiff = lastBeatTime > data.BeatTime ? 65535 - (lastBeatTime - data.BeatTime) : data.BeatTime - lastBeatTime
+ }
+
+ rrIntervals = beatTimeDiff !== undefined ? [Math.round(beatTimeDiff / 1024 * 1000) / 1000] : []
+
+ lastBeatCount = data.BeatCount
+ lastBeatTime = data.BeatTime
+ }
+
+ emitter.emit('heartRateMeasurement', {
+ heartrate: data.ComputedHeartRate,
+ rrIntervals,
+ batteryLevel,
+ manufacturerId: data.ManId,
+ serialNumber: data.SerialNumber
+ })
+ })
+
+ if (!(await channel.startSensor(heartRateSensor))) {
+ log.error('Could not start ANT+ heart rate sensor')
+ }
+ }
+
+ async function destroy () {
+ if (!channel) {
+ log.debug('Ant Sensor does not seem to be running')
+ return
+ }
+ await channel.stopSensor(heartRateSensor)
+ }
+
+ return Object.assign(emitter, {
+ destroy,
+ attach
+ })
+}
+
+export { createAntHrmPeripheral }
diff --git a/app/peripherals/ble/BleManager.js b/app/peripherals/ble/BleManager.js
new file mode 100644
index 0000000000..9d255c5152
--- /dev/null
+++ b/app/peripherals/ble/BleManager.js
@@ -0,0 +1,148 @@
+import loglevel from 'loglevel'
+
+import HciSocket from 'hci-socket'
+import NodeBleHost from 'ble-host'
+
+/**
+ * @typedef {import('./ble-host.interface.js').BleManager} BleHostManager
+ */
+
+const log = loglevel.getLogger('Peripherals')
+
+export class BleManager {
+ /**
+ * @type {HciSocket | undefined}
+ */
+ #transport
+ /**
+ * @type {BleHostManager | undefined}
+ */
+ #manager
+ /**
+ * @type {Promise | undefined}
+ */
+ #managerOpeningTask
+
+ open () {
+ if (this.#manager !== undefined) {
+ return Promise.resolve(this.#manager)
+ }
+
+ if (this.#managerOpeningTask === undefined) {
+ this.#managerOpeningTask = new Promise((resolve, reject) => {
+ if (this.#manager) {
+ resolve(this.#manager)
+ }
+ log.debug('Opening BLE manager')
+
+ if (this.#transport === undefined) {
+ this.#transport = new HciSocket()
+ }
+
+ NodeBleHost.BleManager.create(this.#transport, {}, (/** @type {Error | null} */err, /** @type {BleHostManager} */manager) => {
+ if (err) { reject(err) }
+ this.#manager = manager
+ this.#managerOpeningTask = undefined
+ resolve(manager)
+ })
+ })
+ }
+
+ return this.#managerOpeningTask
+ }
+
+ close () {
+ try {
+ this.#transport?.close()
+ } catch (e) {
+ if (e.message !== 'Transport closed') {
+ log.error('Error while closing Ble socket')
+
+ throw e
+ }
+
+ log.debug('Ble socket is closed')
+ this.#transport = undefined
+ this.#manager = undefined
+ }
+ }
+
+ isOpen () {
+ return this.#manager !== undefined
+ }
+
+ getManager () {
+ return this.open()
+ }
+}
+
+/**
+ * Convert a 16-bit C2 PM5 UUID to a BLE standard 128-bit UUID.
+ * @param {string} uuid
+ * @returns
+ */
+export const toBLEStandard128BitUUID = (uuid) => {
+ return `0000${uuid}-0000-1000-8000-00805F9B34FB`
+}
+
+export class GattNotifyCharacteristic {
+ get characteristic () {
+ return this.#characteristic
+ }
+
+ get isSubscribed () {
+ return this.#isSubscribed
+ }
+
+ #characteristic
+ #isSubscribed = false
+
+ /**
+ * @type {import('./ble-host.interface.js').Connection | undefined}
+ */
+ #connection
+
+ /**
+ * @param {GattServerCharacteristicFactory} characteristic
+ */
+ constructor (characteristic) {
+ this.#characteristic = {
+ ...characteristic,
+ onSubscriptionChange: (/** @type {import('./ble-host.interface.js').Connection} */connection, /** @type {boolean} */ notification) => {
+ log.debug(`${this.#characteristic.name} subscription change: ${connection.peerAddress}, notification: ${notification}`)
+ this.#isSubscribed = notification
+ this.#connection = notification ? connection : undefined
+ }
+ }
+ }
+
+ /**
+ * @param {Buffer | string} buffer
+ */
+ notify (buffer) {
+ if (this.#characteristic.notify === undefined) {
+ throw new Error(`Characteristics ${this.#characteristic.name} has not been initialized`)
+ }
+
+ if (!this.#isSubscribed || this.#connection === undefined) {
+ return
+ }
+
+ this.#characteristic.notify(this.#connection, buffer)
+ }
+}
+
+export class GattService {
+ get gattService () {
+ return this.#gattService
+ }
+
+ #gattService
+
+ /**
+ * @param {GattServerServiceFactory} gattService
+ */
+ constructor (gattService) {
+ this.#gattService = gattService
+ }
+}
diff --git a/app/ble/BufferBuilder.js b/app/peripherals/ble/BufferBuilder.js
similarity index 83%
rename from app/ble/BufferBuilder.js
rename to app/peripherals/ble/BufferBuilder.js
index 5aebc7f32c..bba12177bb 100644
--- a/app/ble/BufferBuilder.js
+++ b/app/peripherals/ble/BufferBuilder.js
@@ -1,16 +1,22 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
A buffer builder that simplifies the creation of payloads for BLE messages
*/
import log from 'loglevel'
-export default class BufferBuilder {
+export class BufferBuilder {
constructor () {
+ /**
+ * @type {Array}
+ */
this._dataArray = []
}
+ /**
+ * @param {number} value
+ */
writeUInt8 (value) {
const buffer = Buffer.alloc(1)
try {
@@ -21,6 +27,9 @@ export default class BufferBuilder {
this._dataArray.push(buffer)
}
+ /**
+ * @param {number} value
+ */
writeUInt16LE (value) {
const buffer = Buffer.alloc(2)
try {
@@ -31,6 +40,9 @@ export default class BufferBuilder {
this._dataArray.push(buffer)
}
+ /**
+ * @param {number} value
+ */
writeUInt24LE (value) {
const _value = value || 0
const buffer = Buffer.alloc(3)
@@ -47,6 +59,9 @@ export default class BufferBuilder {
this._dataArray.push(buffer)
}
+ /**
+ * @param {number} value
+ */
writeUInt32LE (value) {
const _value = value || 0
const buffer = Buffer.alloc(4)
diff --git a/app/ble/BufferBuilder.test.js b/app/peripherals/ble/BufferBuilder.test.js
similarity index 92%
rename from app/ble/BufferBuilder.test.js
rename to app/peripherals/ble/BufferBuilder.test.js
index 72d7f133c5..1f5d0a3eaa 100644
--- a/app/ble/BufferBuilder.test.js
+++ b/app/peripherals/ble/BufferBuilder.test.js
@@ -1,11 +1,13 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
-import { test } from 'uvu'
import * as assert from 'uvu/assert'
-import BufferBuilder from './BufferBuilder.js'
import log from 'loglevel'
+import { test } from 'uvu'
+
+import { BufferBuilder } from './BufferBuilder.js'
+
log.setLevel(log.levels.SILENT)
test('valid max UInts should produce correct buffer', () => {
@@ -52,6 +54,7 @@ test('negative writeUInt32LE should produce 4 bit buffer of 0x0', () => {
test('invalid datatype value UInt16LE should produce 2 bit buffer of 0x0', () => {
const buffer = new BufferBuilder()
+ // @ts-ignore
buffer.writeUInt16LE(new Map())
assert.equal(buffer.getBuffer(), Buffer.from([0x0, 0x0]))
})
diff --git a/app/peripherals/ble/CpsPeripheral.js b/app/peripherals/ble/CpsPeripheral.js
new file mode 100644
index 0000000000..e5b1904b7c
--- /dev/null
+++ b/app/peripherals/ble/CpsPeripheral.js
@@ -0,0 +1,166 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are required for
+ * a Cycling Power Profile
+ */
+import NodeBleHost from 'ble-host'
+import loglevel from 'loglevel'
+
+import { bleBroadcastInterval, bleMinimumKnowDataUpdateInterval } from '../PeripheralConstants.js'
+
+import { CyclingPowerService } from './cps/CyclingPowerMeterService.js'
+import { DeviceInformationService } from './common/DeviceInformationService.js'
+
+/**
+ * @typedef {import('./ble-host.interface.js').Connection} Connection
+ * @typedef {import('./ble-host.interface.js').BleManager} BleManager
+ */
+
+const log = loglevel.getLogger('Peripherals')
+
+/**
+ *
+ * @param {import('./BleManager.js').BleManager} bleManager
+ * @param {Config} config
+ * @returns
+ */
+export function createCpsPeripheral (bleManager, config) {
+ const cyclingPowerService = new CyclingPowerService((event) => {
+ log.debug('CPS Control Point', event)
+ return false
+ })
+ /**
+ * @type {Metrics}
+ */
+ let lastKnownMetrics = {
+ // This reference is to satisfy type checking while simplifying the initialization of lastKnownMetrics (i.e. allow partial initialization but have the type system consider it as a full Metrics type)
+ .../** @type {Metrics} */({}),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ dragFactor: config.rowerSettings.dragFactor
+ }
+ let timer = setTimeout(onBroadcastInterval, bleBroadcastInterval)
+
+ const deviceInformationService = new DeviceInformationService()
+ const cpsAppearance = 1156
+ const advDataBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .addFlags(['leGeneralDiscoverableMode', 'brEdrNotSupported'])
+ .addLocalName(/* isComplete */ false, `${config.ftmsRowerPeripheralName}`)
+ .addAppearance(cpsAppearance)
+ .add16BitServiceUUIDs(/* isComplete */ false, [cyclingPowerService.gattService.uuid])
+ .build()
+ const scanResponseBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .addLocalName(/* isComplete */ true, `${config.ftmsRowerPeripheralName} (CPS)`)
+ .build()
+
+ /**
+ * @type {BleManager | undefined}
+ */
+ let _manager
+ /**
+ * @type {Connection | undefined}
+ */
+ let _connection
+
+ setup()
+
+ async function setup () {
+ _manager = await bleManager.getManager()
+ _manager.gattDb.setDeviceName(`${config.ftmsRowerPeripheralName} (CPS)`)
+ _manager.gattDb.addServices([cyclingPowerService.gattService, deviceInformationService.gattService])
+ _manager.setAdvertisingData(advDataBuffer)
+ _manager.setScanResponseData(scanResponseBuffer)
+
+ await triggerAdvertising()
+ }
+
+ async function triggerAdvertising () {
+ _connection = await new Promise((/** @type {(value: Connection) => void} */resolve) => {
+ /** @type {BleManager} */(_manager).startAdvertising({/* options */}, (_status, connection) => {
+ resolve(connection)
+ })
+ })
+ log.debug(`CPS Connection established, address: ${_connection.peerAddress}`)
+
+ _connection.once('disconnect', async () => {
+ log.debug(`CPS client disconnected (address: ${_connection?.peerAddress}), restarting advertising`)
+ _connection = undefined
+ await triggerAdvertising()
+ }) // restart advertising after disconnect
+ }
+
+ // Broadcast the last known metrics
+ function onBroadcastInterval () {
+ cyclingPowerService.notifyData(lastKnownMetrics)
+ timer = setTimeout(onBroadcastInterval, bleBroadcastInterval)
+ }
+
+ /** Records the last known rowing metrics to CPS central
+ * As the client calculates its own speed based on time and distance,
+ * we an only update the last known metrics upon a stroke state change to prevent spiky behaviour
+ * @param {Metrics} metrics
+ */
+ function notifyData (metrics) {
+ if (metrics.metricsContext === undefined) { return }
+ switch (true) {
+ case (metrics.metricsContext.isSessionStop):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ case (metrics.metricsContext.isPauseStart):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ case (metrics.metricsContext.isRecoveryStart):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ case (metrics.timestamp - lastKnownMetrics.timestamp >= bleMinimumKnowDataUpdateInterval):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ default:
+ // Do nothing
+ }
+ }
+
+ /**
+ * CPS does not have status characteristic
+ * @param {{name: string}} status
+ */
+ /* eslint-disable-next-line no-unused-vars -- standardized characteristic interface where the status parameter isn't relevant */
+ function notifyStatus (status) {
+ }
+
+ function destroy () {
+ log.debug('Shutting down CPS peripheral')
+ clearTimeout(timer)
+ _manager?.gattDb.removeService(cyclingPowerService.gattService)
+ _manager?.gattDb.removeService(deviceInformationService.gattService)
+ return new Promise((resolve) => {
+ if (_connection !== undefined) {
+ log.debug('Terminating current CPS connection')
+ _connection.removeAllListeners()
+ _connection.once('disconnect', resolve)
+ _connection.disconnect()
+
+ return
+ }
+ _manager?.stopAdvertising(resolve)
+ })
+ }
+
+ return {
+ triggerAdvertising,
+ notifyData,
+ notifyStatus,
+ destroy
+ }
+}
diff --git a/app/peripherals/ble/CscPeripheral.js b/app/peripherals/ble/CscPeripheral.js
new file mode 100644
index 0000000000..97178a310e
--- /dev/null
+++ b/app/peripherals/ble/CscPeripheral.js
@@ -0,0 +1,164 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are required for
+ a Cycling Speed and Cadence Profile
+*/
+import NodeBleHost from 'ble-host'
+import loglevel from 'loglevel'
+
+import { bleBroadcastInterval, bleMinimumKnowDataUpdateInterval } from '../PeripheralConstants.js'
+
+import { CyclingSpeedCadenceService } from './csc/CyclingSpeedCadenceService.js'
+import { DeviceInformationService } from './common/DeviceInformationService.js'
+
+/**
+ * @typedef {import('./ble-host.interface.js').Connection} Connection
+ * @typedef {import('./ble-host.interface.js').BleManager} BleManager
+ */
+
+const log = loglevel.getLogger('Peripherals')
+
+/**
+ * @param {import('./BleManager.js').BleManager} bleManager
+ * @param {Config} config
+ */
+export function createCscPeripheral (bleManager, config) {
+ const cyclingSpeedCadenceService = new CyclingSpeedCadenceService((event) => {
+ log.debug('CSC Control Point', event)
+ return false
+ })
+
+ /**
+ * @type {Metrics}
+ */
+ let lastKnownMetrics = {
+ // This reference is to satisfy type checking while simplifying the initialization of lastKnownMetrics (i.e. allow partial initialization but have the type system consider it as a full Metrics type)
+ .../** @type {Metrics} */({}),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ dragFactor: config.rowerSettings.dragFactor
+ }
+ let timer = setTimeout(onBroadcastInterval, bleBroadcastInterval)
+
+ const deviceInformationService = new DeviceInformationService()
+ const cscAppearance = 1157 // Cycling Speed and Cadence Sensor
+ const advDataBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .addFlags(['leGeneralDiscoverableMode', 'brEdrNotSupported'])
+ .addLocalName(/* isComplete */ false, `${config.ftmsRowerPeripheralName}`)
+ .addAppearance(cscAppearance)
+ .add16BitServiceUUIDs(/* isComplete */ false, [cyclingSpeedCadenceService.gattService.uuid])
+ .build()
+ const scanResponseBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .addLocalName(/* isComplete */ true, `${config.ftmsRowerPeripheralName} (CSC)`)
+ .build()
+
+ /**
+ * @type {BleManager | undefined}
+ */
+ let _manager
+ /**
+ * @type {Connection | undefined}
+ */
+ let _connection
+
+ setup()
+
+ async function setup () {
+ _manager = await bleManager.getManager()
+ _manager.gattDb.setDeviceName(`${config.ftmsRowerPeripheralName} (CSC)`)
+ _manager.gattDb.addServices([cyclingSpeedCadenceService.gattService, deviceInformationService.gattService])
+ _manager.setAdvertisingData(advDataBuffer)
+ _manager.setScanResponseData(scanResponseBuffer)
+
+ await triggerAdvertising()
+ }
+
+ async function triggerAdvertising () {
+ _connection = await new Promise((/** @type {(value: Connection) => void} */resolve) => {
+ /** @type {BleManager} */(_manager).startAdvertising({/* options */}, (_status, connection) => {
+ resolve(connection)
+ })
+ })
+ log.debug(`CSC Connection established, address: ${_connection.peerAddress}`)
+
+ _connection.once('disconnect', async () => {
+ log.debug(`CSC client disconnected (address: ${_connection?.peerAddress}), restarting advertising`)
+ _connection = undefined
+ await triggerAdvertising()
+ }) // restart advertising after disconnect
+ }
+
+ // present current rowing metrics to CSC central
+ function onBroadcastInterval () {
+ cyclingSpeedCadenceService.notifyData(lastKnownMetrics)
+ timer = setTimeout(onBroadcastInterval, bleBroadcastInterval)
+ }
+
+ /** Records the last known rowing metrics to CSC central
+ * As the client calculates its own speed based on time and distance,
+ * we an only update the last known metrics upon a stroke state change to prevent spiky behaviour
+ * @param {Metrics} metrics
+ */
+ function notifyData (metrics) {
+ if (metrics.metricsContext === undefined) { return }
+ switch (true) {
+ case (metrics.metricsContext.isSessionStop):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ case (metrics.metricsContext.isPauseStart):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ case (metrics.metricsContext.isRecoveryStart):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ case (metrics.timestamp - lastKnownMetrics.timestamp >= bleMinimumKnowDataUpdateInterval):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ default:
+ // Do nothing
+ }
+ }
+
+ /**
+ * CSC does not have status characteristic
+ * @param {{name: string}} status
+ */
+ /* eslint-disable-next-line no-unused-vars -- standardized characteristic interface where the status parameter isn't relevant */
+ function notifyStatus (status) {
+ }
+
+ function destroy () {
+ log.debug('Shutting down CSC peripheral')
+ clearTimeout(timer)
+ _manager?.gattDb.removeService(cyclingSpeedCadenceService.gattService)
+ _manager?.gattDb.removeService(deviceInformationService.gattService)
+ return new Promise((resolve) => {
+ if (_connection !== undefined) {
+ log.debug('Terminating current CSC connection')
+ _connection.removeAllListeners()
+ _connection.once('disconnect', resolve)
+ _connection.disconnect()
+
+ return
+ }
+ _manager?.stopAdvertising(resolve)
+ })
+ }
+
+ return {
+ triggerAdvertising,
+ notifyData,
+ notifyStatus,
+ destroy
+ }
+}
diff --git a/app/peripherals/ble/FtmsPeripheral.js b/app/peripherals/ble/FtmsPeripheral.js
new file mode 100644
index 0000000000..9f761a7b5e
--- /dev/null
+++ b/app/peripherals/ble/FtmsPeripheral.js
@@ -0,0 +1,149 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are required for
+ a Fitness Machine Device
+
+ Relevant parts from https://www.bluetooth.com/specifications/specs/fitness-machine-profile-1-0/
+ The Fitness Machine shall instantiate one and only one Fitness Machine Service as Primary Service
+ The User Data Service, if supported, shall be instantiated as a Primary Service.
+ The Fitness Machine may instantiate the Device Information Service
+ (Manufacturer Name String, Model Number String)
+*/
+import NodeBleHost from 'ble-host'
+import loglevel from 'loglevel'
+
+import { DeviceInformationService } from './common/DeviceInformationService.js'
+import { FitnessMachineService } from './ftms/FitnessMachineService.js'
+
+/**
+ * @typedef {import('./ble-host.interface.js').BleManager} BleManager
+ * @typedef {import('./ble-host.interface.js').Connection} Connection
+ */
+
+const log = loglevel.getLogger('Peripherals')
+
+/**
+ * @param {import('./BleManager.js').BleManager} bleManager
+ * @param {ControlPointCallback} controlCallback
+ * @param {Config} config
+ * @param {boolean} simulateIndoorBike
+ */
+export function createFtmsPeripheral (bleManager, controlCallback, config, simulateIndoorBike) {
+ const peripheralName = simulateIndoorBike ? config.ftmsBikePeripheralName : config.ftmsRowerPeripheralName
+ const fitnessMachineService = new FitnessMachineService(controlCallback, simulateIndoorBike)
+ const deviceInformationService = new DeviceInformationService()
+
+ const rowerSupportedDataFlag = simulateIndoorBike ? 0x01 << 5 : 0x01 << 4
+ const fitnessMachineAvailable = 0x01
+
+ const advDataBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .addFlags(['leGeneralDiscoverableMode', 'brEdrNotSupported'])
+ .addLocalName(/* isComplete */ false, peripheralName.slice(0, 15))
+ .add16BitServiceUUIDs(/* isComplete */ true, [fitnessMachineService.gattService.uuid])
+ .add16BitServiceData(fitnessMachineService.gattService.uuid, Buffer.from([fitnessMachineAvailable, rowerSupportedDataFlag, rowerSupportedDataFlag >> 8]))
+ .build()
+
+ const scanResponseBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .addLocalName(/* isComplete */ true, peripheralName)
+ .build()
+
+ const broadcastInterval = config.ftmsUpdateInterval
+ /**
+ * @type {Metrics}
+ */
+ let lastKnownMetrics = {
+ // This reference is to satisfy type checking while simplifying the initialization of lastKnownMetrics (i.e. allow partial initialization but have the type system consider it as a full Metrics type)
+ .../** @type {Metrics} */({}),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ dragFactor: config.rowerSettings.dragFactor
+ }
+
+ let timer = setTimeout(onBroadcastInterval, broadcastInterval)
+
+ /**
+ * @type {BleManager | undefined}
+ */
+ let _manager
+ /**
+ * @type {Connection | undefined}
+ */
+ let _connection
+
+ setup()
+
+ async function setup () {
+ _manager = await bleManager.getManager()
+ _manager.gattDb.setDeviceName(peripheralName)
+ _manager.gattDb.addServices([fitnessMachineService.gattService, deviceInformationService.gattService])
+ _manager.setAdvertisingData(advDataBuffer)
+ _manager.setScanResponseData(scanResponseBuffer)
+
+ await triggerAdvertising()
+ }
+
+ async function triggerAdvertising () {
+ _connection = await new Promise((/** @type {(value: Connection) => void} */resolve) => {
+ /** @type {BleManager} */(_manager).startAdvertising({/* options */}, (_status, connection) => {
+ resolve(connection)
+ })
+ })
+ log.debug(`FTMS Connection established, address: ${_connection.peerAddress}`)
+
+ await new Promise((resolve) => { /** @type {Connection} */(_connection).gatt.exchangeMtu(resolve) })
+
+ _connection.once('disconnect', async () => {
+ log.debug(`FTMS client disconnected (address: ${_connection?.peerAddress}), restarting advertising`)
+ _connection = undefined
+ await triggerAdvertising()
+ }) // restart advertising after disconnect
+ }
+
+ /** Records the last known rowing metrics to FTMS central
+ * @param {Metrics} data
+ */
+ function notifyData (data) {
+ lastKnownMetrics = data
+ }
+
+ /**
+ * Present current rowing status to FTMS central
+ * @param {{name: string}} status
+ */
+ function notifyStatus (status) {
+ fitnessMachineService.notifyStatus(status)
+ }
+
+ function destroy () {
+ log.debug(`Shutting down FTMS ${simulateIndoorBike ? 'Bike' : 'Rower'} peripheral`)
+ clearTimeout(timer)
+ _manager?.gattDb.removeService(fitnessMachineService.gattService)
+ _manager?.gattDb.removeService(deviceInformationService.gattService)
+ return new Promise((resolve) => {
+ if (_connection !== undefined) {
+ log.debug(`Terminating current FTMS ${simulateIndoorBike ? 'Bike' : 'Rower'} connection`)
+ _connection.removeAllListeners()
+ _connection.once('disconnect', resolve)
+ _connection.disconnect()
+
+ return
+ }
+ _manager?.stopAdvertising(resolve)
+ })
+ }
+
+ // present current rowing metrics to FTMS central
+ function onBroadcastInterval () {
+ fitnessMachineService.notifyData(lastKnownMetrics)
+ timer = setTimeout(onBroadcastInterval, broadcastInterval)
+ }
+
+ return {
+ triggerAdvertising,
+ notifyData,
+ notifyStatus,
+ destroy
+ }
+}
diff --git a/app/peripherals/ble/HrmPeripheral.js b/app/peripherals/ble/HrmPeripheral.js
new file mode 100644
index 0000000000..508f9c11c0
--- /dev/null
+++ b/app/peripherals/ble/HrmPeripheral.js
@@ -0,0 +1,42 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+import EventEmitter from 'node:events'
+
+import { HrmService } from './hrm/HrmService.js'
+
+/**
+ * @event createBleHrmPeripheral#heartRateMeasurement
+ * @param {import ('./BleManager.js').BleManager} bleManager
+ */
+export function createBleHrmPeripheral (bleManager) {
+ /**
+ * @type {EventEmitter<{heartRateMeasurement: Array}>}
+ */
+ const emitter = new EventEmitter()
+ /**
+ * @type {HrmService | undefined}
+ */
+ let _hrmService
+
+ async function attach () {
+ _hrmService = new HrmService(await bleManager.getManager())
+
+ _hrmService.on('heartRateMeasurement', (data) => {
+ emitter.emit('heartRateMeasurement', data)
+ })
+
+ _hrmService.start()
+ }
+
+ async function destroy () {
+ _hrmService?.removeAllListeners()
+ await _hrmService?.stop()
+ }
+
+ return Object.assign(emitter, {
+ destroy,
+ attach
+ })
+}
diff --git a/app/peripherals/ble/Pm5Peripheral.js b/app/peripherals/ble/Pm5Peripheral.js
new file mode 100644
index 0000000000..308da8eaf3
--- /dev/null
+++ b/app/peripherals/ble/Pm5Peripheral.js
@@ -0,0 +1,129 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are used by the
+ Concept2 PM5 rowing machine.
+
+ see: https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
+ and https://www.concept2.co.uk/files/pdf/us/monitors/PM5_CSAFECommunicationDefinition.pdf
+*/
+import NodeBleHost from 'ble-host'
+import log from 'loglevel'
+
+import { pm5Constants, toC2128BitUUID } from './pm5/Pm5Constants.js'
+import { Pm5AppearanceService } from './pm5/Pm5AppearanceService.js'
+import { Pm5ControlService } from './pm5/control-service/Pm5ControlService.js'
+import { Pm5DeviceInformationService } from './pm5/Pm5DeviceInformationService.js'
+import { Pm5HeartRateControlService } from './pm5/heart-rate-service/Pm5HeartRateControlService.js'
+import { Pm5RowingService } from './pm5/rowing-service/Pm5RowingService.js'
+import { DeviceInformationService } from './common/DeviceInformationService.js'
+
+/**
+ * @typedef {import('./ble-host.interface.js').BleManager} BleManager
+ * @typedef {import('./ble-host.interface.js').Connection} Connection
+ */
+
+/**
+ * @param {import ('./BleManager.js').BleManager} bleManager
+ * @param {Config} config
+ * @param {ControlPointCallback} controlCallback
+ */
+export function createPm5Peripheral (bleManager, config, controlCallback) {
+ const deviceInformationService = new Pm5DeviceInformationService()
+ const appearanceService = new Pm5AppearanceService()
+ const controlService = new Pm5ControlService(controlCallback)
+ const rowingService = new Pm5RowingService(config)
+ const heartRateControlService = new Pm5HeartRateControlService()
+ const gattServices = [appearanceService.gattService, controlService.gattService, deviceInformationService.gattService, rowingService.gattService, heartRateControlService.gattService, new DeviceInformationService().gattService]
+
+ const advDataBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .addFlags(['leGeneralDiscoverableMode', 'brEdrNotSupported'])
+ .addLocalName(/* isComplete */ true, `${pm5Constants.name} Row`)
+ .build()
+ const scanResponseBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .add128BitServiceUUIDs(/* isComplete */ true, [toC2128BitUUID('0000')])
+ .build()
+
+ /**
+ * @type {BleManager | undefined}
+ */
+ let _manager
+ /**
+ * @type {Connection | undefined}
+ */
+ let _connection
+
+ setup()
+
+ async function setup () {
+ _manager = await bleManager.getManager()
+ _manager.gattDb.setDeviceName(pm5Constants.name)
+ _manager.gattDb.addServices(gattServices)
+ _manager.setAdvertisingData(advDataBuffer)
+ _manager.setScanResponseData(scanResponseBuffer)
+
+ await triggerAdvertising()
+ }
+
+ async function triggerAdvertising () {
+ _connection = await new Promise((/** @type {(value: Connection) => void} */resolve) => {
+ /** @type {BleManager} */(_manager).startAdvertising({/* options */}, (_status, connection) => {
+ resolve(connection)
+ })
+ })
+ log.debug(`PM5 Connection established, address: ${_connection.peerAddress}`)
+
+ await new Promise((resolve) => { /** @type {Connection} */(_connection).gatt.exchangeMtu(resolve) })
+
+ _connection.once('disconnect', async () => {
+ log.debug(`PM5 client disconnected (address: ${_connection?.peerAddress}), restarting advertising`)
+ _connection = undefined
+ await triggerAdvertising()
+ }) // restart advertising after disconnect
+ }
+
+ /**
+ * Records the last known rowing metrics to FTMS central
+ * @param {Metrics} data
+ */
+ function notifyData (data) {
+ rowingService.notifyData(data)
+ }
+
+ /**
+ * Present current rowing status to C2-PM5 central
+ * @param {{name: string}} status
+ */
+ /* eslint-disable-next-line no-unused-vars -- standardized characteristic interface where the data parameter isn't relevant */
+ function notifyStatus (status) {
+ }
+
+ function destroy () {
+ log.debug('Shutting down PM5 peripheral')
+
+ if (_manager !== undefined) {
+ gattServices.forEach((service) => {
+ /** @type {BleManager} */(_manager).gattDb.removeService(service)
+ })
+ }
+ return new Promise((resolve) => {
+ if (_connection !== undefined) {
+ log.debug('Terminating current PM5 connection')
+ _connection.removeAllListeners()
+ _connection.once('disconnect', resolve)
+ _connection.disconnect()
+
+ return
+ }
+ _manager?.stopAdvertising(resolve)
+ })
+ }
+
+ return {
+ triggerAdvertising,
+ notifyData,
+ notifyStatus,
+ destroy
+ }
+}
diff --git a/app/peripherals/ble/ble-host.interface.js b/app/peripherals/ble/ble-host.interface.js
new file mode 100644
index 0000000000..7292251062
--- /dev/null
+++ b/app/peripherals/ble/ble-host.interface.js
@@ -0,0 +1,658 @@
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/* eslint-disable no-unused-vars */
+import { EventEmitter } from 'node:stream'
+
+/**
+ * - not-permitted (Characteristic cannot be read)
+ * - open (Can always be read)
+ * - encrypted (Can only be read when the link is encrypted)
+ * - encrypted-mitm (Can only be read when the link is encrypted with a key that was generated with MITM protection)
+ * - encrypted-mitm-sc (Can only be read when the link is encrypted with a key that was generated with MITM protection and Secure Connections pairing)
+ * - custom (A user-provided method will called upon each read to determine if the read should be permitted)
+ * @typedef {'not-permitted'|'open'|'encrypted'|'encrypted-mitm'|'encrypted-mitm-sc'|'custom'}CharacteristicPermission
+ */
+
+/**
+ * BLE Manager for handling Bluetooth Low Energy operations.
+ */
+export class BleManager {
+ /**
+ * @type {GattServerDb}
+ */
+ // @ts-ignore
+ gattDb
+ /**
+ * Creates a BleManager instance.
+ * @param {import('node:events').EventEmitter} transport - The transport object for HCI packets.
+ * @param {object} options - Optional parameters.
+ * @param {string} options.staticRandomAddress - Optional static random address.
+ * @param {Function} callback - Callback function with error and manager instance.
+ */
+ static create (transport, options, callback) {
+ callback(null, new BleManager())
+ }
+
+ /**
+ * Starts a scan for BLE devices.
+ * @param {object} parameters - Scan parameters.
+ * @param {boolean} [parameters.activeScan=true] - Request scan response data.
+ * @param {number} [parameters.scanWindow=16] - Scan window in 0.625 ms units.
+ * @param {number} [parameters.scanInterval=16] - Scan interval in 0.625 ms units.
+ * @param {boolean} [parameters.filterDuplicates=false] - Filter duplicate advertisements.
+ * @param {Array} [parameters.scanFilters] - Array of scan filters.
+ * @returns {Scanner} The scanner instance.
+ */
+ startScan (parameters) {
+ return new Scanner()
+ }
+
+ /**
+ * Connects to a BLE device.
+ * @param {string} bdAddrType - Address type: "public" or "random".
+ * @param {string} bdAddr - Bluetooth Device Address.
+ * @param {object} parameters - Connection parameters.
+ * @param {number} [parameters.connIntervalMin=20] - Minimum connection interval.
+ * @param {number} [parameters.connIntervalMax=25] - Maximum connection interval.
+ * @param {number} [parameters.connLatency=0] - Slave latency.
+ * @param {number} [parameters.supervisionTimeout=500] - Supervision timeout.
+ * @param {(connection: Connection) => void} callback - Callback with the connection object.
+ * @returns {PendingConnection} A pending connection object.
+ */
+ connect (bdAddrType, bdAddr, parameters, callback) {
+ return new PendingConnection()
+ }
+
+ /**
+ * Removes a bonding between the local controller and a peer device.
+ * @param {string} identityAddressType - Identity address type ("public" or "random").
+ * @param {string} identityAddress - The identity address.
+ */
+ removeBond (identityAddressType, identityAddress) {}
+
+ /**
+ * Sets advertising data.
+ * @param {Buffer} data - Buffer containing max 31 bytes of advertising data.
+ * @param {Function} [callback] - Callback with HCI status code.
+ */
+ setAdvertisingData (data, callback) {}
+
+ /**
+ * Sets scan response data.
+ * @param {Buffer} data - Buffer containing max 31 bytes of scan response data.
+ * @param {Function} [callback] - Callback with HCI status code.
+ */
+ setScanResponseData (data, callback) {}
+
+ /**
+ * Starts advertising.
+ * @param {object} parameters - Advertising parameters.
+ * @param {number} [parameters.intervalMin=62.5] - Minimum advertising interval.
+ * @param {number} [parameters.intervalMax=62.5] - Maximum advertising interval.
+ * @param {string} [parameters.advertisingType="ADV_IND"] - Advertising type.
+ * @param {object} [parameters.directedAddress] - Directed address object.
+ * @param {(status: number, connection: Connection) => void} callback - Callback function.
+ */
+ startAdvertising (parameters, callback) {}
+
+ /**
+ * Stops advertising.
+ * @param {Function} [callback] - Callback with HCI status code.
+ */
+ stopAdvertising (callback) {}
+}
+
+/**
+ * Scanner for BLE device discovery.
+ * @fires Scanner#report
+ */
+export class Scanner extends EventEmitter {
+ /**
+ * Stops the scan.
+ */
+ stopScan () {}
+}
+
+/**
+ * Event emitted when a report is received during the scan.
+ * @type {object}
+ * @property {boolean} connectable - Whether the device is connectable (i.e. it did not send ADV_NONCONN_IND).
+ * @property {string} addressType - Address type, either 'public' or 'random'.
+ * @property {string} address - The Bluetooth address of the device.
+ * @property {number} rssi - The RSSI (Received Signal Strength Indicator) in dBm. (-127 to 20, 127 means not available).
+ * @property {Array