begins implementation of the PM5 protocol

This commit is contained in:
Lars Berning 2021-03-13 20:35:36 +00:00
parent 6c5c1b8cb8
commit efe13f4804
17 changed files with 317 additions and 10 deletions

View File

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

102
app/ble/Pm5Peripheral.js Normal file
View File

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

View File

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

View File

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

View File

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

31
app/ble/pm5/GapService.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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