From 23c66a3e3b262c66c7859196607e7a5c30cd1194 Mon Sep 17 00:00:00 2001 From: ptx2 Date: Sun, 21 Mar 2021 21:13:35 -0400 Subject: [PATCH 1/3] Add experimental Echelon support. --- src/bikes/echelon.js | 198 ++++++++++++++++++++++++++++++++++++++ src/bikes/index.js | 6 ++ src/test/bikes/echelon.js | 26 +++++ 3 files changed, 230 insertions(+) create mode 100644 src/bikes/echelon.js create mode 100644 src/test/bikes/echelon.js diff --git a/src/bikes/echelon.js b/src/bikes/echelon.js new file mode 100644 index 00000000..c9089a73 --- /dev/null +++ b/src/bikes/echelon.js @@ -0,0 +1,198 @@ +import util from 'util'; +import {EventEmitter} from 'events'; +import {execFile} from 'child_process'; +const execFileAsync = util.promisify(execFile); +import {scan} from '../util/ble-scan'; +import {macAddress} from '../util/mac-address'; + +// GATT service/characteristic UUIDs +const ADVERTISED_SERVICE_UUID = '0bf669f045f211e795980800200c9a66'; +const UART_SERVICE_UUID = '0bf669f145f211e795980800200c9a66'; +const UART_RX_UUID = '0bf669f245f211e795980800200c9a66'; +const UART_TX_UUID = '0bf669f445f211e795980800200c9a66'; + +// stats packet parsing +const STATS_PKT_TYPE_CADENCE = 0xD1; +const STATS_PKT_TYPE_RESISTANCE = 0xD2; +const STATS_PKT_IDX_TYPE = 1; // 8-bit packet type data offset within packet +const STATS_PKT_IDX_CADENCE = 10; // 8-bit cadence data offset within packet +const STATS_PKT_IDX_RESISTANCE = 3; // 8-bit resistance data offset within packet + +const ENABLE_NOTIFICATIONS_PKT = Buffer.from([0xF0, 0xB0, 0x01, 0x01, 0xA2]); + +const debuglog = util.debuglog('gymnasticon:bikes:echelon'); + +/** + * Handles communication with Echelon indoor training bike using the bike's + * proprietary protocol. + */ +export class EchelonBikeClient extends EventEmitter { + /** + * Create an EchelonBikeClient instance. + * @param {Noble} noble - a Noble instance. + * @param {object} filters - filters to specify bike when more than one is present + * @param {string} filters.address - mac address + * @param {string} filters.name - device name + */ + constructor(noble, filters) { + super(); + this.noble = noble; + this.filters = filters; + this.state = 'disconnected'; + this.onReceive = this.onReceive.bind(this); + this.onDisconnect = this.onDisconnect.bind(this); + } + + /** + * Establish a connection to the bike's Bluetooth LE GATT Service. + */ + async connect() { + if (this.state === 'connected') { + throw new Error('Already connected'); + } + + // scan + this.peripheral = await scan(this.noble, [ADVERTISED_SERVICE_UUID], this.filters); + + // connect + this.peripheral.on('disconnect', this.onDisconnect); + await this.peripheral.connectAsync(); + + // discover services/characteristics + const {characteristics} = await this.peripheral.discoverSomeServicesAndCharacteristicsAsync( + [UART_SERVICE_UUID], [UART_TX_UUID, UART_RX_UUID]); + const [tx, rx] = characteristics; + this.tx = tx; + this.rx = rx; + + // initial stats + this.stats = { + cadence: 0, + resistance: 0, + power: 0, + }; + + // start streaming stats + await this.rx.writeAsync(ENABLE_NOTIFICATIONS_PKT, true); + + // subscribe to receive data + this.tx.on('read', this.onReceive); + await this.tx.subscribeAsync(); + + this.state = 'connected'; + } + + /** + * Get the bike's MAC address. + * @returns {string} mac address + */ + get address() { + return macAddress(this.peripheral.address); + } + + /** + * Handle data received from the bike. + * @param {buffer} data - raw data encoded in proprietary format. + * @emits BikeClient#data + * @emits BikeClient#stats + * @private + */ + onReceive(data) { + /** + * Data event. + * + * @event BikeClient#data + * @type {buffer} + */ + this.emit('data', data); + + try { + const {type, payload} = parse(data); + if (type === 'cadence' || type === 'resistance') { + this.stats = { + ...this.stats, + ...payload, + }; + this.stats.power = calculatePower(this.stats.cadence, this.stats.resistance); + this.emit('stats', this.stats); + } + } catch (e) { + if (!/unable to parse message/.test(e)) { + throw e; + } + } + } + + /** + * Send data to the bike. + * @param {buffer} data - raw data encoded in proprietary format. + */ + async send(data) { + if (this.state !== 'connected') { + throw new Error('Not connected'); + } + await this.rx.writeAsync(data); + } + + /** + * Disconnect from the bike. + */ + async disconnect() { + if (this.state !== 'disconnected') return; + await this.peripheral.disconnectAsync(); + } + + /** + * Handle bike disconnection. + * @emits BikeClient#disconnect + * @private + */ + onDisconnect() { + this.state = 'disconnected'; + this.peripheral.off('disconnect', this.onDisconnect); + + /** + * Disconnect event. + * @event BikeClient#disconnect + * @type {object} + * @property {string} address - mac address + */ + this.emit('disconnect', {address: this.peripheral.address}); + } +} + + +/** + * Parse Echelon protocol message. + * @param {buffer} data - raw data encoded in proprietary format + * @returns {object} message - parsed message + * @returns {string} message.type - message type + * @returns {object} message.payload - message payload + */ +export function parse(data) { + if (data.length >= 2) { + const pktType = data.readUInt8(STATS_PKT_IDX_TYPE); + switch (pktType) { + case STATS_PKT_TYPE_CADENCE: + const cadence = data.readUInt8(STATS_PKT_IDX_CADENCE); + return {type: 'cadence', payload: {cadence}}; + + case STATS_PKT_TYPE_RESISTANCE: + const resistance = data.readUInt8(STATS_PKT_IDX_RESISTANCE); + return {type: 'resistance', payload: {resistance}}; + } + } + throw new Error('unable to parse message'); +} + + +/** + * Calculate estimated power (watts) from cadence and resistance. + * @param {number} cadence - rpm + * @param {number} resistance - raw value from echelon data packet + * @returns {number} power - watts + */ +export function calculatePower(cadence, resistance) { + if (cadence === 0 || resistance === 0) return 0; + return Math.round(Math.pow(1.090112, resistance) * Math.pow(1.015343, cadence) * 7.228958); +} diff --git a/src/bikes/index.js b/src/bikes/index.js index 81bf7657..ba8d1e9c 100644 --- a/src/bikes/index.js +++ b/src/bikes/index.js @@ -2,6 +2,7 @@ import {FlywheelBikeClient} from './flywheel'; import {PelotonBikeClient} from './peloton'; import {Ic4BikeClient} from './ic4'; import {KeiserBikeClient} from './keiser'; +import {EchelonBikeClient} from './echelon'; import {BotBikeClient} from './bot'; import {macAddress} from '../util/mac-address'; import fs from 'fs'; @@ -11,6 +12,7 @@ const factories = { 'peloton': createPelotonBikeClient, 'ic4': createIc4BikeClient, 'keiser': createKeiserBikeClient, + 'echelon': createEchelonBikeClient, 'bot': createBotBikeClient, 'autodetect': autodetectBikeClient, }; @@ -53,6 +55,10 @@ function createKeiserBikeClient(options, noble) { return new KeiserBikeClient(noble); } +function createEchelonBikeClient(options, noble) { + return new EchelonBikeClient(noble); +} + function createBotBikeClient(options, noble) { const args = [ options.botPower, diff --git a/src/test/bikes/echelon.js b/src/test/bikes/echelon.js new file mode 100644 index 00000000..ec96233f --- /dev/null +++ b/src/test/bikes/echelon.js @@ -0,0 +1,26 @@ +import test from 'tape'; +import {parse, calculatePower} from '../../bikes/echelon'; + +test('parse() parses Echelon cadence', t => { + const buf = Buffer.from('f0d109001300000011003e002c', 'hex'); + const {type, payload: {cadence}} = parse(buf); + t.equal(type, 'cadence', 'message type'); + t.equal(cadence, 62, 'cadence'); + t.end(); +}); + +test('parse() parses Echelon resistance', t => { + const buf = Buffer.from('f0d20111d4', 'hex'); + const {type, payload: {resistance}} = parse(buf); + t.equal(type, 'resistance', 'message type'); + t.equal(resistance, 17, 'resistance'); + t.end(); +}); + +test('calculatePower() calculates Echelon power', t => { + const cadence = 62; + const resistance = 17; + const power = calculatePower(cadence, resistance); + t.equal(power, 81, 'power (watts)'); + t.end(); +}); From 1df338752c0a6d468c608f7c2c892eab2fde287a Mon Sep 17 00:00:00 2001 From: ptx2 Date: Mon, 22 Mar 2021 20:08:07 -0400 Subject: [PATCH 2/3] Change write/notify order. --- src/bikes/echelon.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/bikes/echelon.js b/src/bikes/echelon.js index c9089a73..f4c2f243 100644 --- a/src/bikes/echelon.js +++ b/src/bikes/echelon.js @@ -72,13 +72,13 @@ export class EchelonBikeClient extends EventEmitter { power: 0, }; - // start streaming stats - await this.rx.writeAsync(ENABLE_NOTIFICATIONS_PKT, true); - // subscribe to receive data this.tx.on('read', this.onReceive); await this.tx.subscribeAsync(); + // start streaming stats + await this.rx.writeAsync(ENABLE_NOTIFICATIONS_PKT, false); + this.state = 'connected'; } From da22aa572c3f437cfef11717ed9dd131be4a3228 Mon Sep 17 00:00:00 2001 From: ptx2 Date: Mon, 29 Mar 2021 08:51:06 -0400 Subject: [PATCH 3/3] Add two Echelon tweaks to test. --- src/bikes/echelon2.js | 201 +++++++++++++++++++++++++++++++++++++++ src/bikes/echelon3.js | 213 ++++++++++++++++++++++++++++++++++++++++++ src/bikes/index.js | 12 +++ 3 files changed, 426 insertions(+) create mode 100644 src/bikes/echelon2.js create mode 100644 src/bikes/echelon3.js diff --git a/src/bikes/echelon2.js b/src/bikes/echelon2.js new file mode 100644 index 00000000..e7421fa1 --- /dev/null +++ b/src/bikes/echelon2.js @@ -0,0 +1,201 @@ +import util from 'util'; +import {EventEmitter} from 'events'; +import {execFile} from 'child_process'; +const execFileAsync = util.promisify(execFile); +import {scan} from '../util/ble-scan'; +import {macAddress} from '../util/mac-address'; + +// GATT service/characteristic UUIDs +const ADVERTISED_SERVICE_UUID = '0bf669f045f211e795980800200c9a66'; +const UART_SERVICE_UUID = '0bf669f145f211e795980800200c9a66'; +const UART_RX_UUID = '0bf669f245f211e795980800200c9a66'; +const UART_TX_UUID = '0bf669f445f211e795980800200c9a66'; + +// stats packet parsing +const STATS_PKT_TYPE_CADENCE = 0xD1; +const STATS_PKT_TYPE_RESISTANCE = 0xD2; +const STATS_PKT_IDX_TYPE = 1; // 8-bit packet type data offset within packet +const STATS_PKT_IDX_CADENCE = 10; // 8-bit cadence data offset within packet +const STATS_PKT_IDX_RESISTANCE = 3; // 8-bit resistance data offset within packet + +const ENABLE_NOTIFICATIONS_PKT = Buffer.from([0xF0, 0xB0, 0x01, 0x01, 0xA2]); + +const debuglog = util.debuglog('gymnasticon:bikes:echelon'); + +/** + * Handles communication with Echelon indoor training bike using the bike's + * proprietary protocol. + */ +export class EchelonBikeClient2 extends EventEmitter { + /** + * Create an EchelonBikeClient instance. + * @param {Noble} noble - a Noble instance. + * @param {object} filters - filters to specify bike when more than one is present + * @param {string} filters.address - mac address + * @param {string} filters.name - device name + */ + constructor(noble, filters) { + super(); + this.noble = noble; + this.filters = filters; + this.state = 'disconnected'; + this.onReceive = this.onReceive.bind(this); + this.onDisconnect = this.onDisconnect.bind(this); + } + + /** + * Establish a connection to the bike's Bluetooth LE GATT Service. + */ + async connect() { + if (this.state === 'connected') { + throw new Error('Already connected'); + } + + // scan + this.peripheral = await scan(this.noble, [ADVERTISED_SERVICE_UUID], this.filters); + + // connect + this.peripheral.on('disconnect', this.onDisconnect); + await this.peripheral.connectAsync(); + + // discover services/characteristics + const {characteristics} = await this.peripheral.discoverSomeServicesAndCharacteristicsAsync( + [UART_SERVICE_UUID], [UART_TX_UUID, UART_RX_UUID]); + const [tx, rx] = characteristics; + this.tx = tx; + this.rx = rx; + + // initial stats + this.stats = { + cadence: 0, + resistance: 0, + power: 0, + }; + + // subscribe to receive data + this.tx.on('read', this.onReceive); + //await this.tx.subscribeAsync(); + await this.tx.discoverDescriptorsAsync(); + const cccd = this.tx.descriptors.find(d => d.uuid == '2902'); + await cccd.writeValueAsync(Buffer.from([1,0])); // 0100 <- enable notifications + + // start streaming stats + await this.rx.writeAsync(ENABLE_NOTIFICATIONS_PKT, false); + + this.state = 'connected'; + } + + /** + * Get the bike's MAC address. + * @returns {string} mac address + */ + get address() { + return macAddress(this.peripheral.address); + } + + /** + * Handle data received from the bike. + * @param {buffer} data - raw data encoded in proprietary format. + * @emits BikeClient#data + * @emits BikeClient#stats + * @private + */ + onReceive(data) { + /** + * Data event. + * + * @event BikeClient#data + * @type {buffer} + */ + this.emit('data', data); + + try { + const {type, payload} = parse(data); + if (type === 'cadence' || type === 'resistance') { + this.stats = { + ...this.stats, + ...payload, + }; + this.stats.power = calculatePower(this.stats.cadence, this.stats.resistance); + this.emit('stats', this.stats); + } + } catch (e) { + if (!/unable to parse message/.test(e)) { + throw e; + } + } + } + + /** + * Send data to the bike. + * @param {buffer} data - raw data encoded in proprietary format. + */ + async send(data) { + if (this.state !== 'connected') { + throw new Error('Not connected'); + } + await this.rx.writeAsync(data); + } + + /** + * Disconnect from the bike. + */ + async disconnect() { + if (this.state !== 'disconnected') return; + await this.peripheral.disconnectAsync(); + } + + /** + * Handle bike disconnection. + * @emits BikeClient#disconnect + * @private + */ + onDisconnect() { + this.state = 'disconnected'; + this.peripheral.off('disconnect', this.onDisconnect); + + /** + * Disconnect event. + * @event BikeClient#disconnect + * @type {object} + * @property {string} address - mac address + */ + this.emit('disconnect', {address: this.peripheral.address}); + } +} + + +/** + * Parse Echelon protocol message. + * @param {buffer} data - raw data encoded in proprietary format + * @returns {object} message - parsed message + * @returns {string} message.type - message type + * @returns {object} message.payload - message payload + */ +export function parse(data) { + if (data.length >= 2) { + const pktType = data.readUInt8(STATS_PKT_IDX_TYPE); + switch (pktType) { + case STATS_PKT_TYPE_CADENCE: + const cadence = data.readUInt8(STATS_PKT_IDX_CADENCE); + return {type: 'cadence', payload: {cadence}}; + + case STATS_PKT_TYPE_RESISTANCE: + const resistance = data.readUInt8(STATS_PKT_IDX_RESISTANCE); + return {type: 'resistance', payload: {resistance}}; + } + } + throw new Error('unable to parse message'); +} + + +/** + * Calculate estimated power (watts) from cadence and resistance. + * @param {number} cadence - rpm + * @param {number} resistance - raw value from echelon data packet + * @returns {number} power - watts + */ +export function calculatePower(cadence, resistance) { + if (cadence === 0 || resistance === 0) return 0; + return Math.round(Math.pow(1.090112, resistance) * Math.pow(1.015343, cadence) * 7.228958); +} diff --git a/src/bikes/echelon3.js b/src/bikes/echelon3.js new file mode 100644 index 00000000..31d02b01 --- /dev/null +++ b/src/bikes/echelon3.js @@ -0,0 +1,213 @@ +import util from 'util'; +import {EventEmitter} from 'events'; +import {execFile} from 'child_process'; +const execFileAsync = util.promisify(execFile); +import {scan} from '../util/ble-scan'; +import {macAddress} from '../util/mac-address'; + +// GATT service/characteristic UUIDs +const ADVERTISED_SERVICE_UUID = '0bf669f045f211e795980800200c9a66'; +const UART_SERVICE_UUID = '0bf669f145f211e795980800200c9a66'; +const UART_RX_UUID = '0bf669f245f211e795980800200c9a66'; +const UART_TX_UUID = '0bf669f445f211e795980800200c9a66'; + +// stats packet parsing +const STATS_PKT_TYPE_CADENCE = 0xD1; +const STATS_PKT_TYPE_RESISTANCE = 0xD2; +const STATS_PKT_IDX_TYPE = 1; // 8-bit packet type data offset within packet +const STATS_PKT_IDX_CADENCE = 10; // 8-bit cadence data offset within packet +const STATS_PKT_IDX_RESISTANCE = 3; // 8-bit resistance data offset within packet + +const ENABLE_NOTIFICATIONS_PKT = Buffer.from([0xF0, 0xB0, 0x01, 0x01, 0xA2]); + +const debuglog = util.debuglog('gymnasticon:bikes:echelon'); + +/** + * Handles communication with Echelon indoor training bike using the bike's + * proprietary protocol. + */ +export class EchelonBikeClient3 extends EventEmitter { + /** + * Create an EchelonBikeClient instance. + * @param {Noble} noble - a Noble instance. + * @param {object} filters - filters to specify bike when more than one is present + * @param {string} filters.address - mac address + * @param {string} filters.name - device name + */ + constructor(noble, filters) { + super(); + this.noble = noble; + this.filters = filters; + this.state = 'disconnected'; + this.onReceive = this.onReceive.bind(this); + this.onDisconnect = this.onDisconnect.bind(this); + } + + /** + * Establish a connection to the bike's Bluetooth LE GATT Service. + */ + async connect() { + if (this.state === 'connected') { + throw new Error('Already connected'); + } + + // scan + this.peripheral = await scan(this.noble, [ADVERTISED_SERVICE_UUID], this.filters); + + // connect + this.peripheral.on('disconnect', this.onDisconnect); + await this.peripheral.connectAsync(); + + // discover services/characteristics + const {characteristics} = await this.peripheral.discoverSomeServicesAndCharacteristicsAsync( + [UART_SERVICE_UUID], [UART_TX_UUID, UART_RX_UUID]); + const [tx, rx] = characteristics; + this.tx = tx; + this.rx = rx; + + // initial stats + this.stats = { + cadence: 0, + resistance: 0, + power: 0, + }; + + // subscribe to receive data + this.tx.on('read', this.onReceive); + //await this.tx.subscribeAsync(); + // some embedded BLE stacks don't like noble-hci's use of + // Read By Type in subscribeAsync() so use Find Info instead + await this.tx.discoverDescriptorsAsync(); + const cccd = this.tx.descriptors.find(d => d.uuid == '2902'); + await cccd.writeValueAsync(Buffer.from([1,0])); // 0100 <- enable notifications + + // other init packets (unsure of meaning?) + const INITDATA1_PKT = Buffer.from([0xf0, 0xa1, 0x00, 0x91]); + const INITDATA2_PKT = Buffer.from([0xf0, 0xa3, 0x00, 0x93]); + await this.rx.writeAsync(INITDATA1_PKT, false); + await this.rx.writeAsync(INITDATA1_PKT, false); + await this.rx.writeAsync(INITDATA1_PKT, false); + await this.rx.writeAsync(INITDATA1_PKT, false); + await this.rx.writeAsync(INITDATA2_PKT, false); + await this.rx.writeAsync(INITDATA1_PKT, false); + + // start streaming stats + await this.rx.writeAsync(ENABLE_NOTIFICATIONS_PKT, false); + + this.state = 'connected'; + } + + /** + * Get the bike's MAC address. + * @returns {string} mac address + */ + get address() { + return macAddress(this.peripheral.address); + } + + /** + * Handle data received from the bike. + * @param {buffer} data - raw data encoded in proprietary format. + * @emits BikeClient#data + * @emits BikeClient#stats + * @private + */ + onReceive(data) { + /** + * Data event. + * + * @event BikeClient#data + * @type {buffer} + */ + this.emit('data', data); + + try { + const {type, payload} = parse(data); + if (type === 'cadence' || type === 'resistance') { + this.stats = { + ...this.stats, + ...payload, + }; + this.stats.power = calculatePower(this.stats.cadence, this.stats.resistance); + this.emit('stats', this.stats); + } + } catch (e) { + if (!/unable to parse message/.test(e)) { + throw e; + } + } + } + + /** + * Send data to the bike. + * @param {buffer} data - raw data encoded in proprietary format. + */ + async send(data) { + if (this.state !== 'connected') { + throw new Error('Not connected'); + } + await this.rx.writeAsync(data); + } + + /** + * Disconnect from the bike. + */ + async disconnect() { + if (this.state !== 'disconnected') return; + await this.peripheral.disconnectAsync(); + } + + /** + * Handle bike disconnection. + * @emits BikeClient#disconnect + * @private + */ + onDisconnect() { + this.state = 'disconnected'; + this.peripheral.off('disconnect', this.onDisconnect); + + /** + * Disconnect event. + * @event BikeClient#disconnect + * @type {object} + * @property {string} address - mac address + */ + this.emit('disconnect', {address: this.peripheral.address}); + } +} + + +/** + * Parse Echelon protocol message. + * @param {buffer} data - raw data encoded in proprietary format + * @returns {object} message - parsed message + * @returns {string} message.type - message type + * @returns {object} message.payload - message payload + */ +export function parse(data) { + if (data.length >= 2) { + const pktType = data.readUInt8(STATS_PKT_IDX_TYPE); + switch (pktType) { + case STATS_PKT_TYPE_CADENCE: + const cadence = data.readUInt8(STATS_PKT_IDX_CADENCE); + return {type: 'cadence', payload: {cadence}}; + + case STATS_PKT_TYPE_RESISTANCE: + const resistance = data.readUInt8(STATS_PKT_IDX_RESISTANCE); + return {type: 'resistance', payload: {resistance}}; + } + } + throw new Error('unable to parse message'); +} + + +/** + * Calculate estimated power (watts) from cadence and resistance. + * @param {number} cadence - rpm + * @param {number} resistance - raw value from echelon data packet + * @returns {number} power - watts + */ +export function calculatePower(cadence, resistance) { + if (cadence === 0 || resistance === 0) return 0; + return Math.round(Math.pow(1.090112, resistance) * Math.pow(1.015343, cadence) * 7.228958); +} diff --git a/src/bikes/index.js b/src/bikes/index.js index ba8d1e9c..8923e69a 100644 --- a/src/bikes/index.js +++ b/src/bikes/index.js @@ -3,6 +3,8 @@ import {PelotonBikeClient} from './peloton'; import {Ic4BikeClient} from './ic4'; import {KeiserBikeClient} from './keiser'; import {EchelonBikeClient} from './echelon'; +import {EchelonBikeClient2} from './echelon2'; +import {EchelonBikeClient3} from './echelon3'; import {BotBikeClient} from './bot'; import {macAddress} from '../util/mac-address'; import fs from 'fs'; @@ -13,6 +15,8 @@ const factories = { 'ic4': createIc4BikeClient, 'keiser': createKeiserBikeClient, 'echelon': createEchelonBikeClient, + 'echelon2': createEchelonBikeClient2, + 'echelon3': createEchelonBikeClient3, 'bot': createBotBikeClient, 'autodetect': autodetectBikeClient, }; @@ -59,6 +63,14 @@ function createEchelonBikeClient(options, noble) { return new EchelonBikeClient(noble); } +function createEchelonBikeClient2(options, noble) { + return new EchelonBikeClient2(noble); +} + +function createEchelonBikeClient3(options, noble) { + return new EchelonBikeClient3(noble); +} + function createBotBikeClient(options, noble) { const args = [ options.botPower,