148 lines
5.0 KiB
JavaScript
148 lines
5.0 KiB
JavaScript
'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: 'simulation', 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
|
|
}
|
|
}
|