Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add experimental Echelon support. #76

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions src/bikes/echelon.js
Original file line number Diff line number Diff line change
@@ -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,
};

// 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';
}

/**
* 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);
}
Loading