From 8897c61b0928f155dc1f9e993bf9738567225659 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Sun, 17 Oct 2021 11:27:12 -0700 Subject: [PATCH 01/29] Support for Speed data via ANT+ / BLE Support for Speed data via ANT+ / BLE --- src/app/app.js | 60 ++++++--- src/app/cli-options.js | 5 + src/app/{simulation.js => crankSimulation.js} | 2 +- src/app/wheelSimulation.js | 71 +++++++++++ src/bikes/bot.js | 13 +- src/bikes/flywheel.js | 5 +- src/bikes/ic4.js | 6 +- src/bikes/index.js | 1 + src/bikes/keiser.js | 4 +- src/bikes/peloton.js | 6 +- src/servers/ant/{ => bike-power}/index.js | 10 +- src/servers/ant/bike-speed/index.js | 116 ++++++++++++++++++ src/servers/ble/index.js | 4 + .../characteristics/cycling-power-feature.js | 2 +- .../cycling-power-measurement.js | 6 +- .../ble/services/cycling-power/index.js | 3 + .../characteristics/csc-feature.js | 2 +- .../characteristics/csc-measurement.js | 38 ++++-- .../cycling-speed-and-cadence/index.js | 3 + src/test/app/simulation.js | 6 +- 20 files changed, 310 insertions(+), 53 deletions(-) rename src/app/{simulation.js => crankSimulation.js} (96%) create mode 100644 src/app/wheelSimulation.js rename src/servers/ant/{ => bike-power}/index.js (89%) create mode 100644 src/servers/ant/bike-speed/index.js diff --git a/src/app/app.js b/src/app/app.js index 4bfb279c..36147895 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -4,9 +4,11 @@ import bleno from '@abandonware/bleno'; import {once} from 'events'; import {GymnasticonServer} from '../servers/ble'; -import {AntServer} from '../servers/ant'; +import {AntBikePower} from '../servers/ant/bike-power'; +import {AntBikeSpeed} from '../servers/ant/bike-speed'; import {createBikeClient, getBikeTypes} from '../bikes'; -import {Simulation} from './simulation'; +import {CrankSimulation} from './crankSimulation'; +import {WheelSimulation} from './wheelSimulation'; import {Timer} from '../util/timer'; import {Logger} from '../util/logger'; import {createAntStick} from '../util/ant-stick'; @@ -32,6 +34,7 @@ export const defaults = { // test bike options botPower: 0, // power botCadence: 0, // cadence + botSpeed: 0, // speed botHost: '0.0.0.0', // listen for udp message to update cadence/power botPort: 3000, @@ -63,7 +66,9 @@ export class App { const opts = {...defaults, ...options}; this.power = 0; + this.cadence = 0; this.crank = {revolutions: 0, timestamp: -Infinity}; + this.wheel = {revolutions: 0, timestamp: -Infinity}; process.env['NOBLE_HCI_DEVICE_ID'] = opts.bikeAdapter; process.env['BLENO_HCI_DEVICE_ID'] = opts.serverAdapter; @@ -73,11 +78,13 @@ export class App { this.opts = opts; this.logger = new Logger(); - this.simulation = new Simulation(); + this.crankSimulation = new CrankSimulation(); + this.wheelSimulation = new WheelSimulation(); this.server = new GymnasticonServer(bleno, opts.serverName); this.antStick = createAntStick(opts); - this.antServer = new AntServer(this.antStick, {deviceId: opts.antDeviceId}); + this.antBikePower = new AntBikePower(this.antStick, {deviceId: opts.antDeviceId}); + this.antBikeSpeed = new AntBikeSpeed(this.antStick, {deviceId: opts.antDeviceId}); this.antStick.on('startup', this.onAntStickStartup.bind(this)); this.pingInterval = new Timer(opts.serverPingInterval); @@ -89,7 +96,8 @@ export class App { this.pingInterval.on('timeout', this.onPingInterval.bind(this)); this.statsTimeout.on('timeout', this.onBikeStatsTimeout.bind(this)); this.connectTimeout.on('timeout', this.onBikeConnectTimeout.bind(this)); - this.simulation.on('pedal', this.onPedalStroke.bind(this)); + this.crankSimulation.on('pedal', this.onPedalStroke.bind(this)); + this.wheelSimulation.on('wheel', this.onWheelRotation.bind(this)); this.onSigInt = this.onSigInt.bind(this); this.onExit = this.onExit.bind(this); @@ -126,26 +134,40 @@ export class App { this.pingInterval.reset(); this.crank.timestamp = timestamp; this.crank.revolutions++; - let {power, crank} = this; + this.cadence = this.crankSimulation.cadence; + let {power, crank, wheel, cadence} = this; this.logger.log(`pedal stroke [timestamp=${timestamp} revolutions=${crank.revolutions} power=${power}W]`); - this.server.updateMeasurement({ power, crank }); + this.antBikePower.updateMeasurement({ power, cadence }); + //this.server.updateMeasurement({ power, crank, wheel }); + } + + onWheelRotation(timestamp) { + this.pingInterval.reset(); + this.wheel.timestamp = timestamp; + this.wheel.revolutions++; + let {power, crank, wheel, cadence} = this; + this.logger.log(`wheel rotation [timestamp=${timestamp} revolutions=${wheel.revolutions} power=${power}W]`); + this.antBikeSpeed.updateMeasurement({ wheel }); + this.server.updateMeasurement({ power, crank, wheel }); } onPingInterval() { debuglog(`pinging app since no stats or pedal strokes for ${this.pingInterval.interval}s`); - let {power, crank} = this; - this.server.updateMeasurement({ power, crank }); + let {power, crank, wheel} = this; + this.server.updateMeasurement({ power, crank, wheel }); } - onBikeStats({ power, cadence }) { + onBikeStats({ power, cadence, speed }) { power = power > 0 ? Math.max(0, Math.round(power * this.powerScale + this.powerOffset)) : 0; - this.logger.log(`received stats from bike [power=${power}W cadence=${cadence}rpm]`); + this.logger.log(`received stats from bike [power=${power}W cadence=${cadence}rpm speed=${speed}km/h]`); this.statsTimeout.reset(); this.power = power; - this.simulation.cadence = cadence; - let {crank} = this; - this.server.updateMeasurement({ power, crank }); - this.antServer.updateMeasurement({ power, cadence }); + this.crankSimulation.cadence = cadence; + this.wheelSimulation.speed = speed; + let {crank, wheel} = this; + this.server.updateMeasurement({ power, crank, wheel }); + //this.antBikePower.updateMeasurement({ power, cadence }); + //this.antBikeSpeed.updateMeasurement({ wheel }); } onBikeStatsTimeout() { @@ -175,12 +197,14 @@ export class App { onAntStickStartup() { this.logger.log('ANT+ stick opened'); - this.antServer.start(); + this.antBikePower.start(); + this.antBikeSpeed.start(); } stopAnt() { this.logger.log('stopping ANT+ server'); - this.antServer.stop(); + this.antBikePower.stop(); + this.antBikeSpeed.stop(); } onSigInt() { @@ -191,7 +215,7 @@ export class App { } onExit() { - if (this.antServer.isRunning) { + if (this.antBikePower.isRunning||this.antBikeSpeed.isRunning) { this.stopAnt(); } } diff --git a/src/app/cli-options.js b/src/app/cli-options.js index 791c9ee9..19c4bc4e 100644 --- a/src/app/cli-options.js +++ b/src/app/cli-options.js @@ -49,6 +49,11 @@ export const options = { type: 'number', default: defaults.botCadence, }, + 'bot-speed': { + describe: ' initial bot speed', + type: 'number', + default: defaults.botSpeed, + }, 'bot-host': { describe: ' for power/cadence control over udp', type: 'string', diff --git a/src/app/simulation.js b/src/app/crankSimulation.js similarity index 96% rename from src/app/simulation.js rename to src/app/crankSimulation.js index 843acdd9..da2aa5c4 100644 --- a/src/app/simulation.js +++ b/src/app/crankSimulation.js @@ -4,7 +4,7 @@ import {EventEmitter} from 'events'; * Emit pedal stroke events at a rate that matches the given target cadence. * The target cadence can be updated on-the-fly. */ -export class Simulation extends EventEmitter { +export class CrankSimulation extends EventEmitter { constructor() { super(); this._cadence = 0; diff --git a/src/app/wheelSimulation.js b/src/app/wheelSimulation.js new file mode 100644 index 00000000..b3776c81 --- /dev/null +++ b/src/app/wheelSimulation.js @@ -0,0 +1,71 @@ +import {EventEmitter} from 'events'; + +/** + * Emit wheel rotation events at a rate that matches the given target speed. + * The target speed can be updated on-the-fly. + */ + +const TIRE_CIRCUMFERENCE = 2.096; // in meter; Corresponds to 700x23C tire + +export class WheelSimulation extends EventEmitter { + constructor() { + super(); + this._speed = 0; + this._interval = Infinity; + this._lastWheelTime = -Infinity; + this._timeoutId = null; + } + + /** + * Set the target speed. + * @param {number} speed - the target cadence in kmh. + */ + set speed(x) { + this._speed = x; + this._interval = x > 0 ? ( ( 1000 * 18 * TIRE_CIRCUMFERENCE ) / ( 5 * this._speed) ) : Infinity; + if (this._timeoutId) { + clearTimeout(this._timeoutId); + this._timeoutId = null; + } + this.scheduleWheel(); + } + + /** + * Get the current target speed (km/h). + */ + get speed() { + return this._speed; + } + + /** + * Handle a wheel event. + * @emits Simulation#wheel + * @private + */ + onWheel(timestamp) { + this._lastWheelTime = Number.isFinite(timestamp) ? timestamp : Date.now(); + /** + * Wheel event. + * @event Simulation#wheel + * @type {number} timestamp - timestamp (ms) of this wheel event + */ + this.emit('wheel', this._lastWheelTime); + } + + /** + * Schedule the next wheel event according to the target speed. + * @private + */ + scheduleWheel() { + if (this._interval === Infinity) return; + + let now = Date.now(); + let timeSinceLast = now - this._lastWheelTime; + let timeUntilNext = Math.max(0, this._interval - timeSinceLast); + let nextWheelTime = now + timeUntilNext; + this._timeoutId = setTimeout(() => { + this.onWheel(nextWheelTime); + this.scheduleWheel(); + }, timeUntilNext); + } +} diff --git a/src/bikes/bot.js b/src/bikes/bot.js index b897f895..866ddb91 100644 --- a/src/bikes/bot.js +++ b/src/bikes/bot.js @@ -12,10 +12,11 @@ export class BotBikeClient extends EventEmitter { * Create a BotBikeClient instance. * @param {number} power - initial power (watts) * @param {number} cadence - initial cadence (rpm) + * @param {number} speed - initial speed (km/h) * @param {string} host - host to listen on for udp control interface * @param {number} port - port to listen on for udp control interface */ - constructor(power, cadence, host, port) { + constructor(power, cadence, speed, host, port) { super(); this.onStatsUpdate = this.onStatsUpdate.bind(this); @@ -24,6 +25,7 @@ export class BotBikeClient extends EventEmitter { this.power = power; this.cadence = cadence; + this.speed = speed; this._host = host; this._port = port; @@ -51,8 +53,8 @@ export class BotBikeClient extends EventEmitter { * @private */ onStatsUpdate() { - const {power, cadence} = this; - this.emit('stats', {power, cadence}); + const {power, cadence, speed} = this; + this.emit('stats', {power, cadence, speed}); } /** @@ -66,13 +68,16 @@ export class BotBikeClient extends EventEmitter { console.error(e); } console.log(j); - const {power, cadence} = j; + const {power, cadence, speed} = j; if (Number.isInteger(power) && power >= 0) { this.power = power; } if (Number.isInteger(cadence) && cadence >= 0) { this.cadence = cadence; } + if (!Number.isNaN(speed) && speed >= 0) { + this.speed = speed; + } } /** diff --git a/src/bikes/flywheel.js b/src/bikes/flywheel.js index 7b9b7fd3..1d92000a 100644 --- a/src/bikes/flywheel.js +++ b/src/bikes/flywheel.js @@ -17,6 +17,7 @@ const UART_TX_UUID = '6e400003b5a3f393e0a9e50e24dcca9e'; const STATS_PKT_MAGIC = Buffer.from([0xff, 0x1f, 0x0c]); // identifies a stats packet const STATS_PKT_IDX_POWER = 3; // 16-bit power (watts) data offset within packet const STATS_PKT_IDX_CADENCE = 12; // 8-bit cadence (rpm) data offset within packet +const STATS_PKT_IDX_SPEED = 13; // 16-bit speed (km/h x 10) data offset within packet // the bike's desired LE connection parameters (needed for BlueZ workaround) const LE_MIN_INTERVAL = 16*1.25; @@ -169,7 +170,9 @@ export function parse(data) { if (data.indexOf(STATS_PKT_MAGIC) === 0) { const power = data.readUInt16BE(STATS_PKT_IDX_POWER); const cadence = data.readUInt8(STATS_PKT_IDX_CADENCE); - return {type: 'stats', payload: {power, cadence}}; + const speed = data.readUInt16BE(STATS_PKT_IDX_SPEED)/10; + + return {type: 'stats', payload: {power, cadence,speed}}; } throw new Error('unable to parse message'); } diff --git a/src/bikes/ic4.js b/src/bikes/ic4.js index 3f36fce3..61bf04fa 100644 --- a/src/bikes/ic4.js +++ b/src/bikes/ic4.js @@ -15,6 +15,7 @@ const INDOOR_BIKE_DATA_UUID = '2ad2'; const IBD_VALUE_MAGIC = Buffer.from([0x44]); // identifies indoor bike data message const IBD_VALUE_IDX_POWER = 6; // 16-bit power (watts) data offset within packet const IBD_VALUE_IDX_CADENCE = 4; // 16-bit cadence (1/2 rpm) data offset within packet +const IBD_VALUE_IDX_SPEED = 2; // 16-bit cadence (1/100 km/h) data offset within packet const debuglog = require('debug')('gym:bikes:ic4'); @@ -115,8 +116,8 @@ export class Ic4BikeClient extends EventEmitter { this.emit('data', data); try { - const {power, cadence} = parse(data); - this.emit('stats', {power, cadence}); + const {power, cadence, speed} = parse(data); + this.emit('stats', {power, cadence, speed}); } catch (e) { if (!/unable to parse message/.test(e)) { throw e; @@ -178,6 +179,7 @@ export function parse(data) { if (data.indexOf(IBD_VALUE_MAGIC) === 0) { const power = data.readInt16LE(IBD_VALUE_IDX_POWER); const cadence = Math.round(data.readUInt16LE(IBD_VALUE_IDX_CADENCE) / 2); + const speed = Math.round(data.readUInt16LE(IBD_VALUE_IDX_SPEED) / 100); return {power, cadence}; } throw new Error('unable to parse message'); diff --git a/src/bikes/index.js b/src/bikes/index.js index fb06f909..67ab8375 100644 --- a/src/bikes/index.js +++ b/src/bikes/index.js @@ -70,6 +70,7 @@ function createBotBikeClient(options, noble) { const args = [ options.botPower, options.botCadence, + options.botSpeed, options.botHost, options.botPort, ] diff --git a/src/bikes/keiser.js b/src/bikes/keiser.js index eda0dbcd..baf97b21 100644 --- a/src/bikes/keiser.js +++ b/src/bikes/keiser.js @@ -208,9 +208,9 @@ export function parse(data) { // Realtime data received const power = data.readUInt16LE(KEISER_VALUE_IDX_POWER); const cadence = Math.round(data.readUInt16LE(KEISER_VALUE_IDX_CADENCE) / 10); - return {type: 'stats', payload: {power, cadence}}; + const speed = 0; // Speed not supported by Keiser + return {type: 'stats', payload: {power, cadence, speed}}; } } throw new Error('unable to parse message'); } - diff --git a/src/bikes/peloton.js b/src/bikes/peloton.js index 72692ee0..a0f28dcd 100644 --- a/src/bikes/peloton.js +++ b/src/bikes/peloton.js @@ -41,6 +41,7 @@ export class PelotonBikeClient extends EventEmitter { // initial stats this.power = 0; this.cadence = 0; + this.speed = 0; // reset stats to 0 when the user leaves the ride screen or turns the bike off this.statsTimeout = new Timer(STATS_TIMEOUT, {repeats: false}); @@ -83,8 +84,8 @@ export class PelotonBikeClient extends EventEmitter { * @private */ onStatsUpdate() { - const {power, cadence} = this; - this.emit('stats', {power, cadence}); + const {power, cadence, speed} = this; + this.emit('stats', {power, cadence, speed}); } onSerialMessage(data) { @@ -117,6 +118,7 @@ export class PelotonBikeClient extends EventEmitter { onStatsTimeout() { this.power = 0; this.cadence = 0; + this.speed = 0; tracelog("StatsTimeout exceeded"); this.onStatsUpdate(); } diff --git a/src/servers/ant/index.js b/src/servers/ant/bike-power/index.js similarity index 89% rename from src/servers/ant/index.js rename to src/servers/ant/bike-power/index.js index 306d7c59..ccfd0ce4 100644 --- a/src/servers/ant/index.js +++ b/src/servers/ant/bike-power/index.js @@ -1,9 +1,9 @@ import Ant from 'gd-ant-plus'; -import {Timer} from '../../util/timer'; +import {Timer} from '../../../util/timer'; const debuglog = require('debug')('gym:servers:ant'); -const DEVICE_TYPE = 0x0b; // power meter +const DEVICE_TYPE = 0x0b; // Bike Power Sensors const DEVICE_NUMBER = 1; const PERIOD = 8182; // 8182/32768 ~4hz const RF_CHANNEL = 57; // 2457 MHz @@ -18,7 +18,7 @@ const defaults = { * Handles communication with apps (e.g. Zwift) using the ANT+ Bicycle Power * profile (instantaneous cadence and power). */ -export class AntServer { +export class AntBikePower { /** * Create an AntServer instance. * @param {Ant.USBDevice} antStick - ANT+ device instance @@ -49,7 +49,7 @@ export class AntServer { start() { const {stick, channel, deviceId} = this; const messages = [ - Ant.Messages.assignChannel(channel, 'transmit'), + Ant.Messages.assignChannel(channel, 'transmit_only'), Ant.Messages.setDevice(channel, deviceId, DEVICE_TYPE, DEVICE_NUMBER), Ant.Messages.setFrequency(channel, RF_CHANNEL), Ant.Messages.setPeriod(channel, PERIOD), @@ -110,7 +110,7 @@ export class AntServer { ...Ant.Messages.intToLEHexArray(power, 2), ]; const message = Ant.Messages.broadcastData(data); - debuglog(`ANT+ broadcast power=${power}W cadence=${cadence}rpm accumulatedPower=${this.accumulatedPower}W eventCount=${this.eventCount} message=${message.toString('hex')}`); + debuglog(`ANT+ broadcast power power=${power}W cadence=${cadence}rpm accumulatedPower=${this.accumulatedPower}W eventCount=${this.eventCount} message=${message.toString('hex')}`); stick.write(message); this.eventCount++; this.eventCount &= 0xff; diff --git a/src/servers/ant/bike-speed/index.js b/src/servers/ant/bike-speed/index.js new file mode 100644 index 00000000..6a90e619 --- /dev/null +++ b/src/servers/ant/bike-speed/index.js @@ -0,0 +1,116 @@ +import Ant from 'gd-ant-plus'; +import {Timer} from '../../../util/timer'; + +const debuglog = require('debug')('gym:servers:ant'); + +const WHEEL_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec + +const DEVICE_TYPE = 0x7b; // Bike Speed Sensors +const DEVICE_NUMBER = 2; +const PERIOD = 8118; // 8118/32768 ~4hz +const RF_CHANNEL = 57; // 2457 MHz +const BROADCAST_INTERVAL = PERIOD / 32768; // seconds + +const defaults = { + deviceId: 11234, + channel: 2, +} + +/** + * Handles communication with apps (e.g. Zwift) using the ANT+ Bicycle Power + * profile (instantaneous cadence and power). + */ +export class AntBikeSpeed { + /** + * Create an AntServer instance. + * @param {Ant.USBDevice} antStick - ANT+ device instance + * @param {object} options + * @param {number} options.channel - ANT+ channel + * @param {number} options.deviceId - ANT+ device id + */ + constructor(antStick, options = {}) { + const opts = {...defaults, ...options}; + this.stick = antStick; + this.deviceId = opts.deviceId + 1; + this.channel = opts.channel; + + this.wheelRevolutions = 0; + this.wheelTimestamp = 0; + + this.broadcastInterval = new Timer(BROADCAST_INTERVAL); + this.broadcastInterval.on('timeout', this.onBroadcastInterval.bind(this)); + + this._isRunning = false; + } + + /** + * Start the ANT+ server (setup channel and start broadcasting). + */ + start() { + const {stick, channel, deviceId} = this; + console.log("max channels: ", stick.maxChannels); + const messages = [ + Ant.Messages.assignChannel(channel, 'transmit_only'), + Ant.Messages.setDevice(channel, deviceId, DEVICE_TYPE, DEVICE_NUMBER), + Ant.Messages.setFrequency(channel, RF_CHANNEL), + Ant.Messages.setPeriod(channel, PERIOD), + Ant.Messages.openChannel(channel), + ]; + debuglog(`ANT+ server start [deviceId=${deviceId} channel=${channel}]`); + for (let m of messages) { + stick.write(m); + } + this.broadcastInterval.reset(); + this._isRunning = true; + } + + get isRunning() { + return this._isRunning; + } + + /** + * Stop the ANT+ server (stop broadcasting and unassign channel). + */ + stop() { + const {stick, channel} = this; + this.broadcastInterval.cancel(); + const messages = [ + Ant.Messages.closeChannel(channel), + Ant.Messages.unassignChannel(channel), + ]; + for (let m of messages) { + stick.write(m); + } + } + + /** + * Update instantaneous power and cadence. + * @param {object} measurement + * @param {object} measurement.wheel - last wheel event. + * @param {number} measurement.wheel.revolutions - revolution count at last wheel event. + * @param {number} measurement.wheel.timestamp - timestamp at last wheel event. + */ + updateMeasurement({ wheel }) { + this.wheelRevolutions = wheel.revolutions; + this.wheelTimestamp = Math.round(wheel.timestamp * WHEEL_TIMESTAMP_SCALE) & 0xffff; + } + + /** + * Broadcast instantaneous power and cadence. + */ + onBroadcastInterval() { + const {stick, channel} = this; + const data = [ + channel, + 0x0, + 0x0, + 0x0, + 0x0, + ...Ant.Messages.intToLEHexArray(this.wheelTimestamp, 2), // Event Time + ...Ant.Messages.intToLEHexArray(this.wheelRevolutions, 2), // Revolution Count + ]; + const message = Ant.Messages.broadcastData(data); + debuglog(`ANT+ broadcast speed revolutions=${this.wheelRevolutions} timestamp=${this.wheelTimestamp} message=${message.toString('hex')}`); + stick.write(message); + } +} diff --git a/src/servers/ble/index.js b/src/servers/ble/index.js index 262f6dfc..ec5d08c0 100644 --- a/src/servers/ble/index.js +++ b/src/servers/ble/index.js @@ -27,6 +27,10 @@ export class GymnasticonServer extends BleServer { * @param {object} [measurement.crank] - last crank event. * @param {number} measurement.crank.revolutions - revolution count at last crank event. * @param {number} measurement.crank.timestamp - timestamp at last crank event. + * @param {object} [measurement.wheel] - last wheel event. + * @param {number} measurement.wheel.revolutions - revolution count at last wheel event. + * @param {number} measurement.wheel.timestamp - timestamp at last wheel event. + */ updateMeasurement(measurement) { for (let s of this.services) { diff --git a/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js b/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js index 3678c9c6..65a5538b 100644 --- a/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js +++ b/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js @@ -14,7 +14,7 @@ export class CyclingPowerFeatureCharacteristic extends Characteristic { value: 'Cycling Power Feature' }) ], - value: Buffer.from([8,0,0,0]) // crank revolution data + value: Buffer.from([12,0,0,0]) // crank revolution data }) } } diff --git a/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js index 4e4b85f8..87f05955 100644 --- a/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js +++ b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js @@ -1,8 +1,11 @@ import {Characteristic, Descriptor} from '@abandonware/bleno'; +const debuglog = require('debug')('gym:servers:ble'); + const FLAG_HASCRANKDATA = (1<<5); const CRANK_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec + /** * Bluetooth LE GATT Cycling Power Measurement Characteristic implementation. */ @@ -34,7 +37,6 @@ export class CyclingPowerMeasurementCharacteristic extends Characteristic { const value = Buffer.alloc(8); value.writeInt16LE(power, 2); - // include crank data if provided if (crank) { const revolutions16bit = crank.revolutions & 0xffff; const timestamp16bit = Math.round(crank.timestamp * CRANK_TIMESTAMP_SCALE) & 0xffff; @@ -44,7 +46,7 @@ export class CyclingPowerMeasurementCharacteristic extends Characteristic { } value.writeUInt16LE(flags, 0); - + debuglog(`BLE broadcast PWR power=${power} message=${value.toString('hex')}`); if (this.updateValueCallback) { this.updateValueCallback(value) } diff --git a/src/servers/ble/services/cycling-power/index.js b/src/servers/ble/services/cycling-power/index.js index 04e0e027..50137d23 100644 --- a/src/servers/ble/services/cycling-power/index.js +++ b/src/servers/ble/services/cycling-power/index.js @@ -28,6 +28,9 @@ export class CyclingPowerService extends PrimaryService { * @param {object} [measurement.crank] - last crank event. * @param {number} measurement.crank.revolutions - revolution count at last crank event. * @param {number} measurement.crank.timestamp - timestamp at last crank event. + * @param {object} [measurement.wheel] - last wheel event. + * @param {number} measurement.wheel.revolutions - revolution count at last wheel event. + * @param {number} measurement.wheel.timestamp - timestamp at last wheel event. */ updateMeasurement(measurement) { this.characteristics[0].updateMeasurement(measurement) diff --git a/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-feature.js b/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-feature.js index f612357e..b4c61b55 100644 --- a/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-feature.js +++ b/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-feature.js @@ -14,7 +14,7 @@ export class CscFeatureCharacteristic extends Characteristic { value: 'CSC Feature' }) ], - value: Buffer.from([2,0]) // crank revolution data + value: Buffer.from([3,0]) // crank revolution data }) } } diff --git a/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js b/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js index d3d80a24..e18e7561 100644 --- a/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js +++ b/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js @@ -1,7 +1,11 @@ import {Characteristic, Descriptor} from '@abandonware/bleno'; +const debuglog = require('debug')('gym:servers:ble'); + const FLAG_HASCRANKDATA = (1<<1); +const FLAG_HASSPEEDDATA = (1<<0); const CRANK_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec +const WHEEL_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec /** * Bluetooth LE GATT CSC Measurement Characteristic implementation. @@ -26,22 +30,34 @@ export class CscMeasurementCharacteristic extends Characteristic { * @param {object} measurement.crank - last crank event. * @param {number} measurement.crank.revolutions - revolution count at last crank event. * @param {number} measurement.crank.timestamp - timestamp at last crank event. + * @param {object} measurement.wheel - last wheel event. + * @param {number} measurement.wheel.revolutions - revolution count at last wheel event. + * @param {number} measurement.wheel.timestamp - timestamp at last wheel event. */ - updateMeasurement({ crank }) { - let flags = 0; + updateMeasurement({ crank, wheel }) { + if (crank && wheel) { + let flags = 0; + const value = Buffer.alloc(11); - const value = Buffer.alloc(5); + const wheelRevolutions32bit = wheel.revolutions & 0xffffffff; + //const wheelTimestamp16bit = Math.round(wheel.timestamp * WHEEL_TIMESTAMP_SCALE) & 0xffff; + const wheelTimestamp16bit = Math.round(wheel.sinceLast * WHEEL_TIMESTAMP_SCALE) & 0xffff; + value.writeUInt32LE(wheelRevolutions32bit, 1); + value.writeUInt16LE(wheelTimestamp16bit, 5); - const revolutions16bit = crank.revolutions & 0xffff; - const timestamp16bit = Math.round(crank.timestamp * CRANK_TIMESTAMP_SCALE) & 0xffff; - value.writeUInt16LE(revolutions16bit, 1); - value.writeUInt16LE(timestamp16bit, 3); - flags |= FLAG_HASCRANKDATA; + const crankRevolutions16bit = crank.revolutions & 0xffff; + const crankTimestamp16bit = Math.round(crank.timestamp * CRANK_TIMESTAMP_SCALE) & 0xffff; + value.writeUInt16LE(crankRevolutions16bit, 7); + value.writeUInt16LE(crankTimestamp16bit, 9); - value.writeUInt8(flags, 0); + flags |= FLAG_HASSPEEDDATA; + flags |= FLAG_HASCRANKDATA; - if (this.updateValueCallback) { - this.updateValueCallback(value) + value.writeUInt8(flags, 0); + debuglog(`BLE broadcast CSC wheel revolutions=${wheelRevolutions32bit} wheel timestamp=${wheelTimestamp16bit} message=${value.toString('hex')}`); + if (this.updateValueCallback) { + this.updateValueCallback(value) + } } } } diff --git a/src/servers/ble/services/cycling-speed-and-cadence/index.js b/src/servers/ble/services/cycling-speed-and-cadence/index.js index fae4a116..0dbef285 100644 --- a/src/servers/ble/services/cycling-speed-and-cadence/index.js +++ b/src/servers/ble/services/cycling-speed-and-cadence/index.js @@ -25,6 +25,9 @@ export class CyclingSpeedAndCadenceService extends PrimaryService { * @param {object} measurement.crank - last crank event. * @param {number} measurement.crank.revolutions - revolution count at last crank event. * @param {number} measurement.crank.timestamp - timestamp at last crank event. + * @param {object} measurement.wheel - last wheel event. + * @param {number} measurement.wheel.revolutions - revolution count at last wheel event. + * @param {number} measurement.wheel.timestamp - timestamp at last wheel event. */ updateMeasurement(measurement) { this.characteristics[0].updateMeasurement(measurement) diff --git a/src/test/app/simulation.js b/src/test/app/simulation.js index 33c244e1..2844d23d 100644 --- a/src/test/app/simulation.js +++ b/src/test/app/simulation.js @@ -1,6 +1,6 @@ import {test} from 'tape'; import sinon from 'sinon'; -import {Simulation} from '../../app/simulation'; +import {crankSimulation} from '../../app/crankSimulation'; test('constant cadence', t => { const timeline = [ @@ -10,7 +10,7 @@ test('constant cadence', t => { pedalEvent(2000), pedalEvent(3000), ]; - + testTimeline(timeline, t); }); @@ -123,7 +123,7 @@ function testTimeline(timeline, t) { const duration = Math.max(...timestamps); const clock = sinon.useFakeTimers(); - const sim = new Simulation(); + const sim = new crankSimulation(); // change sim.cadence at the appropriate times for (let {timestamp, cadence} of cadenceChanges) { From 1976b64e28b459be7c833eb426e580d71170caf6 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Sun, 17 Oct 2021 11:44:36 -0700 Subject: [PATCH 02/29] Corrected CSC feature data --- .../cycling-power/characteristics/cycling-power-feature.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js b/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js index 65a5538b..3678c9c6 100644 --- a/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js +++ b/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js @@ -14,7 +14,7 @@ export class CyclingPowerFeatureCharacteristic extends Characteristic { value: 'Cycling Power Feature' }) ], - value: Buffer.from([12,0,0,0]) // crank revolution data + value: Buffer.from([8,0,0,0]) // crank revolution data }) } } From 455aab13057f573a88a9a06a90788e518eefa88d Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Sun, 17 Oct 2021 12:04:04 -0700 Subject: [PATCH 03/29] Fixed typo in test --- src/test/app/simulation.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/app/simulation.js b/src/test/app/simulation.js index 2844d23d..4c835935 100644 --- a/src/test/app/simulation.js +++ b/src/test/app/simulation.js @@ -1,6 +1,6 @@ import {test} from 'tape'; import sinon from 'sinon'; -import {crankSimulation} from '../../app/crankSimulation'; +import {CrankSimulation} from '../../app/crankSimulation'; test('constant cadence', t => { const timeline = [ @@ -123,7 +123,7 @@ function testTimeline(timeline, t) { const duration = Math.max(...timestamps); const clock = sinon.useFakeTimers(); - const sim = new crankSimulation(); + const sim = new CrankSimulation(); // change sim.cadence at the appropriate times for (let {timestamp, cadence} of cadenceChanges) { From e6d381c9989c7f4ae4cf82de706b783024d8a8f3 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Sun, 17 Oct 2021 12:09:27 -0700 Subject: [PATCH 04/29] Updated BLE debug for CSC --- .../characteristics/csc-measurement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js b/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js index e18e7561..01d07c54 100644 --- a/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js +++ b/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js @@ -54,7 +54,7 @@ export class CscMeasurementCharacteristic extends Characteristic { flags |= FLAG_HASCRANKDATA; value.writeUInt8(flags, 0); - debuglog(`BLE broadcast CSC wheel revolutions=${wheelRevolutions32bit} wheel timestamp=${wheelTimestamp16bit} message=${value.toString('hex')}`); + debuglog(`BLE broadcast CSC wheel revolutions=${wheelRevolutions32bit} wheel timestamp=${wheelTimestamp16bit} crank revolutions=${crankRevolutions16bit} crank timestamp=${crankTimestamp16bit} message=${value.toString('hex')}`); if (this.updateValueCallback) { this.updateValueCallback(value) } From 92f36da99d80a7a6d5dc2721df51fc7ba2ef4dae Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Sun, 17 Oct 2021 12:11:19 -0700 Subject: [PATCH 05/29] Fixed wheel timestamp issue --- .../characteristics/csc-measurement.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js b/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js index 01d07c54..dd725b91 100644 --- a/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js +++ b/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js @@ -40,8 +40,7 @@ export class CscMeasurementCharacteristic extends Characteristic { const value = Buffer.alloc(11); const wheelRevolutions32bit = wheel.revolutions & 0xffffffff; - //const wheelTimestamp16bit = Math.round(wheel.timestamp * WHEEL_TIMESTAMP_SCALE) & 0xffff; - const wheelTimestamp16bit = Math.round(wheel.sinceLast * WHEEL_TIMESTAMP_SCALE) & 0xffff; + const wheelTimestamp16bit = Math.round(wheel.timestamp * WHEEL_TIMESTAMP_SCALE) & 0xffff; value.writeUInt32LE(wheelRevolutions32bit, 1); value.writeUInt16LE(wheelTimestamp16bit, 5); From d3fa314ee1752dac2bc8c842d48eadcc63f37b7e Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Sun, 17 Oct 2021 16:44:27 -0700 Subject: [PATCH 06/29] Test other channel period for ANT+ SPD --- src/app/app.js | 6 +++--- src/servers/ant/bike-speed/index.js | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/app.js b/src/app/app.js index 36147895..c45c7cbf 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -137,7 +137,7 @@ export class App { this.cadence = this.crankSimulation.cadence; let {power, crank, wheel, cadence} = this; this.logger.log(`pedal stroke [timestamp=${timestamp} revolutions=${crank.revolutions} power=${power}W]`); - this.antBikePower.updateMeasurement({ power, cadence }); + //this.antBikePower.updateMeasurement({ power, cadence }); //this.server.updateMeasurement({ power, crank, wheel }); } @@ -197,13 +197,13 @@ export class App { onAntStickStartup() { this.logger.log('ANT+ stick opened'); - this.antBikePower.start(); + //this.antBikePower.start(); this.antBikeSpeed.start(); } stopAnt() { this.logger.log('stopping ANT+ server'); - this.antBikePower.stop(); + //this.antBikePower.stop(); this.antBikeSpeed.stop(); } diff --git a/src/servers/ant/bike-speed/index.js b/src/servers/ant/bike-speed/index.js index 6a90e619..a74cf23b 100644 --- a/src/servers/ant/bike-speed/index.js +++ b/src/servers/ant/bike-speed/index.js @@ -7,7 +7,8 @@ const WHEEL_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec const DEVICE_TYPE = 0x7b; // Bike Speed Sensors const DEVICE_NUMBER = 2; -const PERIOD = 8118; // 8118/32768 ~4hz +//const PERIOD = 8118; // 8118/32768 ~4hz +const PERIOD = 8182; // 8182/32768 ~4hz const RF_CHANNEL = 57; // 2457 MHz const BROADCAST_INTERVAL = PERIOD / 32768; // seconds From b63909db7ee2d1f09dd0733bf797eb654f43f52b Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Sun, 17 Oct 2021 17:10:27 -0700 Subject: [PATCH 07/29] Test single ANT+ server Test single ANT+ server --- src/app/app.js | 26 ++-- src/servers/ant/bike-power/index.js | 118 ------------------ src/servers/ant/bike-speed/index.js | 117 ------------------ src/servers/ant/index.js | 178 ++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 250 deletions(-) delete mode 100644 src/servers/ant/bike-power/index.js delete mode 100644 src/servers/ant/bike-speed/index.js create mode 100644 src/servers/ant/index.js diff --git a/src/app/app.js b/src/app/app.js index c45c7cbf..9da9be86 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -4,8 +4,7 @@ import bleno from '@abandonware/bleno'; import {once} from 'events'; import {GymnasticonServer} from '../servers/ble'; -import {AntBikePower} from '../servers/ant/bike-power'; -import {AntBikeSpeed} from '../servers/ant/bike-speed'; +import {AntServer} from '../servers/ant'; import {createBikeClient, getBikeTypes} from '../bikes'; import {CrankSimulation} from './crankSimulation'; import {WheelSimulation} from './wheelSimulation'; @@ -83,8 +82,7 @@ export class App { this.server = new GymnasticonServer(bleno, opts.serverName); this.antStick = createAntStick(opts); - this.antBikePower = new AntBikePower(this.antStick, {deviceId: opts.antDeviceId}); - this.antBikeSpeed = new AntBikeSpeed(this.antStick, {deviceId: opts.antDeviceId}); + this.antServer = new AntServer(this.antStick, {deviceId: opts.antDeviceId}); this.antStick.on('startup', this.onAntStickStartup.bind(this)); this.pingInterval = new Timer(opts.serverPingInterval); @@ -137,8 +135,8 @@ export class App { this.cadence = this.crankSimulation.cadence; let {power, crank, wheel, cadence} = this; this.logger.log(`pedal stroke [timestamp=${timestamp} revolutions=${crank.revolutions} power=${power}W]`); - //this.antBikePower.updateMeasurement({ power, cadence }); //this.server.updateMeasurement({ power, crank, wheel }); + this.antServer.updateMeasurement({ power, cadence, wheel }); } onWheelRotation(timestamp) { @@ -147,14 +145,15 @@ export class App { this.wheel.revolutions++; let {power, crank, wheel, cadence} = this; this.logger.log(`wheel rotation [timestamp=${timestamp} revolutions=${wheel.revolutions} power=${power}W]`); - this.antBikeSpeed.updateMeasurement({ wheel }); - this.server.updateMeasurement({ power, crank, wheel }); + //this.server.updateMeasurement({ power, crank, wheel }); + this.antServer.updateMeasurement({ power, cadence, wheel }); } onPingInterval() { debuglog(`pinging app since no stats or pedal strokes for ${this.pingInterval.interval}s`); - let {power, crank, wheel} = this; + let {power, crank, wheel, cadence} = this; this.server.updateMeasurement({ power, crank, wheel }); + this.antServer.updateMeasurement({ power, cadence, wheel }); } onBikeStats({ power, cadence, speed }) { @@ -166,8 +165,7 @@ export class App { this.wheelSimulation.speed = speed; let {crank, wheel} = this; this.server.updateMeasurement({ power, crank, wheel }); - //this.antBikePower.updateMeasurement({ power, cadence }); - //this.antBikeSpeed.updateMeasurement({ wheel }); + this.antServer.updateMeasurement({ power, cadence, wheel }); } onBikeStatsTimeout() { @@ -197,14 +195,12 @@ export class App { onAntStickStartup() { this.logger.log('ANT+ stick opened'); - //this.antBikePower.start(); - this.antBikeSpeed.start(); + this.antServer.start(); } stopAnt() { this.logger.log('stopping ANT+ server'); - //this.antBikePower.stop(); - this.antBikeSpeed.stop(); + this.antServer.stop(); } onSigInt() { @@ -215,7 +211,7 @@ export class App { } onExit() { - if (this.antBikePower.isRunning||this.antBikeSpeed.isRunning) { + if (this.antServer.isRunning) { this.stopAnt(); } } diff --git a/src/servers/ant/bike-power/index.js b/src/servers/ant/bike-power/index.js deleted file mode 100644 index ccfd0ce4..00000000 --- a/src/servers/ant/bike-power/index.js +++ /dev/null @@ -1,118 +0,0 @@ -import Ant from 'gd-ant-plus'; -import {Timer} from '../../../util/timer'; - -const debuglog = require('debug')('gym:servers:ant'); - -const DEVICE_TYPE = 0x0b; // Bike Power Sensors -const DEVICE_NUMBER = 1; -const PERIOD = 8182; // 8182/32768 ~4hz -const RF_CHANNEL = 57; // 2457 MHz -const BROADCAST_INTERVAL = PERIOD / 32768; // seconds - -const defaults = { - deviceId: 11234, - channel: 1, -} - -/** - * Handles communication with apps (e.g. Zwift) using the ANT+ Bicycle Power - * profile (instantaneous cadence and power). - */ -export class AntBikePower { - /** - * Create an AntServer instance. - * @param {Ant.USBDevice} antStick - ANT+ device instance - * @param {object} options - * @param {number} options.channel - ANT+ channel - * @param {number} options.deviceId - ANT+ device id - */ - constructor(antStick, options = {}) { - const opts = {...defaults, ...options}; - this.stick = antStick; - this.deviceId = opts.deviceId; - this.eventCount = 0; - this.accumulatedPower = 0; - this.channel = opts.channel; - - this.power = 0; - this.cadence = 0; - - this.broadcastInterval = new Timer(BROADCAST_INTERVAL); - this.broadcastInterval.on('timeout', this.onBroadcastInterval.bind(this)); - - this._isRunning = false; - } - - /** - * Start the ANT+ server (setup channel and start broadcasting). - */ - start() { - const {stick, channel, deviceId} = this; - const messages = [ - Ant.Messages.assignChannel(channel, 'transmit_only'), - Ant.Messages.setDevice(channel, deviceId, DEVICE_TYPE, DEVICE_NUMBER), - Ant.Messages.setFrequency(channel, RF_CHANNEL), - Ant.Messages.setPeriod(channel, PERIOD), - Ant.Messages.openChannel(channel), - ]; - debuglog(`ANT+ server start [deviceId=${deviceId} channel=${channel}]`); - for (let m of messages) { - stick.write(m); - } - this.broadcastInterval.reset(); - this._isRunning = true; - } - - get isRunning() { - return this._isRunning; - } - - /** - * Stop the ANT+ server (stop broadcasting and unassign channel). - */ - stop() { - const {stick, channel} = this; - this.broadcastInterval.cancel(); - const messages = [ - Ant.Messages.closeChannel(channel), - Ant.Messages.unassignChannel(channel), - ]; - for (let m of messages) { - stick.write(m); - } - } - - /** - * Update instantaneous power and cadence. - * @param {object} measurement - * @param {number} measurement.power - power in watts - * @param {number} measurement.cadence - cadence in rpm - */ - updateMeasurement({ power, cadence }) { - this.power = power; - this.cadence = cadence; - } - - /** - * Broadcast instantaneous power and cadence. - */ - onBroadcastInterval() { - const {stick, channel, power, cadence} = this; - this.accumulatedPower += power; - this.accumulatedPower &= 0xffff; - const data = [ - channel, - 0x10, // power only - this.eventCount, - 0xff, // pedal power not used - cadence, - ...Ant.Messages.intToLEHexArray(this.accumulatedPower, 2), - ...Ant.Messages.intToLEHexArray(power, 2), - ]; - const message = Ant.Messages.broadcastData(data); - debuglog(`ANT+ broadcast power power=${power}W cadence=${cadence}rpm accumulatedPower=${this.accumulatedPower}W eventCount=${this.eventCount} message=${message.toString('hex')}`); - stick.write(message); - this.eventCount++; - this.eventCount &= 0xff; - } -} diff --git a/src/servers/ant/bike-speed/index.js b/src/servers/ant/bike-speed/index.js deleted file mode 100644 index a74cf23b..00000000 --- a/src/servers/ant/bike-speed/index.js +++ /dev/null @@ -1,117 +0,0 @@ -import Ant from 'gd-ant-plus'; -import {Timer} from '../../../util/timer'; - -const debuglog = require('debug')('gym:servers:ant'); - -const WHEEL_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec - -const DEVICE_TYPE = 0x7b; // Bike Speed Sensors -const DEVICE_NUMBER = 2; -//const PERIOD = 8118; // 8118/32768 ~4hz -const PERIOD = 8182; // 8182/32768 ~4hz -const RF_CHANNEL = 57; // 2457 MHz -const BROADCAST_INTERVAL = PERIOD / 32768; // seconds - -const defaults = { - deviceId: 11234, - channel: 2, -} - -/** - * Handles communication with apps (e.g. Zwift) using the ANT+ Bicycle Power - * profile (instantaneous cadence and power). - */ -export class AntBikeSpeed { - /** - * Create an AntServer instance. - * @param {Ant.USBDevice} antStick - ANT+ device instance - * @param {object} options - * @param {number} options.channel - ANT+ channel - * @param {number} options.deviceId - ANT+ device id - */ - constructor(antStick, options = {}) { - const opts = {...defaults, ...options}; - this.stick = antStick; - this.deviceId = opts.deviceId + 1; - this.channel = opts.channel; - - this.wheelRevolutions = 0; - this.wheelTimestamp = 0; - - this.broadcastInterval = new Timer(BROADCAST_INTERVAL); - this.broadcastInterval.on('timeout', this.onBroadcastInterval.bind(this)); - - this._isRunning = false; - } - - /** - * Start the ANT+ server (setup channel and start broadcasting). - */ - start() { - const {stick, channel, deviceId} = this; - console.log("max channels: ", stick.maxChannels); - const messages = [ - Ant.Messages.assignChannel(channel, 'transmit_only'), - Ant.Messages.setDevice(channel, deviceId, DEVICE_TYPE, DEVICE_NUMBER), - Ant.Messages.setFrequency(channel, RF_CHANNEL), - Ant.Messages.setPeriod(channel, PERIOD), - Ant.Messages.openChannel(channel), - ]; - debuglog(`ANT+ server start [deviceId=${deviceId} channel=${channel}]`); - for (let m of messages) { - stick.write(m); - } - this.broadcastInterval.reset(); - this._isRunning = true; - } - - get isRunning() { - return this._isRunning; - } - - /** - * Stop the ANT+ server (stop broadcasting and unassign channel). - */ - stop() { - const {stick, channel} = this; - this.broadcastInterval.cancel(); - const messages = [ - Ant.Messages.closeChannel(channel), - Ant.Messages.unassignChannel(channel), - ]; - for (let m of messages) { - stick.write(m); - } - } - - /** - * Update instantaneous power and cadence. - * @param {object} measurement - * @param {object} measurement.wheel - last wheel event. - * @param {number} measurement.wheel.revolutions - revolution count at last wheel event. - * @param {number} measurement.wheel.timestamp - timestamp at last wheel event. - */ - updateMeasurement({ wheel }) { - this.wheelRevolutions = wheel.revolutions; - this.wheelTimestamp = Math.round(wheel.timestamp * WHEEL_TIMESTAMP_SCALE) & 0xffff; - } - - /** - * Broadcast instantaneous power and cadence. - */ - onBroadcastInterval() { - const {stick, channel} = this; - const data = [ - channel, - 0x0, - 0x0, - 0x0, - 0x0, - ...Ant.Messages.intToLEHexArray(this.wheelTimestamp, 2), // Event Time - ...Ant.Messages.intToLEHexArray(this.wheelRevolutions, 2), // Revolution Count - ]; - const message = Ant.Messages.broadcastData(data); - debuglog(`ANT+ broadcast speed revolutions=${this.wheelRevolutions} timestamp=${this.wheelTimestamp} message=${message.toString('hex')}`); - stick.write(message); - } -} diff --git a/src/servers/ant/index.js b/src/servers/ant/index.js new file mode 100644 index 00000000..41383bc5 --- /dev/null +++ b/src/servers/ant/index.js @@ -0,0 +1,178 @@ +import Ant from 'gd-ant-plus'; +import {Timer} from '../../util/timer'; + +const debuglog = require('debug')('gym:servers:ant'); + +const WHEEL_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec + +const PWR_DEVICE_TYPE = 0x0b; // Bike Power Sensors +const PWR_DEVICE_NUMBER = 1; +const PWR_PERIOD = 8182; // 8182/32768 ~4hz PWR + +const SPD_DEVICE_TYPE = 0x7b; // Bike Speed Sensors +const SPD_DEVICE_NUMBER = 2; +const SPD_PERIOD = 8118; // 8118/32768 ~4hz SPD + +const RF_CHANNEL = 57; // 2457 MHz +const PERIOD = 8182; +const BROADCAST_INTERVAL = PERIOD / 32768; // seconds + +const defaults = { + deviceId: 11234, +} + +/** + * Handles communication with apps (e.g. Zwift) using the ANT+ Bicycle Power + * profile (instantaneous cadence and power), as well as ANT+ Bicycle Speed + * profile (wheel rotations and timestamp). + */ +export class AntServer { + /** + * Create an AntServer instance. + * @param {Ant.USBDevice} antStick - ANT+ device instance + * @param {object} options + * @param {number} options.channel - ANT+ channel + * @param {number} options.deviceId - ANT+ device id + */ + constructor(antStick, options = {}) { + const opts = {...defaults, ...options}; + this.stick = antStick; + this.pwr_deviceId = opts.deviceId; + this.pwr_channel = 1; + this.power = 0; + this.cadence = 0; + this.eventCount = 0; + this.accumulatedPower = 0; + + this.spd_deviceId = opts.deviceId + 1; + this.spd_channel = 2; + this.wheelRevolutions = 0; + this.wheelTimestamp = 0; + this.wheelCount = 0; + + this.broadcastInterval = new Timer(BROADCAST_INTERVAL); + this.broadcastInterval.on('timeout', this.onBroadcastInterval.bind(this)); + + this._isRunning = false; + } + + /** + * Start the ANT+ server (setup channel and start broadcasting). + */ + start() { + const {stick, pwr_channel, spd_channel, pwr_deviceId, spd_deviceId} = this; + const pwr_messages = [ + Ant.Messages.assignChannel(pwr_channel, 'transmit_only'), + Ant.Messages.setDevice(pwr_channel, pwr_deviceId, PWR_DEVICE_TYPE, PWR_DEVICE_NUMBER), + Ant.Messages.setFrequency(pwr_channel, RF_CHANNEL), + Ant.Messages.setPeriod(pwr_channel, PWR_PERIOD), + Ant.Messages.openChannel(pwr_channel), + ]; + debuglog(`ANT+ server power start [deviceId=${pwr_deviceId} channel=${pwr_channel}]`); + for (let pm of pwr_messages) { + stick.write(pm); + } + + const spd_messages = [ + Ant.Messages.assignChannel(spd_channel, 'transmit_only'), + Ant.Messages.setDevice(spd_channel, spd_deviceId, SPD_DEVICE_TYPE, SPD_DEVICE_NUMBER), + Ant.Messages.setFrequency(spd_channel, RF_CHANNEL), + Ant.Messages.setPeriod(spd_channel, SPD_PERIOD), + Ant.Messages.openChannel(spd_channel), + ]; + debuglog(`ANT+ server speed start [deviceId=${spd_deviceId} channel=${spd_channel}]`); + for (let sm of spd_messages) { + stick.write(sm); + } + this.broadcastInterval.reset(); + this._isRunning = true; + } + + get isRunning() { + return this._isRunning; + } + + /** + * Stop the ANT+ server (stop broadcasting and unassign channel). + */ + stop() { + const {stick, pwr_channel, spd_channel} = this; + this.broadcastInterval.cancel(); + const pwr_messages = [ + Ant.Messages.closeChannel(pwr_channel), + Ant.Messages.unassignChannel(pwr_channel), + ]; + for (let pm of pwr_messages) { + stick.write(pm); + } + const spd_messages = [ + Ant.Messages.closeChannel(spd_channel), + Ant.Messages.unassignChannel(spd_channel), + ]; + for (let sm of spd_messages) { + stick.write(sm); + } + } + + /** + * Update instantaneous power, cadence and wheel revolution data. + * @param {object} measurement + * @param {number} measurement.power - power in watts + * @param {number} measurement.cadence - cadence in rpm + * @param {object} measurement.wheel - last wheel event. + * @param {number} measurement.wheel.revolutions - revolution count at last wheel event. + * @param {number} measurement.wheel.timestamp - timestamp at last wheel event. + */ + updateMeasurement({ power, cadence, wheel }) { + if (power) { + this.power = power; + this.cadence = cadence; + } + if (wheel) { + this.wheelRevolutions = wheel.revolutions; + this.wheelTimestamp = Math.round(wheel.timestamp * WHEEL_TIMESTAMP_SCALE) & 0xffff; + } + } + + /** + * Broadcast instantaneous power, cadence and wheel revolution data. + */ + onBroadcastInterval() { + const {stick, pwr_channel, spd_channel, power, cadence} = this; + + this.accumulatedPower += power; + this.accumulatedPower &= 0xffff; + const pwr_data = [ + pwr_channel, + 0x10, // power only + this.eventCount, + 0xff, // pedal power not used + cadence, + ...Ant.Messages.intToLEHexArray(this.accumulatedPower, 2), + ...Ant.Messages.intToLEHexArray(power, 2), + ]; + debuglog(`ANT+ broadcast power power=${power}W cadence=${cadence}rpm accumulatedPower=${this.accumulatedPower}W eventCount=${this.eventCount}`); + + this.eventCount++; + this.eventCount &= 0xff; + + const spd_data = [ + spd_channel, + ...Ant.Messages.intToLEHexArray(0x0, 4), + ...Ant.Messages.intToLEHexArray(this.wheelTimestamp, 2), // Last event Time + ...Ant.Messages.intToLEHexArray(this.wheelRevolutions, 2), // Revolution Count + ]; + + const messages = [ + Ant.Messages.broadcastData(pwr_data), + Ant.Messages.broadcastData(spd_data), + Ant.Messages.broadcastData(pwr_data), + Ant.Messages.broadcastData(spd_data), + ]; + debuglog(`ANT+ broadcast speed revolutions=${this.wheelRevolutions} timestamp=${this.wheelTimestamp}`); + + for (let m of messages) { + stick.write(m); + } + } +} From 2da4b9a6acfe801ce7e6364a1bfac17eba5f63f8 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Sun, 17 Oct 2021 22:21:00 -0700 Subject: [PATCH 08/29] Sending SPD broadcast twice for better stability --- src/servers/ant/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/servers/ant/index.js b/src/servers/ant/index.js index 41383bc5..543c2507 100644 --- a/src/servers/ant/index.js +++ b/src/servers/ant/index.js @@ -164,7 +164,6 @@ export class AntServer { ]; const messages = [ - Ant.Messages.broadcastData(pwr_data), Ant.Messages.broadcastData(spd_data), Ant.Messages.broadcastData(pwr_data), Ant.Messages.broadcastData(spd_data), From b115495ca8c5fdfc151292c5c8019c105bae66ef Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Mon, 18 Oct 2021 07:44:23 -0700 Subject: [PATCH 09/29] Fixed speed support in IC4 bike driver --- src/bikes/ic4.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bikes/ic4.js b/src/bikes/ic4.js index 61bf04fa..dbe56bc5 100644 --- a/src/bikes/ic4.js +++ b/src/bikes/ic4.js @@ -179,8 +179,8 @@ export function parse(data) { if (data.indexOf(IBD_VALUE_MAGIC) === 0) { const power = data.readInt16LE(IBD_VALUE_IDX_POWER); const cadence = Math.round(data.readUInt16LE(IBD_VALUE_IDX_CADENCE) / 2); - const speed = Math.round(data.readUInt16LE(IBD_VALUE_IDX_SPEED) / 100); - return {power, cadence}; + const speed = data.readUInt16LE(IBD_VALUE_IDX_SPEED) / 100; + return {power, cadence, speed}; } throw new Error('unable to parse message'); } From 2281120f822e7c077612196d354de70e6a929c52 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Mon, 18 Oct 2021 07:44:23 -0700 Subject: [PATCH 10/29] Various small fixes for ANT+ speed support --- src/app/app.js | 10 +++++----- src/bikes/ic4.js | 4 ++-- src/servers/ant/index.js | 6 ++---- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/app/app.js b/src/app/app.js index 9da9be86..929f6d8a 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -132,10 +132,9 @@ export class App { this.pingInterval.reset(); this.crank.timestamp = timestamp; this.crank.revolutions++; - this.cadence = this.crankSimulation.cadence; let {power, crank, wheel, cadence} = this; - this.logger.log(`pedal stroke [timestamp=${timestamp} revolutions=${crank.revolutions} power=${power}W]`); - //this.server.updateMeasurement({ power, crank, wheel }); + this.logger.log(`pedal stroke [timestamp=${timestamp} revolutions=${crank.revolutions} cadence=${cadence}rpm power=${power}W]`); + this.server.updateMeasurement({ power, crank, wheel }); this.antServer.updateMeasurement({ power, cadence, wheel }); } @@ -144,8 +143,8 @@ export class App { this.wheel.timestamp = timestamp; this.wheel.revolutions++; let {power, crank, wheel, cadence} = this; - this.logger.log(`wheel rotation [timestamp=${timestamp} revolutions=${wheel.revolutions} power=${power}W]`); - //this.server.updateMeasurement({ power, crank, wheel }); + this.logger.log(`wheel rotation [timestamp=${timestamp} revolutions=${wheel.revolutions} cadence=${cadence}rpm power=${power}W]`); + this.server.updateMeasurement({ power, crank, wheel }); this.antServer.updateMeasurement({ power, cadence, wheel }); } @@ -161,6 +160,7 @@ export class App { this.logger.log(`received stats from bike [power=${power}W cadence=${cadence}rpm speed=${speed}km/h]`); this.statsTimeout.reset(); this.power = power; + this.cadence = cadence; this.crankSimulation.cadence = cadence; this.wheelSimulation.speed = speed; let {crank, wheel} = this; diff --git a/src/bikes/ic4.js b/src/bikes/ic4.js index 61bf04fa..dbe56bc5 100644 --- a/src/bikes/ic4.js +++ b/src/bikes/ic4.js @@ -179,8 +179,8 @@ export function parse(data) { if (data.indexOf(IBD_VALUE_MAGIC) === 0) { const power = data.readInt16LE(IBD_VALUE_IDX_POWER); const cadence = Math.round(data.readUInt16LE(IBD_VALUE_IDX_CADENCE) / 2); - const speed = Math.round(data.readUInt16LE(IBD_VALUE_IDX_SPEED) / 100); - return {power, cadence}; + const speed = data.readUInt16LE(IBD_VALUE_IDX_SPEED) / 100; + return {power, cadence, speed}; } throw new Error('unable to parse message'); } diff --git a/src/servers/ant/index.js b/src/servers/ant/index.js index 543c2507..e9330a52 100644 --- a/src/servers/ant/index.js +++ b/src/servers/ant/index.js @@ -124,10 +124,8 @@ export class AntServer { * @param {number} measurement.wheel.timestamp - timestamp at last wheel event. */ updateMeasurement({ power, cadence, wheel }) { - if (power) { - this.power = power; - this.cadence = cadence; - } + this.power = power; + this.cadence = cadence; if (wheel) { this.wheelRevolutions = wheel.revolutions; this.wheelTimestamp = Math.round(wheel.timestamp * WHEEL_TIMESTAMP_SCALE) & 0xffff; From f686eaba49099e10285296906019623878f670b8 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Mon, 18 Oct 2021 08:29:36 -0700 Subject: [PATCH 11/29] Support for Wheel Revolution Data in BLE Cycling Power --- .../cycling-power-measurement.js | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js index 87f05955..df86873c 100644 --- a/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js +++ b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js @@ -3,8 +3,9 @@ import {Characteristic, Descriptor} from '@abandonware/bleno'; const debuglog = require('debug')('gym:servers:ble'); const FLAG_HASCRANKDATA = (1<<5); +const FLAG_HASSPEEDDATA = (1<<4); const CRANK_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec - +const WHEEL_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec /** * Bluetooth LE GATT Cycling Power Measurement Characteristic implementation. @@ -27,22 +28,31 @@ export class CyclingPowerMeasurementCharacteristic extends Characteristic { * Notify subscriber (e.g. Zwift) of new Cycling Power Measurement. * @param {object} measurement - new cycling power measurement. * @param {number} measurement.power - current power (watts) - * @param {object} [measurement.crank] - last crank event. + * @param {object} measurement.crank - last crank event. * @param {number} measurement.crank.revolutions - revolution count at last crank event. * @param {number} measurement.crank.timestamp - timestamp at last crank event. + * @param {object} measurement.wheel - last wheel event. + * @param {number} measurement.wheel.revolutions - revolution count at last wheel event. + * @param {number} measurement.wheel.timestamp - timestamp at last wheel event. + */ */ - updateMeasurement({ power, crank }) { + updateMeasurement({ power, crank, wheel }) { let flags = 0; - const value = Buffer.alloc(8); + const value = Buffer.alloc(14); value.writeInt16LE(power, 2); - if (crank) { - const revolutions16bit = crank.revolutions & 0xffff; - const timestamp16bit = Math.round(crank.timestamp * CRANK_TIMESTAMP_SCALE) & 0xffff; - value.writeUInt16LE(revolutions16bit, 4); - value.writeUInt16LE(timestamp16bit, 6); + if (crank && wheel) { + const wheelRevolutions32bit = wheel.revolutions & 0xffffffff; + const wheelTimestamp16bit = Math.round(wheel.timestamp * WHEEL_TIMESTAMP_SCALE) & 0xffff; + value.writeUInt32LE(wheelRevolutions32bit, 4); + value.writeUInt16LE(wheelTimestamp16bit, 8); + const crankRevolutions16bit = crank.revolutions & 0xffff; + const crankTimestamp16bit = Math.round(crank.timestamp * CRANK_TIMESTAMP_SCALE) & 0xffff; + value.writeUInt16LE(crankRevolutions16bit, 10); + value.writeUInt16LE(crankTimestamp16bit, 12); flags |= FLAG_HASCRANKDATA; + flags |= FLAG_HASSPEEDDATA; } value.writeUInt16LE(flags, 0); From e96b151c2234a4fe59f28945defb17612aba12d9 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Mon, 18 Oct 2021 08:30:26 -0700 Subject: [PATCH 12/29] Fixed typo --- .../cycling-power/characteristics/cycling-power-measurement.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js index df86873c..db172dfb 100644 --- a/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js +++ b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js @@ -35,7 +35,6 @@ export class CyclingPowerMeasurementCharacteristic extends Characteristic { * @param {number} measurement.wheel.revolutions - revolution count at last wheel event. * @param {number} measurement.wheel.timestamp - timestamp at last wheel event. */ - */ updateMeasurement({ power, crank, wheel }) { let flags = 0; From 36d333eedb1d561808db83013b13d32365e79f4f Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Mon, 18 Oct 2021 08:47:29 -0700 Subject: [PATCH 13/29] Added speed calculation support for Peleton --- src/bikes/peloton.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/bikes/peloton.js b/src/bikes/peloton.js index a0f28dcd..74072edf 100644 --- a/src/bikes/peloton.js +++ b/src/bikes/peloton.js @@ -84,7 +84,16 @@ export class PelotonBikeClient extends EventEmitter { * @private */ onStatsUpdate() { - const {power, cadence, speed} = this; + const {power, cadence} = this; + + // Calculate Speed based on + // https://ihaque.org/posts/2020/12/25/pelomon-part-ib-computing-speed/ + const r = Math.sqrt(power); + if (power < 26) { + const speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 + } else { + const speed = -1.635 + 2.325 * r - 0.064 * r^2 + 0.001 * r^3 + } this.emit('stats', {power, cadence, speed}); } From 208e4e068a0cd52b3170e791983d32b4e6268f6d Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Mon, 18 Oct 2021 11:00:57 -0700 Subject: [PATCH 14/29] Added speed support for Keiser based on Peleton formula --- src/bikes/keiser.js | 10 +++++++++- src/servers/ant/index.js | 9 ++++++--- .../characteristics/cycling-power-measurement.js | 1 + .../characteristics/csc-measurement.js | 1 + 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/bikes/keiser.js b/src/bikes/keiser.js index baf97b21..e6429071 100644 --- a/src/bikes/keiser.js +++ b/src/bikes/keiser.js @@ -208,7 +208,15 @@ export function parse(data) { // Realtime data received const power = data.readUInt16LE(KEISER_VALUE_IDX_POWER); const cadence = Math.round(data.readUInt16LE(KEISER_VALUE_IDX_CADENCE) / 10); - const speed = 0; // Speed not supported by Keiser + + // Calculate Speed based on + // https://ihaque.org/posts/2020/12/25/pelomon-part-ib-computing-speed/ + const r = Math.sqrt(power); + if (power < 26) { + const speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 + } else { + const speed = -1.635 + 2.325 * r - 0.064 * r^2 + 0.001 * r^3 + } return {type: 'stats', payload: {power, cadence, speed}}; } } diff --git a/src/servers/ant/index.js b/src/servers/ant/index.js index e9330a52..3b9430ce 100644 --- a/src/servers/ant/index.js +++ b/src/servers/ant/index.js @@ -156,11 +156,14 @@ export class AntServer { const spd_data = [ spd_channel, - ...Ant.Messages.intToLEHexArray(0x0, 4), - ...Ant.Messages.intToLEHexArray(this.wheelTimestamp, 2), // Last event Time - ...Ant.Messages.intToLEHexArray(this.wheelRevolutions, 2), // Revolution Count + ...Ant.Messages.intToLEHexArray(0x0, 4), // Unused for SPD only sensor + ...Ant.Messages.intToLEHexArray(this.wheelTimestamp, 2), // Last event Time + ...Ant.Messages.intToLEHexArray(this.wheelRevolutions, 2), // Revolution Count ]; + /** + * Sending SPD data twice in this order leads to more receiver stability. + */ const messages = [ Ant.Messages.broadcastData(spd_data), Ant.Messages.broadcastData(pwr_data), diff --git a/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js index db172dfb..ece95bb7 100644 --- a/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js +++ b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js @@ -9,6 +9,7 @@ const WHEEL_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec /** * Bluetooth LE GATT Cycling Power Measurement Characteristic implementation. + * https://www.bluetooth.com/wp-content/uploads/Sitecore-Media-Library/Gatt/Xml/Characteristics/org.bluetooth.characteristic.cycling_power_measurement.xml */ export class CyclingPowerMeasurementCharacteristic extends Characteristic { constructor() { diff --git a/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js b/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js index dd725b91..6ca5fabc 100644 --- a/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js +++ b/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js @@ -9,6 +9,7 @@ const WHEEL_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec /** * Bluetooth LE GATT CSC Measurement Characteristic implementation. + * https://www.bluetooth.com/wp-content/uploads/Sitecore-Media-Library/Gatt/Xml/Characteristics/org.bluetooth.characteristic.csc_measurement.xml */ export class CscMeasurementCharacteristic extends Characteristic { constructor() { From 7bfc0b3de562049b86b0db1432a541116f108cba Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Mon, 18 Oct 2021 11:05:44 -0700 Subject: [PATCH 15/29] Added speed support for Keiser --- src/bikes/keiser.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/bikes/keiser.js b/src/bikes/keiser.js index e6429071..d1255144 100644 --- a/src/bikes/keiser.js +++ b/src/bikes/keiser.js @@ -211,11 +211,12 @@ export function parse(data) { // Calculate Speed based on // https://ihaque.org/posts/2020/12/25/pelomon-part-ib-computing-speed/ + speed = 0; const r = Math.sqrt(power); if (power < 26) { - const speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 + speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 } else { - const speed = -1.635 + 2.325 * r - 0.064 * r^2 + 0.001 * r^3 + speed = -1.635 + 2.325 * r - 0.064 * r^2 + 0.001 * r^3 } return {type: 'stats', payload: {power, cadence, speed}}; } From 6346caf5c48acbc0bffd4e9dffda31e3f7021711 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Mon, 18 Oct 2021 11:07:40 -0700 Subject: [PATCH 16/29] Fixed typo --- src/bikes/keiser.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bikes/keiser.js b/src/bikes/keiser.js index d1255144..6bdf5fbd 100644 --- a/src/bikes/keiser.js +++ b/src/bikes/keiser.js @@ -211,7 +211,7 @@ export function parse(data) { // Calculate Speed based on // https://ihaque.org/posts/2020/12/25/pelomon-part-ib-computing-speed/ - speed = 0; + let speed = 0; const r = Math.sqrt(power); if (power < 26) { speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 From d1873b8997ef4b524bedcca8c99df2578c061916 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Tue, 19 Oct 2021 09:39:14 -0700 Subject: [PATCH 17/29] Added support for combined speed+cadence over ANT+ Improved readability for ANT+ SaC implementation Improve readability of Peloton + Keiser Power to Speed conversion Improve ANT+ stability --- src/app/app.js | 8 +-- src/bikes/keiser.js | 22 ++++--- src/bikes/peloton.js | 22 ++++--- src/servers/ant/index.js | 120 +++++++++++++++++++++++++-------------- 4 files changed, 106 insertions(+), 66 deletions(-) diff --git a/src/app/app.js b/src/app/app.js index 929f6d8a..ddcf8b3f 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -135,7 +135,7 @@ export class App { let {power, crank, wheel, cadence} = this; this.logger.log(`pedal stroke [timestamp=${timestamp} revolutions=${crank.revolutions} cadence=${cadence}rpm power=${power}W]`); this.server.updateMeasurement({ power, crank, wheel }); - this.antServer.updateMeasurement({ power, cadence, wheel }); + this.antServer.updateMeasurement({ power, cadence, crank, wheel }); } onWheelRotation(timestamp) { @@ -145,14 +145,14 @@ export class App { let {power, crank, wheel, cadence} = this; this.logger.log(`wheel rotation [timestamp=${timestamp} revolutions=${wheel.revolutions} cadence=${cadence}rpm power=${power}W]`); this.server.updateMeasurement({ power, crank, wheel }); - this.antServer.updateMeasurement({ power, cadence, wheel }); + this.antServer.updateMeasurement({ power, cadence, crank, wheel }); } onPingInterval() { debuglog(`pinging app since no stats or pedal strokes for ${this.pingInterval.interval}s`); let {power, crank, wheel, cadence} = this; this.server.updateMeasurement({ power, crank, wheel }); - this.antServer.updateMeasurement({ power, cadence, wheel }); + this.antServer.updateMeasurement({ power, cadence, crank, wheel }); } onBikeStats({ power, cadence, speed }) { @@ -165,7 +165,7 @@ export class App { this.wheelSimulation.speed = speed; let {crank, wheel} = this; this.server.updateMeasurement({ power, crank, wheel }); - this.antServer.updateMeasurement({ power, cadence, wheel }); + this.antServer.updateMeasurement({ power, cadence, crank, wheel }); } onBikeStatsTimeout() { diff --git a/src/bikes/keiser.js b/src/bikes/keiser.js index 6bdf5fbd..2d249dba 100644 --- a/src/bikes/keiser.js +++ b/src/bikes/keiser.js @@ -208,18 +208,22 @@ export function parse(data) { // Realtime data received const power = data.readUInt16LE(KEISER_VALUE_IDX_POWER); const cadence = Math.round(data.readUInt16LE(KEISER_VALUE_IDX_CADENCE) / 10); + const speed = calcPowerToSpeed(power); - // Calculate Speed based on - // https://ihaque.org/posts/2020/12/25/pelomon-part-ib-computing-speed/ - let speed = 0; - const r = Math.sqrt(power); - if (power < 26) { - speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 - } else { - speed = -1.635 + 2.325 * r - 0.064 * r^2 + 0.001 * r^3 - } return {type: 'stats', payload: {power, cadence, speed}}; } } throw new Error('unable to parse message'); } + +export function calcPowerToSpeed(power) { + // Calculate Speed based on + // https://ihaque.org/posts/2020/12/25/pelomon-part-ib-computing-speed/ + const r = Math.sqrt(power); + if (power < 26) { + const speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 + } else { + const speed = -1.635 + 2.325 * r - 0.064 * r^2 + 0.001 * r^3 + } + return speed; +} diff --git a/src/bikes/peloton.js b/src/bikes/peloton.js index 74072edf..ac72c194 100644 --- a/src/bikes/peloton.js +++ b/src/bikes/peloton.js @@ -85,15 +85,7 @@ export class PelotonBikeClient extends EventEmitter { */ onStatsUpdate() { const {power, cadence} = this; - - // Calculate Speed based on - // https://ihaque.org/posts/2020/12/25/pelomon-part-ib-computing-speed/ - const r = Math.sqrt(power); - if (power < 26) { - const speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 - } else { - const speed = -1.635 + 2.325 * r - 0.064 * r^2 + 0.001 * r^3 - } + const speed = calcPowerToSpeed(power); this.emit('stats', {power, cadence, speed}); } @@ -172,3 +164,15 @@ export function decodePeloton(bufferArray, byteLength, isPower) { return accumulator + precision; } + +export function calcPowerToSpeed(power) { + // Calculate Speed based on + // https://ihaque.org/posts/2020/12/25/pelomon-part-ib-computing-speed/ + const r = Math.sqrt(power); + if (power < 26) { + const speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 + } else { + const speed = -1.635 + 2.325 * r - 0.064 * r^2 + 0.001 * r^3 + } + return speed; +} diff --git a/src/servers/ant/index.js b/src/servers/ant/index.js index 3b9430ce..fb66b979 100644 --- a/src/servers/ant/index.js +++ b/src/servers/ant/index.js @@ -3,15 +3,16 @@ import {Timer} from '../../util/timer'; const debuglog = require('debug')('gym:servers:ant'); +const CRANK_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec const WHEEL_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec -const PWR_DEVICE_TYPE = 0x0b; // Bike Power Sensors +const PWR_DEVICE_TYPE = 0x0b; // Bike Power Sensor const PWR_DEVICE_NUMBER = 1; const PWR_PERIOD = 8182; // 8182/32768 ~4hz PWR -const SPD_DEVICE_TYPE = 0x7b; // Bike Speed Sensors -const SPD_DEVICE_NUMBER = 2; -const SPD_PERIOD = 8118; // 8118/32768 ~4hz SPD +const SAC_DEVICE_TYPE = 0x79; // Bike Speed and Cadence Sensor +const SAC_DEVICE_NUMBER = 2; +const SAC_PERIOD = 8086; // 8086/32768 ~4hz SPD+CDC const RF_CHANNEL = 57; // 2457 MHz const PERIOD = 8182; @@ -24,7 +25,7 @@ const defaults = { /** * Handles communication with apps (e.g. Zwift) using the ANT+ Bicycle Power * profile (instantaneous cadence and power), as well as ANT+ Bicycle Speed - * profile (wheel rotations and timestamp). + * and Cadence profile (wheel rotations and timestamp). */ export class AntServer { /** @@ -44,11 +45,12 @@ export class AntServer { this.eventCount = 0; this.accumulatedPower = 0; - this.spd_deviceId = opts.deviceId + 1; - this.spd_channel = 2; + this.sac_deviceId = opts.deviceId + 1; + this.sac_channel = 2; this.wheelRevolutions = 0; this.wheelTimestamp = 0; - this.wheelCount = 0; + this.crankRevolutions = 0; + this.crankTimestamp = 0; this.broadcastInterval = new Timer(BROADCAST_INTERVAL); this.broadcastInterval.on('timeout', this.onBroadcastInterval.bind(this)); @@ -60,7 +62,9 @@ export class AntServer { * Start the ANT+ server (setup channel and start broadcasting). */ start() { - const {stick, pwr_channel, spd_channel, pwr_deviceId, spd_deviceId} = this; + const {stick, pwr_channel, sac_channel, pwr_deviceId, sac_deviceId} = this; + + // Initialize PWR channel const pwr_messages = [ Ant.Messages.assignChannel(pwr_channel, 'transmit_only'), Ant.Messages.setDevice(pwr_channel, pwr_deviceId, PWR_DEVICE_TYPE, PWR_DEVICE_NUMBER), @@ -73,16 +77,17 @@ export class AntServer { stick.write(pm); } - const spd_messages = [ - Ant.Messages.assignChannel(spd_channel, 'transmit_only'), - Ant.Messages.setDevice(spd_channel, spd_deviceId, SPD_DEVICE_TYPE, SPD_DEVICE_NUMBER), - Ant.Messages.setFrequency(spd_channel, RF_CHANNEL), - Ant.Messages.setPeriod(spd_channel, SPD_PERIOD), - Ant.Messages.openChannel(spd_channel), + // Initialize SaC channel + const sac_messages = [ + Ant.Messages.assignChannel(sac_channel, 'transmit_only'), + Ant.Messages.setDevice(sac_channel, sac_deviceId, SAC_DEVICE_TYPE, SAC_DEVICE_NUMBER), + Ant.Messages.setFrequency(sac_channel, RF_CHANNEL), + Ant.Messages.setPeriod(sac_channel, SAC_PERIOD), + Ant.Messages.openChannel(sac_channel), ]; - debuglog(`ANT+ server speed start [deviceId=${spd_deviceId} channel=${spd_channel}]`); - for (let sm of spd_messages) { - stick.write(sm); + debuglog(`ANT+ server speed and cadence start [deviceId=${sac_deviceId} channel=${sac_channel}]`); + for (let scm of sac_messages) { + stick.write(scm); } this.broadcastInterval.reset(); this._isRunning = true; @@ -96,21 +101,27 @@ export class AntServer { * Stop the ANT+ server (stop broadcasting and unassign channel). */ stop() { - const {stick, pwr_channel, spd_channel} = this; + const {stick, pwr_channel, sac_channel} = this; this.broadcastInterval.cancel(); + const pwr_messages = [ Ant.Messages.closeChannel(pwr_channel), Ant.Messages.unassignChannel(pwr_channel), ]; + + const sac_messages = [ + Ant.Messages.closeChannel(sac_channel), + Ant.Messages.unassignChannel(sac_channel), + ]; + + // Close PWR and SaC channels + // Wait between PWR and SaC close messages for (let pm of pwr_messages) { stick.write(pm); } - const spd_messages = [ - Ant.Messages.closeChannel(spd_channel), - Ant.Messages.unassignChannel(spd_channel), - ]; - for (let sm of spd_messages) { - stick.write(sm); + sleep(100); + for (let scm of sac_messages) { + stick.write(scm); } } @@ -119,13 +130,20 @@ export class AntServer { * @param {object} measurement * @param {number} measurement.power - power in watts * @param {number} measurement.cadence - cadence in rpm + * @param {object} measurement.crank - last crank event. + * @param {number} measurement.crank.revolutions - revolution count at last crank event. + * @param {number} measurement.crank.timestamp - timestamp at last crank event. * @param {object} measurement.wheel - last wheel event. * @param {number} measurement.wheel.revolutions - revolution count at last wheel event. * @param {number} measurement.wheel.timestamp - timestamp at last wheel event. */ - updateMeasurement({ power, cadence, wheel }) { + updateMeasurement({ power, cadence, crank, wheel }) { this.power = power; this.cadence = cadence; + if (crank) { + this.crankRevolutions = crank.revolutions; + this.crankTimestamp = Math.round(crank.timestamp * CRANK_TIMESTAMP_SCALE) & 0xffff; + } if (wheel) { this.wheelRevolutions = wheel.revolutions; this.wheelTimestamp = Math.round(wheel.timestamp * WHEEL_TIMESTAMP_SCALE) & 0xffff; @@ -136,8 +154,9 @@ export class AntServer { * Broadcast instantaneous power, cadence and wheel revolution data. */ onBroadcastInterval() { - const {stick, pwr_channel, spd_channel, power, cadence} = this; + const {stick, pwr_channel, sac_channel, power, cadence} = this; + // Build PWR broadcast message this.accumulatedPower += power; this.accumulatedPower &= 0xffff; const pwr_data = [ @@ -149,30 +168,43 @@ export class AntServer { ...Ant.Messages.intToLEHexArray(this.accumulatedPower, 2), ...Ant.Messages.intToLEHexArray(power, 2), ]; + const pwr_messages = [ + Ant.Messages.broadcastData(pwr_data), + ]; debuglog(`ANT+ broadcast power power=${power}W cadence=${cadence}rpm accumulatedPower=${this.accumulatedPower}W eventCount=${this.eventCount}`); + // Build SaC broadcast message this.eventCount++; this.eventCount &= 0xff; - - const spd_data = [ - spd_channel, - ...Ant.Messages.intToLEHexArray(0x0, 4), // Unused for SPD only sensor - ...Ant.Messages.intToLEHexArray(this.wheelTimestamp, 2), // Last event Time - ...Ant.Messages.intToLEHexArray(this.wheelRevolutions, 2), // Revolution Count + const sac_data = [ + sac_channel, + ...Ant.Messages.intToLEHexArray(this.crankTimestamp, 2), // Last crank event Time + ...Ant.Messages.intToLEHexArray(this.crankRevolutions, 2), // Crank revolution Count + ...Ant.Messages.intToLEHexArray(this.wheelTimestamp, 2), // Last wheel event Time + ...Ant.Messages.intToLEHexArray(this.wheelRevolutions, 2), // Wheel revolution Count ]; - - /** - * Sending SPD data twice in this order leads to more receiver stability. - */ - const messages = [ - Ant.Messages.broadcastData(spd_data), - Ant.Messages.broadcastData(pwr_data), - Ant.Messages.broadcastData(spd_data), + const sac_messages = [ + Ant.Messages.broadcastData(sac_data), ]; - debuglog(`ANT+ broadcast speed revolutions=${this.wheelRevolutions} timestamp=${this.wheelTimestamp}`); + debuglog(`ANT+ broadcast cadence revolutions=${this.crankRevolutions} cadence timestamp=${this.crankTimestamp} speed revolutions=${this.wheelRevolutions} timestamp=${this.wheelTimestamp}`); - for (let m of messages) { - stick.write(m); + // Send broadcast messages + // Wait between PWR and SaC broadcast messages + for (let pm of pwr_messages) { + stick.write(pm); + } + sleep(100); + for (let scm of sac_messages) { + stick.write(scm); } + debuglog(`ANT+ broadcast complete`); } } + +export function sleep(milliseconds) { + const date = Date.now(); + let currentDate = null; + do { + currentDate = Date.now(); + } while (currentDate - date < milliseconds); +} From 7f54361e781ad9b27b2a42e1a7b8ddefba9631c2 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Tue, 19 Oct 2021 12:36:40 -0700 Subject: [PATCH 18/29] Fixed typo --- src/bikes/keiser.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bikes/keiser.js b/src/bikes/keiser.js index 2d249dba..f01180ab 100644 --- a/src/bikes/keiser.js +++ b/src/bikes/keiser.js @@ -219,6 +219,7 @@ export function parse(data) { export function calcPowerToSpeed(power) { // Calculate Speed based on // https://ihaque.org/posts/2020/12/25/pelomon-part-ib-computing-speed/ + let speed = 0; const r = Math.sqrt(power); if (power < 26) { const speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 From fe7f2d509613d2f7ca8a768dd69b39514ad92a50 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Tue, 19 Oct 2021 13:58:53 -0700 Subject: [PATCH 19/29] Added test cases for speed --- src/bikes/keiser.js | 4 ++-- src/bikes/peloton.js | 5 +++-- src/test/app/{simulation.js => crankSimulation.js} | 0 src/test/bikes/flywheel.js | 5 +++-- src/test/bikes/ic4.js | 3 ++- src/test/bikes/keiser.js | 3 ++- src/test/bikes/peloton.js | 3 +++ 7 files changed, 15 insertions(+), 8 deletions(-) rename src/test/app/{simulation.js => crankSimulation.js} (100%) diff --git a/src/bikes/keiser.js b/src/bikes/keiser.js index f01180ab..b2c5bdcc 100644 --- a/src/bikes/keiser.js +++ b/src/bikes/keiser.js @@ -222,9 +222,9 @@ export function calcPowerToSpeed(power) { let speed = 0; const r = Math.sqrt(power); if (power < 26) { - const speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 + speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 } else { - const speed = -1.635 + 2.325 * r - 0.064 * r^2 + 0.001 * r^3 + speed = -1.635 + 2.325 * r - 0.064 * r^2 + 0.001 * r^3 } return speed; } diff --git a/src/bikes/peloton.js b/src/bikes/peloton.js index ac72c194..b78adf3a 100644 --- a/src/bikes/peloton.js +++ b/src/bikes/peloton.js @@ -168,11 +168,12 @@ export function decodePeloton(bufferArray, byteLength, isPower) { export function calcPowerToSpeed(power) { // Calculate Speed based on // https://ihaque.org/posts/2020/12/25/pelomon-part-ib-computing-speed/ + let speed = 0; const r = Math.sqrt(power); if (power < 26) { - const speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 + speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 } else { - const speed = -1.635 + 2.325 * r - 0.064 * r^2 + 0.001 * r^3 + speed = -1.635 + 2.325 * r - 0.064 * r^2 + 0.001 * r^3 } return speed; } diff --git a/src/test/app/simulation.js b/src/test/app/crankSimulation.js similarity index 100% rename from src/test/app/simulation.js rename to src/test/app/crankSimulation.js diff --git a/src/test/bikes/flywheel.js b/src/test/bikes/flywheel.js index e6180b29..d1b43b39 100644 --- a/src/test/bikes/flywheel.js +++ b/src/test/bikes/flywheel.js @@ -2,10 +2,11 @@ import test from 'tape'; import {parse} from '../../bikes/flywheel'; test('parse() parses Flywheel stats messages', t => { - const buf = Buffer.from('ff1f0c0122000000000000005a00000000000000000000000000000a000000016155', 'hex'); - const {type, payload: {power, cadence}} = parse(buf); + const buf = Buffer.from('ff1f0c0122000000000000005a00490000000000000000000000000a000000016155', 'hex'); + const {type, payload: {power, cadence, speed}} = parse(buf); t.equal(type, 'stats', 'message type'); t.equal(power, 290, 'power (watts)'); t.equal(cadence, 90, 'cadence (rpm)'); + t.equal(speed, 7.3, 'speed (km/h)'); t.end(); }); diff --git a/src/test/bikes/ic4.js b/src/test/bikes/ic4.js index 2520d8a5..22b12f6a 100644 --- a/src/test/bikes/ic4.js +++ b/src/test/bikes/ic4.js @@ -3,8 +3,9 @@ import {parse} from '../../bikes/ic4'; test('parse() parses Schwinn IC4 indoor bike data values', t => { const buf = Buffer.from('4402da020201220100', 'hex'); - const {power, cadence} = parse(buf); + const {power, cadence, speed} = parse(buf); t.equal(power, 290, 'power (watts)'); t.equal(cadence, 129, 'cadence (rpm)'); + t.equal(speed, 7.3, 'speed (km/h)'); t.end(); }); diff --git a/src/test/bikes/keiser.js b/src/test/bikes/keiser.js index 0eebaa37..ff851cf7 100644 --- a/src/test/bikes/keiser.js +++ b/src/test/bikes/keiser.js @@ -8,10 +8,11 @@ import {bikeVersion} from '../../bikes/keiser'; */ test('parse() parses Keiser indoor bike data values', t => { const buf = Buffer.from('0201063000383803460573000D00042701000A', 'hex'); - const {type, payload: {power, cadence}} = parse(buf); + const {type, payload: {power, cadence, speed}} = parse(buf); t.equal(type, 'stats', 'message type'); t.equal(power, 115, 'power (watts)'); t.equal(cadence, 82, 'cadence (rpm)'); + t.equal(speed, 23, 'speed (km/h)'); t.end(); }); diff --git a/src/test/bikes/peloton.js b/src/test/bikes/peloton.js index add39ada..78286bcc 100644 --- a/src/test/bikes/peloton.js +++ b/src/test/bikes/peloton.js @@ -1,12 +1,15 @@ import test from 'tape'; import {decodePeloton} from '../../bikes/peloton'; +import {calcPowerToSpeed} from '../../bikes/peloton'; test('decodePeloton() parses Peloton stats messages', t => { const bufPower = Buffer.from('f14405363333323038', 'hex'); const power = decodePeloton(bufPower, bufPower[2], true); const bufRPM = Buffer.from('f14103323930d0', 'hex'); const cadence = decodePeloton(bufRPM, bufRPM[2], false); + const speed = calcPowerToSpeed(233.6); t.equal(power, 233.6, 'power (watts)'); t.equal(cadence, 92, 'cadence (rpm)'); + t.equal(speed, 33, 'speed (km/h)'); t.end(); }); From 1f172fe7b65a0179b6966370bf26e392d143beeb Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Wed, 20 Oct 2021 10:07:29 -0700 Subject: [PATCH 20/29] Various fixes Various fixes --- src/app/app.js | 18 ++-- src/app/crankSimulation.js | 3 + src/app/wheelSimulation.js | 3 + src/bikes/keiser.js | 2 +- src/servers/ant/index.js | 100 ++++++++---------- .../characteristics/cycling-power-feature.js | 2 +- .../cycling-power-measurement.js | 17 +-- .../characteristics/csc-measurement.js | 34 +++--- 8 files changed, 96 insertions(+), 83 deletions(-) diff --git a/src/app/app.js b/src/app/app.js index ddcf8b3f..f02bf0f9 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -134,8 +134,8 @@ export class App { this.crank.revolutions++; let {power, crank, wheel, cadence} = this; this.logger.log(`pedal stroke [timestamp=${timestamp} revolutions=${crank.revolutions} cadence=${cadence}rpm power=${power}W]`); - this.server.updateMeasurement({ power, crank, wheel }); - this.antServer.updateMeasurement({ power, cadence, crank, wheel }); + this.server.updateMeasurement({ power, crank }); + this.antServer.updateMeasurement({ power, cadence, crank }); } onWheelRotation(timestamp) { @@ -143,16 +143,16 @@ export class App { this.wheel.timestamp = timestamp; this.wheel.revolutions++; let {power, crank, wheel, cadence} = this; - this.logger.log(`wheel rotation [timestamp=${timestamp} revolutions=${wheel.revolutions} cadence=${cadence}rpm power=${power}W]`); - this.server.updateMeasurement({ power, crank, wheel }); - this.antServer.updateMeasurement({ power, cadence, crank, wheel }); + this.logger.log(`wheel rotation [timestamp=${timestamp} revolutions=${wheel.revolutions} speed=${this.wheelSimulation.speed}km/h power=${power}W]`); + this.server.updateMeasurement({ power, wheel }); + this.antServer.updateMeasurement({ power, cadence, wheel }); } onPingInterval() { debuglog(`pinging app since no stats or pedal strokes for ${this.pingInterval.interval}s`); let {power, crank, wheel, cadence} = this; - this.server.updateMeasurement({ power, crank, wheel }); - this.antServer.updateMeasurement({ power, cadence, crank, wheel }); + this.server.updateMeasurement({ power }); + this.antServer.updateMeasurement({ power, cadence }); } onBikeStats({ power, cadence, speed }) { @@ -164,8 +164,8 @@ export class App { this.crankSimulation.cadence = cadence; this.wheelSimulation.speed = speed; let {crank, wheel} = this; - this.server.updateMeasurement({ power, crank, wheel }); - this.antServer.updateMeasurement({ power, cadence, crank, wheel }); + this.server.updateMeasurement({ power }); + this.antServer.updateMeasurement({ power, cadence }); } onBikeStatsTimeout() { diff --git a/src/app/crankSimulation.js b/src/app/crankSimulation.js index da2aa5c4..a7d5458a 100644 --- a/src/app/crankSimulation.js +++ b/src/app/crankSimulation.js @@ -1,5 +1,7 @@ import {EventEmitter} from 'events'; +const debuglog = require('debug')('gym:sim:crank'); + /** * Emit pedal stroke events at a rate that matches the given target cadence. * The target cadence can be updated on-the-fly. @@ -60,6 +62,7 @@ export class CrankSimulation extends EventEmitter { let timeSinceLast = now - this._lastPedalTime; let timeUntilNext = Math.max(0, this._interval - timeSinceLast); let nextPedalTime = now + timeUntilNext; + debuglog(`Crank Simulation: Interval=${this._interval} Next interval=${timeSinceLast+timeUntilNext} sinceLast=${timeSinceLast} untilNext=${timeUntilNext}`); this._timeoutId = setTimeout(() => { this.onPedal(nextPedalTime); this.schedulePedal(); diff --git a/src/app/wheelSimulation.js b/src/app/wheelSimulation.js index b3776c81..ee50de1a 100644 --- a/src/app/wheelSimulation.js +++ b/src/app/wheelSimulation.js @@ -1,5 +1,7 @@ import {EventEmitter} from 'events'; +const debuglog = require('debug')('gym:sim:wheel'); + /** * Emit wheel rotation events at a rate that matches the given target speed. * The target speed can be updated on-the-fly. @@ -63,6 +65,7 @@ export class WheelSimulation extends EventEmitter { let timeSinceLast = now - this._lastWheelTime; let timeUntilNext = Math.max(0, this._interval - timeSinceLast); let nextWheelTime = now + timeUntilNext; + debuglog(`Wheel Simulation: Interval=${this._interval} Next interval=${timeSinceLast+timeUntilNext} sinceLast=${timeSinceLast} untilNext=${timeUntilNext}`); this._timeoutId = setTimeout(() => { this.onWheel(nextWheelTime); this.scheduleWheel(); diff --git a/src/bikes/keiser.js b/src/bikes/keiser.js index 2d249dba..05eb56a2 100644 --- a/src/bikes/keiser.js +++ b/src/bikes/keiser.js @@ -13,7 +13,7 @@ const KEISER_VALUE_IDX_VER_MAJOR = 2; // 8-bit Version Major data offset within const KEISER_VALUE_IDX_VER_MINOR = 3; // 8-bit Version Major data offset within packet const KEISER_STATS_NEWVER_MINOR = 30; // Version Minor when broadcast interval was changed from ~ 2 sec to ~ 0.3 sec const KEISER_STATS_TIMEOUT_OLD = 7.0; // Old Bike: If no stats received within 7 sec, reset power and cadence to 0 -const KEISER_STATS_TIMEOUT_NEW = 1.0; // New Bike: If no stats received within 1 sec, reset power and cadence to 0 +const KEISER_STATS_TIMEOUT_NEW = 1.5; // New Bike: If no stats received within 1 sec, reset power and cadence to 0 const KEISER_BIKE_TIMEOUT = 60.0; // Consider bike disconnected if no stats have been received for 60 sec / 1 minutes const debuglog = require('debug')('gym:bikes:keiser'); diff --git a/src/servers/ant/index.js b/src/servers/ant/index.js index fb66b979..69402ec0 100644 --- a/src/servers/ant/index.js +++ b/src/servers/ant/index.js @@ -15,7 +15,7 @@ const SAC_DEVICE_NUMBER = 2; const SAC_PERIOD = 8086; // 8086/32768 ~4hz SPD+CDC const RF_CHANNEL = 57; // 2457 MHz -const PERIOD = 8182; +const PERIOD = 8192 / 2 ; // 8 Hz; Send PWR & SaC data on every other cycle const BROADCAST_INTERVAL = PERIOD / 32768; // seconds const defaults = { @@ -39,6 +39,8 @@ export class AntServer { const opts = {...defaults, ...options}; this.stick = antStick; this.pwr_deviceId = opts.deviceId; + this.broadcastCycle = 0; + this.pwr_channel = 1; this.power = 0; this.cadence = 0; @@ -119,7 +121,6 @@ export class AntServer { for (let pm of pwr_messages) { stick.write(pm); } - sleep(100); for (let scm of sac_messages) { stick.write(scm); } @@ -154,57 +155,50 @@ export class AntServer { * Broadcast instantaneous power, cadence and wheel revolution data. */ onBroadcastInterval() { - const {stick, pwr_channel, sac_channel, power, cadence} = this; - - // Build PWR broadcast message - this.accumulatedPower += power; - this.accumulatedPower &= 0xffff; - const pwr_data = [ - pwr_channel, - 0x10, // power only - this.eventCount, - 0xff, // pedal power not used - cadence, - ...Ant.Messages.intToLEHexArray(this.accumulatedPower, 2), - ...Ant.Messages.intToLEHexArray(power, 2), - ]; - const pwr_messages = [ - Ant.Messages.broadcastData(pwr_data), - ]; - debuglog(`ANT+ broadcast power power=${power}W cadence=${cadence}rpm accumulatedPower=${this.accumulatedPower}W eventCount=${this.eventCount}`); - - // Build SaC broadcast message - this.eventCount++; - this.eventCount &= 0xff; - const sac_data = [ - sac_channel, - ...Ant.Messages.intToLEHexArray(this.crankTimestamp, 2), // Last crank event Time - ...Ant.Messages.intToLEHexArray(this.crankRevolutions, 2), // Crank revolution Count - ...Ant.Messages.intToLEHexArray(this.wheelTimestamp, 2), // Last wheel event Time - ...Ant.Messages.intToLEHexArray(this.wheelRevolutions, 2), // Wheel revolution Count - ]; - const sac_messages = [ - Ant.Messages.broadcastData(sac_data), - ]; - debuglog(`ANT+ broadcast cadence revolutions=${this.crankRevolutions} cadence timestamp=${this.crankTimestamp} speed revolutions=${this.wheelRevolutions} timestamp=${this.wheelTimestamp}`); - - // Send broadcast messages - // Wait between PWR and SaC broadcast messages - for (let pm of pwr_messages) { - stick.write(pm); + const {stick, pwr_channel, sac_channel, power, cadence, broadcastCycle} = this; + + // Send PWR and SaC data alternating on every other 8 Hz cycle + if (broadcastCycle %2 == 0) { + // Build PWR broadcast message + this.accumulatedPower += power; + this.accumulatedPower &= 0xffff; + const pwr_data = [ + pwr_channel, + 0x10, // power only + this.eventCount, + 0xff, // pedal power not used + cadence, + ...Ant.Messages.intToLEHexArray(this.accumulatedPower, 2), + ...Ant.Messages.intToLEHexArray(power, 2), + ]; + this.eventCount++; + this.eventCount &= 0xff; + // Send broadcast messages + const pwr_messages = [ + Ant.Messages.broadcastData(pwr_data), + ]; + debuglog(`ANT+ broadcast power power=${power}W cadence=${cadence}rpm accumulatedPower=${this.accumulatedPower}W eventCount=${this.eventCount}`); + for (let pm of pwr_messages) { + stick.write(pm); + } + } else { + // Build SaC broadcast message + const sac_data = [ + sac_channel, + ...Ant.Messages.intToLEHexArray(this.crankTimestamp, 2), // Last crank event Time + ...Ant.Messages.intToLEHexArray(this.crankRevolutions, 2), // Crank revolution Count + ...Ant.Messages.intToLEHexArray(this.wheelTimestamp, 2), // Last wheel event Time + ...Ant.Messages.intToLEHexArray(this.wheelRevolutions, 2), // Wheel revolution Count + ]; + const sac_messages = [ + Ant.Messages.broadcastData(sac_data), + ]; + // Send broadcast messages + debuglog(`ANT+ broadcast cadence revolutions=${this.crankRevolutions} cadence timestamp=${this.crankTimestamp} speed revolutions=${this.wheelRevolutions} timestamp=${this.wheelTimestamp}`); + for (let scm of sac_messages) { + stick.write(scm); + } } - sleep(100); - for (let scm of sac_messages) { - stick.write(scm); - } - debuglog(`ANT+ broadcast complete`); + this.broadcastCycle++; } } - -export function sleep(milliseconds) { - const date = Date.now(); - let currentDate = null; - do { - currentDate = Date.now(); - } while (currentDate - date < milliseconds); -} diff --git a/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js b/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js index 3678c9c6..3c59bafc 100644 --- a/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js +++ b/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js @@ -14,7 +14,7 @@ export class CyclingPowerFeatureCharacteristic extends Characteristic { value: 'Cycling Power Feature' }) ], - value: Buffer.from([8,0,0,0]) // crank revolution data + value: Buffer.from([12,0,0,0]) // wheel revolution data + crank revolution data }) } } diff --git a/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js index ece95bb7..3ec70f52 100644 --- a/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js +++ b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js @@ -38,25 +38,30 @@ export class CyclingPowerMeasurementCharacteristic extends Characteristic { */ updateMeasurement({ power, crank, wheel }) { let flags = 0; + let debugOutput = ""; - const value = Buffer.alloc(14); + const value = Buffer.alloc(10); value.writeInt16LE(power, 2); - if (crank && wheel) { + if (wheel) { const wheelRevolutions32bit = wheel.revolutions & 0xffffffff; const wheelTimestamp16bit = Math.round(wheel.timestamp * WHEEL_TIMESTAMP_SCALE) & 0xffff; value.writeUInt32LE(wheelRevolutions32bit, 4); value.writeUInt16LE(wheelTimestamp16bit, 8); + flags |= FLAG_HASSPEEDDATA; + debugOutput += ` wheel revolutions=${wheelRevolutions32bit} wheel timestamp=${wheelTimestamp16bit}` + } + else if (crank) { const crankRevolutions16bit = crank.revolutions & 0xffff; const crankTimestamp16bit = Math.round(crank.timestamp * CRANK_TIMESTAMP_SCALE) & 0xffff; - value.writeUInt16LE(crankRevolutions16bit, 10); - value.writeUInt16LE(crankTimestamp16bit, 12); + value.writeUInt16LE(crankRevolutions16bit, 4); + value.writeUInt16LE(crankTimestamp16bit, 6); + debugOutput += ` crank revolutions=${crankRevolutions16bit} crank timestamp=${crankTimestamp16bit}` flags |= FLAG_HASCRANKDATA; - flags |= FLAG_HASSPEEDDATA; } value.writeUInt16LE(flags, 0); - debuglog(`BLE broadcast PWR power=${power} message=${value.toString('hex')}`); + debuglog(`BLE broadcast PWR power=${power}${debugOutput} message=${value.toString('hex')}`); if (this.updateValueCallback) { this.updateValueCallback(value) } diff --git a/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js b/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js index 6ca5fabc..177a3b50 100644 --- a/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js +++ b/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js @@ -36,28 +36,36 @@ export class CscMeasurementCharacteristic extends Characteristic { * @param {number} measurement.wheel.timestamp - timestamp at last wheel event. */ updateMeasurement({ crank, wheel }) { - if (crank && wheel) { - let flags = 0; - const value = Buffer.alloc(11); + let flags = 0; + let debugOutput = ""; + if ((!crank) && (!wheel)) { + return; + } + + const value = Buffer.alloc(7); + + if (wheel) { const wheelRevolutions32bit = wheel.revolutions & 0xffffffff; const wheelTimestamp16bit = Math.round(wheel.timestamp * WHEEL_TIMESTAMP_SCALE) & 0xffff; value.writeUInt32LE(wheelRevolutions32bit, 1); value.writeUInt16LE(wheelTimestamp16bit, 5); - + flags |= FLAG_HASSPEEDDATA; + debugOutput += ` wheel revolutions=${wheelRevolutions32bit} wheel timestamp=${wheelTimestamp16bit}` + } + else if (crank) { const crankRevolutions16bit = crank.revolutions & 0xffff; const crankTimestamp16bit = Math.round(crank.timestamp * CRANK_TIMESTAMP_SCALE) & 0xffff; - value.writeUInt16LE(crankRevolutions16bit, 7); - value.writeUInt16LE(crankTimestamp16bit, 9); - - flags |= FLAG_HASSPEEDDATA; + value.writeUInt16LE(crankRevolutions16bit, 1); + value.writeUInt16LE(crankTimestamp16bit, 3); + debugOutput += ` crank revolutions=${crankRevolutions16bit} crank timestamp=${crankTimestamp16bit}` flags |= FLAG_HASCRANKDATA; + } - value.writeUInt8(flags, 0); - debuglog(`BLE broadcast CSC wheel revolutions=${wheelRevolutions32bit} wheel timestamp=${wheelTimestamp16bit} crank revolutions=${crankRevolutions16bit} crank timestamp=${crankTimestamp16bit} message=${value.toString('hex')}`); - if (this.updateValueCallback) { - this.updateValueCallback(value) - } + value.writeUInt8(flags, 0); + debuglog(`BLE broadcast SPD+CDC${debugOutput} message=${value.toString('hex')}`); + if (this.updateValueCallback) { + this.updateValueCallback(value) } } } From ec5f1e1d77666a63f28fb4cef37933a9f0393e40 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Wed, 20 Oct 2021 14:36:57 -0700 Subject: [PATCH 21/29] Fixed Keiser power drops --- src/bikes/keiser.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bikes/keiser.js b/src/bikes/keiser.js index 9d6db521..a4ce3cb6 100644 --- a/src/bikes/keiser.js +++ b/src/bikes/keiser.js @@ -13,7 +13,7 @@ const KEISER_VALUE_IDX_VER_MAJOR = 2; // 8-bit Version Major data offset within const KEISER_VALUE_IDX_VER_MINOR = 3; // 8-bit Version Major data offset within packet const KEISER_STATS_NEWVER_MINOR = 30; // Version Minor when broadcast interval was changed from ~ 2 sec to ~ 0.3 sec const KEISER_STATS_TIMEOUT_OLD = 7.0; // Old Bike: If no stats received within 7 sec, reset power and cadence to 0 -const KEISER_STATS_TIMEOUT_NEW = 1.5; // New Bike: If no stats received within 1 sec, reset power and cadence to 0 +const KEISER_STATS_TIMEOUT_NEW = 2; // New Bike: If no stats received within 2 sec, reset power and cadence to 0 const KEISER_BIKE_TIMEOUT = 60.0; // Consider bike disconnected if no stats have been received for 60 sec / 1 minutes const debuglog = require('debug')('gym:bikes:keiser'); From 14c1b3e8c4fa5f7231695ed04b2713cd1393f489 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Wed, 20 Oct 2021 14:38:35 -0700 Subject: [PATCH 22/29] Fixed Keiser power drop test case --- src/test/bikes/keiser.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/bikes/keiser.js b/src/test/bikes/keiser.js index ff851cf7..2c9a8759 100644 --- a/src/test/bikes/keiser.js +++ b/src/test/bikes/keiser.js @@ -20,7 +20,7 @@ test('bikeVersion() Tests Keiser bike version (6.40)', t => { const bufver = Buffer.from('0201064000383803460573000D00042701000A', 'hex'); const {version, timeout} = bikeVersion(bufver); t.equal(version, '6.40', 'Version: 6.40'); - t.equal(timeout, 1, 'Timeout: 1 second'); + t.equal(timeout, 2, 'Timeout: 2 second'); t.end(); }); @@ -28,7 +28,7 @@ test('bikeVersion() Tests Keiser bike version (6.30)', t => { const bufver = Buffer.from('0201063000383803460573000D00042701000A', 'hex'); const {version, timeout} = bikeVersion(bufver); t.equal(version, '6.30', 'Version: 6.30'); - t.equal(timeout, 1, 'Timeout: 1 second'); + t.equal(timeout, 2, 'Timeout: 2 second'); t.end(); }); From f17569d54d56a2a54cc0c30412aa4c2e233092c1 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Thu, 21 Oct 2021 08:50:25 -0700 Subject: [PATCH 23/29] Fixed Peloton and Keiser speed calculation --- src/bikes/keiser.js | 4 ++-- src/bikes/peloton.js | 4 ++-- src/servers/ant/index.js | 4 ++-- src/test/bikes/keiser.js | 2 +- src/test/bikes/peloton.js | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/bikes/keiser.js b/src/bikes/keiser.js index a4ce3cb6..6279e44e 100644 --- a/src/bikes/keiser.js +++ b/src/bikes/keiser.js @@ -222,9 +222,9 @@ export function calcPowerToSpeed(power) { let speed = 0; const r = Math.sqrt(power); if (power < 26) { - speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 + speed = (0.057 - 0.172 * r + 0.759 * Math.pow(r,2) - 0.079 * Math.pow(r,3)).toFixed(2); } else { - speed = -1.635 + 2.325 * r - 0.064 * r^2 + 0.001 * r^3 + speed = (-1.635 + 2.325 * r - 0.064 * Math.pow(r,2) + 0.001 * Math.pow(r,3)).toFixed(2); } return speed; } diff --git a/src/bikes/peloton.js b/src/bikes/peloton.js index b78adf3a..5d20a064 100644 --- a/src/bikes/peloton.js +++ b/src/bikes/peloton.js @@ -171,9 +171,9 @@ export function calcPowerToSpeed(power) { let speed = 0; const r = Math.sqrt(power); if (power < 26) { - speed = 0.057 - 0.172 * r + 0.759 * r^2 - 0.079 * r^3 + speed = (0.057 - 0.172 * r + 0.759 * Math.pow(r,2) - 0.079 * Math.pow(r,3)).toFixed(2); } else { - speed = -1.635 + 2.325 * r - 0.064 * r^2 + 0.001 * r^3 + speed = (-1.635 + 2.325 * r - 0.064 * Math.pow(r,2) + 0.001 * Math.pow(r,3)).toFixed(2); } return speed; } diff --git a/src/servers/ant/index.js b/src/servers/ant/index.js index 69402ec0..ba0de31b 100644 --- a/src/servers/ant/index.js +++ b/src/servers/ant/index.js @@ -68,7 +68,7 @@ export class AntServer { // Initialize PWR channel const pwr_messages = [ - Ant.Messages.assignChannel(pwr_channel, 'transmit_only'), + Ant.Messages.assignChannel(pwr_channel, 'transmit'), Ant.Messages.setDevice(pwr_channel, pwr_deviceId, PWR_DEVICE_TYPE, PWR_DEVICE_NUMBER), Ant.Messages.setFrequency(pwr_channel, RF_CHANNEL), Ant.Messages.setPeriod(pwr_channel, PWR_PERIOD), @@ -81,7 +81,7 @@ export class AntServer { // Initialize SaC channel const sac_messages = [ - Ant.Messages.assignChannel(sac_channel, 'transmit_only'), + Ant.Messages.assignChannel(sac_channel, 'transmit'), Ant.Messages.setDevice(sac_channel, sac_deviceId, SAC_DEVICE_TYPE, SAC_DEVICE_NUMBER), Ant.Messages.setFrequency(sac_channel, RF_CHANNEL), Ant.Messages.setPeriod(sac_channel, SAC_PERIOD), diff --git a/src/test/bikes/keiser.js b/src/test/bikes/keiser.js index 2c9a8759..b7300cc0 100644 --- a/src/test/bikes/keiser.js +++ b/src/test/bikes/keiser.js @@ -12,7 +12,7 @@ test('parse() parses Keiser indoor bike data values', t => { t.equal(type, 'stats', 'message type'); t.equal(power, 115, 'power (watts)'); t.equal(cadence, 82, 'cadence (rpm)'); - t.equal(speed, 23, 'speed (km/h)'); + t.equal(speed, 17.17, 'speed (km/h)'); t.end(); }); diff --git a/src/test/bikes/peloton.js b/src/test/bikes/peloton.js index 78286bcc..e3f1af83 100644 --- a/src/test/bikes/peloton.js +++ b/src/test/bikes/peloton.js @@ -10,6 +10,6 @@ test('decodePeloton() parses Peloton stats messages', t => { const speed = calcPowerToSpeed(233.6); t.equal(power, 233.6, 'power (watts)'); t.equal(cadence, 92, 'cadence (rpm)'); - t.equal(speed, 33, 'speed (km/h)'); + t.equal(speed, 22.52, 'speed (km/h)'); t.end(); }); From 86ab9cc4f02cd27030b081f8b11a9012ae629eff Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Thu, 21 Oct 2021 08:56:11 -0700 Subject: [PATCH 24/29] Fixed typo in test --- src/test/bikes/keiser.js | 2 +- src/test/bikes/peloton.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/bikes/keiser.js b/src/test/bikes/keiser.js index b7300cc0..0420fe2c 100644 --- a/src/test/bikes/keiser.js +++ b/src/test/bikes/keiser.js @@ -12,7 +12,7 @@ test('parse() parses Keiser indoor bike data values', t => { t.equal(type, 'stats', 'message type'); t.equal(power, 115, 'power (watts)'); t.equal(cadence, 82, 'cadence (rpm)'); - t.equal(speed, 17.17, 'speed (km/h)'); + t.equal(speed, '17.17', 'speed (km/h)'); t.end(); }); diff --git a/src/test/bikes/peloton.js b/src/test/bikes/peloton.js index e3f1af83..09e37e4a 100644 --- a/src/test/bikes/peloton.js +++ b/src/test/bikes/peloton.js @@ -10,6 +10,6 @@ test('decodePeloton() parses Peloton stats messages', t => { const speed = calcPowerToSpeed(233.6); t.equal(power, 233.6, 'power (watts)'); t.equal(cadence, 92, 'cadence (rpm)'); - t.equal(speed, 22.52, 'speed (km/h)'); + t.equal(speed, '22.52', 'speed (km/h)'); t.end(); }); From 955385b7ff7859a385900ec114721929c45f9859 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Thu, 21 Oct 2021 11:04:12 -0700 Subject: [PATCH 25/29] Fixed mph to km/h conversion for Power->Speed calc in Peloton and Keiser --- src/bikes/keiser.js | 4 ++-- src/bikes/peloton.js | 4 ++-- src/test/bikes/keiser.js | 2 +- src/test/bikes/peloton.js | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/bikes/keiser.js b/src/bikes/keiser.js index 6279e44e..93b79474 100644 --- a/src/bikes/keiser.js +++ b/src/bikes/keiser.js @@ -222,9 +222,9 @@ export function calcPowerToSpeed(power) { let speed = 0; const r = Math.sqrt(power); if (power < 26) { - speed = (0.057 - 0.172 * r + 0.759 * Math.pow(r,2) - 0.079 * Math.pow(r,3)).toFixed(2); + speed = ( ( 0.057 - 0.172 * r + 0.759 * Math.pow(r,2) - 0.079 * Math.pow(r,3)) * 1.609344 ).toFixed(2); } else { - speed = (-1.635 + 2.325 * r - 0.064 * Math.pow(r,2) + 0.001 * Math.pow(r,3)).toFixed(2); + speed = ( ( -1.635 + 2.325 * r - 0.064 * Math.pow(r,2) + 0.001 * Math.pow(r,3)) * 1.609344 ).toFixed(2); } return speed; } diff --git a/src/bikes/peloton.js b/src/bikes/peloton.js index 5d20a064..51052c7a 100644 --- a/src/bikes/peloton.js +++ b/src/bikes/peloton.js @@ -171,9 +171,9 @@ export function calcPowerToSpeed(power) { let speed = 0; const r = Math.sqrt(power); if (power < 26) { - speed = (0.057 - 0.172 * r + 0.759 * Math.pow(r,2) - 0.079 * Math.pow(r,3)).toFixed(2); + speed = ( ( 0.057 - 0.172 * r + 0.759 * Math.pow(r,2) - 0.079 * Math.pow(r,3)) * 1.609344 ).toFixed(2); } else { - speed = (-1.635 + 2.325 * r - 0.064 * Math.pow(r,2) + 0.001 * Math.pow(r,3)).toFixed(2); + speed = ( ( -1.635 + 2.325 * r - 0.064 * Math.pow(r,2) + 0.001 * Math.pow(r,3)) * 1.609344 ).toFixed(2); } return speed; } diff --git a/src/test/bikes/keiser.js b/src/test/bikes/keiser.js index 0420fe2c..55dbf54c 100644 --- a/src/test/bikes/keiser.js +++ b/src/test/bikes/keiser.js @@ -12,7 +12,7 @@ test('parse() parses Keiser indoor bike data values', t => { t.equal(type, 'stats', 'message type'); t.equal(power, 115, 'power (watts)'); t.equal(cadence, 82, 'cadence (rpm)'); - t.equal(speed, '17.17', 'speed (km/h)'); + t.equal(speed, '27.63', 'speed (km/h)'); t.end(); }); diff --git a/src/test/bikes/peloton.js b/src/test/bikes/peloton.js index 09e37e4a..b38b6693 100644 --- a/src/test/bikes/peloton.js +++ b/src/test/bikes/peloton.js @@ -10,6 +10,6 @@ test('decodePeloton() parses Peloton stats messages', t => { const speed = calcPowerToSpeed(233.6); t.equal(power, 233.6, 'power (watts)'); t.equal(cadence, 92, 'cadence (rpm)'); - t.equal(speed, '22.52', 'speed (km/h)'); + t.equal(speed, '36.24', 'speed (km/h)'); t.end(); }); From b8b654544cdd1785a7050facc9afb7dbd35ca16b Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Sat, 23 Oct 2021 15:35:50 -0700 Subject: [PATCH 26/29] Corrected BLE CPS Wheel Revolution unit resolution --- .../cycling-power/characteristics/cycling-power-measurement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js index 3ec70f52..8430def4 100644 --- a/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js +++ b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js @@ -5,7 +5,7 @@ const debuglog = require('debug')('gym:servers:ble'); const FLAG_HASCRANKDATA = (1<<5); const FLAG_HASSPEEDDATA = (1<<4); const CRANK_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec -const WHEEL_TIMESTAMP_SCALE = 1024 / 1000; // timestamp resolution is 1/1024 sec +const WHEEL_TIMESTAMP_SCALE = 2048 / 1000; // timestamp resolution is 1/2048 sec /** * Bluetooth LE GATT Cycling Power Measurement Characteristic implementation. From 377a507196a32761cab0d7a6002025ed94dc9509 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Sun, 24 Oct 2021 16:11:28 -0700 Subject: [PATCH 27/29] Remove speed from ANT BLE CPS --- src/app/app.js | 6 ++--- .../characteristics/cycling-power-feature.js | 3 ++- .../cycling-power-measurement.js | 21 ++++++++------- .../characteristics/csc-measurement.js | 26 ++++++++----------- 4 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/app/app.js b/src/app/app.js index f02bf0f9..4a1c2faf 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -134,7 +134,7 @@ export class App { this.crank.revolutions++; let {power, crank, wheel, cadence} = this; this.logger.log(`pedal stroke [timestamp=${timestamp} revolutions=${crank.revolutions} cadence=${cadence}rpm power=${power}W]`); - this.server.updateMeasurement({ power, crank }); + //this.server.updateMeasurement({ power, crank, wheel }); this.antServer.updateMeasurement({ power, cadence, crank }); } @@ -144,14 +144,14 @@ export class App { this.wheel.revolutions++; let {power, crank, wheel, cadence} = this; this.logger.log(`wheel rotation [timestamp=${timestamp} revolutions=${wheel.revolutions} speed=${this.wheelSimulation.speed}km/h power=${power}W]`); - this.server.updateMeasurement({ power, wheel }); + //this.server.updateMeasurement({ power, crank, wheel }); this.antServer.updateMeasurement({ power, cadence, wheel }); } onPingInterval() { debuglog(`pinging app since no stats or pedal strokes for ${this.pingInterval.interval}s`); let {power, crank, wheel, cadence} = this; - this.server.updateMeasurement({ power }); + this.server.updateMeasurement({ power, crank, wheel }); this.antServer.updateMeasurement({ power, cadence }); } diff --git a/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js b/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js index 3c59bafc..5c8aaef0 100644 --- a/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js +++ b/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js @@ -14,7 +14,8 @@ export class CyclingPowerFeatureCharacteristic extends Characteristic { value: 'Cycling Power Feature' }) ], - value: Buffer.from([12,0,0,0]) // wheel revolution data + crank revolution data + //value: Buffer.from([12,0,0,0]) // wheel revolution data + crank revolution data + value: Buffer.from([8,0,0,0]) // crank revolution data }) } } diff --git a/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js index 8430def4..7d49eef2 100644 --- a/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js +++ b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js @@ -40,18 +40,19 @@ export class CyclingPowerMeasurementCharacteristic extends Characteristic { let flags = 0; let debugOutput = ""; - const value = Buffer.alloc(10); + const value = Buffer.alloc(8); value.writeInt16LE(power, 2); - if (wheel) { - const wheelRevolutions32bit = wheel.revolutions & 0xffffffff; - const wheelTimestamp16bit = Math.round(wheel.timestamp * WHEEL_TIMESTAMP_SCALE) & 0xffff; - value.writeUInt32LE(wheelRevolutions32bit, 4); - value.writeUInt16LE(wheelTimestamp16bit, 8); - flags |= FLAG_HASSPEEDDATA; - debugOutput += ` wheel revolutions=${wheelRevolutions32bit} wheel timestamp=${wheelTimestamp16bit}` - } - else if (crank) { +// if (wheel) { +// const wheelRevolutions32bit = wheel.revolutions & 0xffffffff; +// const wheelTimestamp16bit = Math.round(wheel.timestamp * WHEEL_TIMESTAMP_SCALE) & 0xffff; +// value.writeUInt32LE(wheelRevolutions32bit, 4); +// value.writeUInt16LE(wheelTimestamp16bit, 8); +// flags |= FLAG_HASSPEEDDATA; +// debugOutput += ` wheel revolutions=${wheelRevolutions32bit} wheel timestamp=${wheelTimestamp16bit}` +// } +// else if (crank) { + if (crank) { const crankRevolutions16bit = crank.revolutions & 0xffff; const crankTimestamp16bit = Math.round(crank.timestamp * CRANK_TIMESTAMP_SCALE) & 0xffff; value.writeUInt16LE(crankRevolutions16bit, 4); diff --git a/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js b/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js index 177a3b50..7799c4f2 100644 --- a/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js +++ b/src/servers/ble/services/cycling-speed-and-cadence/characteristics/csc-measurement.js @@ -39,33 +39,29 @@ export class CscMeasurementCharacteristic extends Characteristic { let flags = 0; let debugOutput = ""; - if ((!crank) && (!wheel)) { - return; - } + if ( crank && wheel ) { - const value = Buffer.alloc(7); + const value = Buffer.alloc(11); - if (wheel) { const wheelRevolutions32bit = wheel.revolutions & 0xffffffff; const wheelTimestamp16bit = Math.round(wheel.timestamp * WHEEL_TIMESTAMP_SCALE) & 0xffff; value.writeUInt32LE(wheelRevolutions32bit, 1); value.writeUInt16LE(wheelTimestamp16bit, 5); flags |= FLAG_HASSPEEDDATA; debugOutput += ` wheel revolutions=${wheelRevolutions32bit} wheel timestamp=${wheelTimestamp16bit}` - } - else if (crank) { + const crankRevolutions16bit = crank.revolutions & 0xffff; const crankTimestamp16bit = Math.round(crank.timestamp * CRANK_TIMESTAMP_SCALE) & 0xffff; - value.writeUInt16LE(crankRevolutions16bit, 1); - value.writeUInt16LE(crankTimestamp16bit, 3); + value.writeUInt16LE(crankRevolutions16bit, 7); + value.writeUInt16LE(crankTimestamp16bit, 9); debugOutput += ` crank revolutions=${crankRevolutions16bit} crank timestamp=${crankTimestamp16bit}` flags |= FLAG_HASCRANKDATA; - } - - value.writeUInt8(flags, 0); - debuglog(`BLE broadcast SPD+CDC${debugOutput} message=${value.toString('hex')}`); - if (this.updateValueCallback) { - this.updateValueCallback(value) + value.writeUInt8(flags, 0); + + debuglog(`BLE broadcast SPD+CDC${debugOutput} message=${value.toString('hex')}`); + if (this.updateValueCallback) { + this.updateValueCallback(value) + } } } } From da3cbfec8f1d2b970b15c44f5202f4e35c41372d Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Sun, 24 Oct 2021 16:23:22 -0700 Subject: [PATCH 28/29] Re-Enable BLE CSC --- src/app/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/app.js b/src/app/app.js index 4a1c2faf..aef6639e 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -164,7 +164,7 @@ export class App { this.crankSimulation.cadence = cadence; this.wheelSimulation.speed = speed; let {crank, wheel} = this; - this.server.updateMeasurement({ power }); + this.server.updateMeasurement({ power, crank, wheel }); this.antServer.updateMeasurement({ power, cadence }); } From 6b372bde5c9351243943b527b2fc862bc4a036d0 Mon Sep 17 00:00:00 2001 From: Christian Elsen Date: Thu, 8 Sep 2022 14:47:34 -0700 Subject: [PATCH 29/29] Support for speed based offset, independently of power based offset. --- src/app/app.js | 8 ++++++++ src/app/cli-options.js | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/app/app.js b/src/app/app.js index aef6639e..febd0667 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -48,6 +48,10 @@ export const defaults = { // power adjustment (to compensate for inaccurate power measurements on bike) powerScale: 1.0, // multiply power by this powerOffset: 0.0, // add this to power + + // speed adjustment (to compensate for inaccurate speed measurements on bike) + speedScale: 1.0, // multiply speed by this + speedOffset: 0.0, // add this to speed }; /** @@ -90,6 +94,9 @@ export class App { this.connectTimeout = new Timer(opts.bikeConnectTimeout, {repeats: false}); this.powerScale = opts.powerScale; this.powerOffset = opts.powerOffset; + this.speedScale = opts.speedScale; + this.speedOffset = opts.speedOffset; + this.pingInterval.on('timeout', this.onPingInterval.bind(this)); this.statsTimeout.on('timeout', this.onBikeStatsTimeout.bind(this)); @@ -157,6 +164,7 @@ export class App { onBikeStats({ power, cadence, speed }) { power = power > 0 ? Math.max(0, Math.round(power * this.powerScale + this.powerOffset)) : 0; + speed = speed > 0 ? Math.max(0, Math.round(speed * this.speedScale + this.speedOffset)) : 0; this.logger.log(`received stats from bike [power=${power}W cadence=${cadence}rpm speed=${speed}km/h]`); this.statsTimeout.reset(); this.power = power; diff --git a/src/app/cli-options.js b/src/app/cli-options.js index 19c4bc4e..94f63d13 100644 --- a/src/app/cli-options.js +++ b/src/app/cli-options.js @@ -92,5 +92,15 @@ export const options = { describe: ' add this value to watts', type: 'number', default: defaults.powerOffset, + }, + 'speed-scale': { + describe: ' scale speed by this multiplier', + type: 'number', + default: defaults.speedScale, + }, + 'speed-offset': { + describe: ' add this value to speed', + type: 'number', + default: defaults.speedOffset, } };