219 lines
8.1 KiB
JavaScript
219 lines
8.1 KiB
JavaScript
'use strict'
|
|
|
|
import log from 'loglevel'
|
|
import { Messages } from 'incyclist-ant-plus'
|
|
import { PeripheralConstants } from '../PeripheralConstants.js'
|
|
|
|
/*
|
|
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
|
|
*/
|
|
|
|
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 timer
|
|
|
|
let sessionData = {
|
|
accumulatedStrokes: 0,
|
|
accumulatedDistance: 0,
|
|
accumulatedTime: 0,
|
|
accumulatedPower: 0,
|
|
cycleLinearVelocity: 0,
|
|
strokeRate: 0,
|
|
instantaneousPower: 0,
|
|
distancePerStroke: 0,
|
|
fitnessEquipmentState: fitnessEquipmentStates.inUse,
|
|
sessionStatus: '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 = setInterval(onBroadcastInterval, broadcastInterval)
|
|
}
|
|
|
|
function destroy () {
|
|
return new Promise((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 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.sessionStatus === '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.sessionStatus === '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.sessionStatus === '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)
|
|
}
|
|
|
|
function notifyData (type, data) {
|
|
if (type === 'strokeFinished' || type === 'metricsUpdate') {
|
|
sessionData = {
|
|
...sessionData,
|
|
accumulatedDistance: data.totalLinearDistance & 0xFF,
|
|
accumulatedStrokes: data.totalNumberOfStrokes & 0xFF,
|
|
accumulatedTime: Math.trunc(data.totalMovingTime * 4) & 0xFF,
|
|
cycleLinearVelocity: Math.round(data.cycleLinearVelocity * 1000),
|
|
strokeRate: Math.round(data.cycleStrokeRate) & 0xFF,
|
|
instantaneousPower: Math.round(data.cyclePower) & 0xFFFF,
|
|
distancePerStroke: Math.round(data.cycleDistance * 100),
|
|
sessionStatus: data.sessionStatus
|
|
}
|
|
}
|
|
}
|
|
|
|
// FE does not have status characteristic
|
|
function notifyStatus (status) {
|
|
}
|
|
|
|
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 }
|