From 40cd8ce1c25da335a286b4f24dfcb0b9c252952d Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Fri, 12 Feb 2021 15:13:22 -0800 Subject: [PATCH] Peloton Direct Polling support (#43) Co-authored-by: Matt Stevens --- src/app/app.js | 2 +- src/bikes/peloton.js | 44 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/app/app.js b/src/app/app.js index 010982c1..6cfa0bef 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -4,9 +4,9 @@ import bleno from '@abandonware/bleno'; import {once} from 'events'; -import {createBikeClient, getBikeTypes} from '../bikes'; import {GymnasticonServer} from '../servers/ble'; import {AntServer} from '../servers/ant'; +import {createBikeClient, getBikeTypes} from '../bikes'; import {Simulation} from './simulation'; import {Timer} from '../util/timer'; import {Logger} from '../util/logger'; diff --git a/src/bikes/peloton.js b/src/bikes/peloton.js index 5b96d14d..38da884c 100644 --- a/src/bikes/peloton.js +++ b/src/bikes/peloton.js @@ -5,7 +5,20 @@ import util from 'util'; const SerialPort = require('serialport') const Delimiter = require('@serialport/parser-delimiter') +/** + * Cadence and Power are both direct values returned by the bike. + * Resistance, on the otherhand, is a raw value returned from the bike to the + * Tablet, and doesn't necessarily add value for our use case. However, we are + * choosing to poll for it to allow for a usecase where Gymnasticon provides + * the polling, with the bike Tx split to the Tablet as well. + */ +const MEASUREMENTS_HEX_ENUM = { + CADENCE: Buffer.from("f6f54136", 'hex'), + POWER: Buffer.from("f6f54439", 'hex'), + RESISTANCE: Buffer.from("f6f54a3f", 'hex') +} const PACKET_DELIMITER = Buffer.from('f6', 'hex'); +const POLL_RATE = 100; const STATS_TIMEOUT = 1.0; const debuglog = util.debuglog('gymnasticon:bikes:peloton'); @@ -23,6 +36,7 @@ export class PelotonBikeClient extends EventEmitter { this.onStatsUpdate = this.onStatsUpdate.bind(this); this.onSerialMessage = this.onSerialMessage.bind(this); this.onSerialClose = this.onSerialClose.bind(this); + this.pollMetric = this.pollMetric.bind(this); // initial stats this.power = 0; @@ -31,6 +45,10 @@ export class PelotonBikeClient extends EventEmitter { // reset stats to 0 when the user leaves the ride screen or turns the bike off this.statsTimeout = new Timer(STATS_TIMEOUT, {repeats: false}); this.statsTimeout.on('timeout', this.onStatsTimeout.bind(this)); + + // Let's collect interval handles for cancellation + this.intervalHandles = new Map(); + this.nextMetric = 0; } async connect() { @@ -47,6 +65,9 @@ export class PelotonBikeClient extends EventEmitter { this._parser.on('data', this.onSerialMessage); this.state = 'connected'; + + // Begin sending polling requests to the Peloton bike + this.intervalHandles['poll'] = setInterval(this.pollMetric, POLL_RATE, this._port); tracelog("Serial Connected"); } @@ -69,16 +90,18 @@ export class PelotonBikeClient extends EventEmitter { onSerialMessage(data) { tracelog("RECV: ", data); switch(data[1]) { - case 65: + case 65: // Cadence this.cadence = decodePeloton(data, data[2], false); this.onStatsUpdate(); this.statsTimeout.reset(); return; - case 68: + case 68: // Power this.power = decodePeloton(data, data[2], true); this.onStatsUpdate(); this.statsTimeout.reset(); return; + case 74: // Resistance + return; // While we can parse this, we don't do anything with it. default: debuglog("Unrecognized Message Type: ", data[1]); return; @@ -87,6 +110,7 @@ export class PelotonBikeClient extends EventEmitter { onSerialClose() { this.emit('disconnect', {address: this.address}); + clearInterval(this.intervalHandles['poll']); tracelog("Serial Closed"); } @@ -96,6 +120,22 @@ export class PelotonBikeClient extends EventEmitter { tracelog("StatsTimeout exceeded"); this.onStatsUpdate(); } + + pollMetric(port) { + let metric = Object.keys(MEASUREMENTS_HEX_ENUM)[this.nextMetric]; + + port.write(MEASUREMENTS_HEX_ENUM[metric], function(err) { + if (err) { throw new Error(`Error requesting ${metric}: ${err.message}`); } + }) + port.drain(); + + if (this.nextMetric === Object.keys(MEASUREMENTS_HEX_ENUM).length -1) { + this.nextMetric = 0; + } else { + this.nextMetric++; + } + } + } export function decodePeloton(bufferArray, byteLength, isPower) {