From efe13f480486d8252e552cc602a6ed7354151e65 Mon Sep 17 00:00:00 2001 From: Lars Berning <151194+laberning@users.noreply.github.com> Date: Sat, 13 Mar 2021 20:35:36 +0000 Subject: [PATCH] begins implementation of the PM5 protocol --- ...MachinePeripheral.js => FtmsPeripheral.js} | 8 +- app/ble/Pm5Peripheral.js | 102 ++++++++++++++++++ .../{ => ftms}/DeviceInformationService.js | 0 ...itnessMachineControlPointCharacteristic.js | 0 app/ble/{ => ftms}/FitnessMachineService.js | 0 .../FitnessMachineStatusCharacteristic.js | 0 .../IndoorBikeDataCharacteristic.js | 4 +- .../IndoorBikeFeatureCharacteristic.js | 0 app/ble/{ => ftms}/RowerDataCharacteristic.js | 4 +- .../{ => ftms}/RowerFeatureCharacteristic.js | 0 app/ble/pm5/DeviceInformationService.js | 32 ++++++ app/ble/pm5/GapService.js | 31 ++++++ app/ble/pm5/Pm5Constants.js | 26 +++++ app/ble/pm5/Pm5ControlService.js | 22 ++++ app/ble/pm5/Pm5RowingService.js | 49 +++++++++ app/ble/pm5/ValueReadCharacteristic.js | 40 +++++++ app/server.js | 9 +- 17 files changed, 317 insertions(+), 10 deletions(-) rename app/ble/{RowingMachinePeripheral.js => FtmsPeripheral.js} (92%) create mode 100644 app/ble/Pm5Peripheral.js rename app/ble/{ => ftms}/DeviceInformationService.js (100%) rename app/ble/{ => ftms}/FitnessMachineControlPointCharacteristic.js (100%) rename app/ble/{ => ftms}/FitnessMachineService.js (100%) rename app/ble/{ => ftms}/FitnessMachineStatusCharacteristic.js (100%) rename app/ble/{ => ftms}/IndoorBikeDataCharacteristic.js (99%) rename app/ble/{ => ftms}/IndoorBikeFeatureCharacteristic.js (100%) rename app/ble/{ => ftms}/RowerDataCharacteristic.js (99%) rename app/ble/{ => ftms}/RowerFeatureCharacteristic.js (100%) create mode 100644 app/ble/pm5/DeviceInformationService.js create mode 100644 app/ble/pm5/GapService.js create mode 100644 app/ble/pm5/Pm5Constants.js create mode 100644 app/ble/pm5/Pm5ControlService.js create mode 100644 app/ble/pm5/Pm5RowingService.js create mode 100644 app/ble/pm5/ValueReadCharacteristic.js diff --git a/app/ble/RowingMachinePeripheral.js b/app/ble/FtmsPeripheral.js similarity index 92% rename from app/ble/RowingMachinePeripheral.js rename to app/ble/FtmsPeripheral.js index 5972aa9..9c7f13f 100644 --- a/app/ble/RowingMachinePeripheral.js +++ b/app/ble/FtmsPeripheral.js @@ -13,11 +13,11 @@ */ import bleno from '@abandonware/bleno' import { EventEmitter } from 'events' -import FitnessMachineService from './FitnessMachineService.js' -import DeviceInformationService from './DeviceInformationService.js' +import FitnessMachineService from './ftms/FitnessMachineService.js' +import DeviceInformationService from './ftms/DeviceInformationService.js' import log from 'loglevel' -function createRowingMachinePeripheral (options) { +function createFtmsPeripheral (options) { const emitter = new EventEmitter() const peripheralName = options?.simulateIndoorBike ? 'OpenRowingBike' : 'OpenRowingMonitor' @@ -107,4 +107,4 @@ function createRowingMachinePeripheral (options) { }) } -export { createRowingMachinePeripheral } +export { createFtmsPeripheral } diff --git a/app/ble/Pm5Peripheral.js b/app/ble/Pm5Peripheral.js new file mode 100644 index 0000000..5247d2d --- /dev/null +++ b/app/ble/Pm5Peripheral.js @@ -0,0 +1,102 @@ +'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 { EventEmitter } from 'events' +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 (options) { + const emitter = new EventEmitter() + + const peripheralName = constants.name + const deviceInformationService = new DeviceInformationService() + const gapService = new GapService() + const controlService = new Pm5ControlService() + const rowingService = new Pm5RowingService() + + bleno.on('stateChange', (state) => { + if (state === 'poweredOn') { + bleno.startAdvertising( + peripheralName, + [gapService.uuid], + (error) => { + if (error) log.error(error) + } + ) + } else { + bleno.stopAdvertising() + } + }) + + 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('advertisingStop', (event) => { + log.debug('advertisingStop', event) + }) + bleno.on('servicesSet', (event) => { + log.debug('servicesSet', event) + }) + bleno.on('servicesSetError', (event) => { + log.debug('servicesSetError', event) + }) + bleno.on('rssiUpdate', (event) => { + log.debug('rssiUpdate', event) + }) + + // deliver current rowing metrics via BLE + function notifyData (data) { + // fitnessMachineService.notifyData(data) + } + + // deliver a status change via BLE + function notifyStatus (status) { + // fitnessMachineService.notifyStatus(status) + } + + return Object.assign(emitter, { + notifyData, + notifyStatus + }) +} + +export { createPm5Peripheral } diff --git a/app/ble/DeviceInformationService.js b/app/ble/ftms/DeviceInformationService.js similarity index 100% rename from app/ble/DeviceInformationService.js rename to app/ble/ftms/DeviceInformationService.js diff --git a/app/ble/FitnessMachineControlPointCharacteristic.js b/app/ble/ftms/FitnessMachineControlPointCharacteristic.js similarity index 100% rename from app/ble/FitnessMachineControlPointCharacteristic.js rename to app/ble/ftms/FitnessMachineControlPointCharacteristic.js diff --git a/app/ble/FitnessMachineService.js b/app/ble/ftms/FitnessMachineService.js similarity index 100% rename from app/ble/FitnessMachineService.js rename to app/ble/ftms/FitnessMachineService.js diff --git a/app/ble/FitnessMachineStatusCharacteristic.js b/app/ble/ftms/FitnessMachineStatusCharacteristic.js similarity index 100% rename from app/ble/FitnessMachineStatusCharacteristic.js rename to app/ble/ftms/FitnessMachineStatusCharacteristic.js diff --git a/app/ble/IndoorBikeDataCharacteristic.js b/app/ble/ftms/IndoorBikeDataCharacteristic.js similarity index 99% rename from app/ble/IndoorBikeDataCharacteristic.js rename to app/ble/ftms/IndoorBikeDataCharacteristic.js index 5c30340..611605f 100644 --- a/app/ble/IndoorBikeDataCharacteristic.js +++ b/app/ble/ftms/IndoorBikeDataCharacteristic.js @@ -36,13 +36,13 @@ export default class IndoorBikeDataCharacteristic extends bleno.Characteristic { log.debug('IndooBikeDataCharacteristic - central subscribed') this._updateValueCallback = updateValueCallback return this.RESULT_SUCCESS - }; + } onUnsubscribe () { log.debug('IndooBikeDataCharacteristic - central unsubscribed') this._updateValueCallback = null return this.RESULT_UNLIKELY_ERROR - }; + } notify (data) { // ignore events without the mandatory fields diff --git a/app/ble/IndoorBikeFeatureCharacteristic.js b/app/ble/ftms/IndoorBikeFeatureCharacteristic.js similarity index 100% rename from app/ble/IndoorBikeFeatureCharacteristic.js rename to app/ble/ftms/IndoorBikeFeatureCharacteristic.js diff --git a/app/ble/RowerDataCharacteristic.js b/app/ble/ftms/RowerDataCharacteristic.js similarity index 99% rename from app/ble/RowerDataCharacteristic.js rename to app/ble/ftms/RowerDataCharacteristic.js index 6d0668b..8e9db48 100644 --- a/app/ble/RowerDataCharacteristic.js +++ b/app/ble/ftms/RowerDataCharacteristic.js @@ -29,13 +29,13 @@ export default class RowerDataCharacteristic extends bleno.Characteristic { log.debug('RowerDataCharacteristic - central subscribed') this._updateValueCallback = updateValueCallback return this.RESULT_SUCCESS - }; + } onUnsubscribe () { log.debug('RowerDataCharacteristic - central unsubscribed') this._updateValueCallback = null return this.RESULT_UNLIKELY_ERROR - }; + } notify (data) { // ignore events without the mandatory fields diff --git a/app/ble/RowerFeatureCharacteristic.js b/app/ble/ftms/RowerFeatureCharacteristic.js similarity index 100% rename from app/ble/RowerFeatureCharacteristic.js rename to app/ble/ftms/RowerFeatureCharacteristic.js diff --git a/app/ble/pm5/DeviceInformationService.js b/app/ble/pm5/DeviceInformationService.js new file mode 100644 index 0000000..4a0d865 --- /dev/null +++ b/app/ble/pm5/DeviceInformationService.js @@ -0,0 +1,32 @@ +'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 './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 new file mode 100644 index 0000000..1730894 --- /dev/null +++ b/app/ble/pm5/GapService.js @@ -0,0 +1,31 @@ +'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 './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 new file mode 100644 index 0000000..398701b --- /dev/null +++ b/app/ble/pm5/Pm5Constants.js @@ -0,0 +1,26 @@ +'use strict' +/* + Open Rowing Monitor, https://github.com/laberning/openrowingmonitor + + Some PM5 specific constants +*/ +const constants = { + serial: '123456789', + model: 'PM5', + name: 'PM5 123456789', + hardwareRevision: '633', + // see https://www.concept2.com/service/monitors/pm5/firmware for available versions + firmwareRevision: '207', + 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 new file mode 100644 index 0000000..f3607da --- /dev/null +++ b/app/ble/pm5/Pm5ControlService.js @@ -0,0 +1,22 @@ +'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 implemented +*/ +import bleno from '@abandonware/bleno' +import { getFullUUID } from './Pm5Constants.js' +import ValueReadCharacteristic from './ValueReadCharacteristic.js' + +export default class PM5ControlService extends bleno.PrimaryService { + constructor () { + super({ + uuid: getFullUUID('0020'), + characteristics: [ + new ValueReadCharacteristic(getFullUUID('0021'), 'Control Command'), + new ValueReadCharacteristic(getFullUUID('0022'), 'Response to Control Command') + ] + }) + } +} diff --git a/app/ble/pm5/Pm5RowingService.js b/app/ble/pm5/Pm5RowingService.js new file mode 100644 index 0000000..efeab1b --- /dev/null +++ b/app/ble/pm5/Pm5RowingService.js @@ -0,0 +1,49 @@ +'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 +*/ +import bleno from '@abandonware/bleno' +import { getFullUUID } from './Pm5Constants.js' +import ValueReadCharacteristic from './ValueReadCharacteristic.js' + +export default class PM5RowingService extends bleno.PrimaryService { + constructor () { + super({ + uuid: getFullUUID('0030'), + characteristics: [ + // C2 rowing general status + new ValueReadCharacteristic(getFullUUID('0031'), 'rowing status', 'rowing status'), + // C2 rowing additional status + new ValueReadCharacteristic(getFullUUID('0032'), 'additional status', 'additional status'), + // C2 rowing additional status 2 + new ValueReadCharacteristic(getFullUUID('0033'), 'additional status 2', 'additional status 2'), + // C2 rowing general status and additional status samplerate + new ValueReadCharacteristic(getFullUUID('0034'), 'samplerate', 'samplerate'), + // C2 rowing stroke data + new ValueReadCharacteristic(getFullUUID('0035'), 'stroke data', 'stroke data'), + // C2 rowing additional stroke data + new ValueReadCharacteristic(getFullUUID('0036'), 'additional stroke data', 'additional stroke data'), + // 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 + new ValueReadCharacteristic(getFullUUID('0080'), 'multiplexed information', 'multiplexed information') + ] + }) + } +} diff --git a/app/ble/pm5/ValueReadCharacteristic.js b/app/ble/pm5/ValueReadCharacteristic.js new file mode 100644 index 0000000..0da120a --- /dev/null +++ b/app/ble/pm5/ValueReadCharacteristic.js @@ -0,0 +1,40 @@ +'use strict' +/* + Open Rowing Monitor, https://github.com/laberning/openrowingmonitor + + A simple Characteristic that gives access to a static value + Currently 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: 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`) + 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/server.js b/app/server.js index b5a6588..d5ed96a 100644 --- a/app/server.js +++ b/app/server.js @@ -12,7 +12,9 @@ import finalhandler from 'finalhandler' import http from 'http' import serveStatic from 'serve-static' import log from 'loglevel' -import { createRowingMachinePeripheral } from './ble/RowingMachinePeripheral.js' +import { createFtmsPeripheral } from './ble/FtmsPeripheral.js' +// eslint-disable-next-line no-unused-vars +import { createPm5Peripheral } from './ble/Pm5Peripheral.js' import { createRowingEngine } from './engine/RowingEngine.js' import { createRowingStatistics } from './engine/RowingStatistics.js' // eslint-disable-next-line no-unused-vars @@ -21,10 +23,13 @@ import { recordRowingSession, replayRowingSession } from './tools/RowingRecorder // sets the global log level log.setLevel(log.levels.INFO) -const peripheral = createRowingMachinePeripheral({ +const peripheral = createFtmsPeripheral({ simulateIndoorBike: false }) +// the simulation of a C2 PM5 is not finished yet +// const peripheral = createPm5Peripheral() + peripheral.on('controlPoint', (event) => { if (event?.req?.name === 'requestControl') { event.res = true