diff --git a/src/app/app.js b/src/app/app.js index 4bfb279c..febd0667 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -6,7 +6,8 @@ import {once} from 'events'; import {GymnasticonServer} from '../servers/ble'; import {AntServer} from '../servers/ant'; 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 +33,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, @@ -46,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 }; /** @@ -63,7 +69,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,7 +81,8 @@ 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); @@ -85,11 +94,15 @@ 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)); 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,25 +139,40 @@ export class App { this.pingInterval.reset(); this.crank.timestamp = timestamp; this.crank.revolutions++; - let {power, crank} = this; - this.logger.log(`pedal stroke [timestamp=${timestamp} revolutions=${crank.revolutions} power=${power}W]`); - this.server.updateMeasurement({ power, crank }); + 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 }); + } + + 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} speed=${this.wheelSimulation.speed}km/h power=${power}W]`); + //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} = this; - this.server.updateMeasurement({ power, crank }); + let {power, crank, wheel, cadence} = this; + this.server.updateMeasurement({ power, crank, wheel }); + this.antServer.updateMeasurement({ power, cadence }); } - 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]`); + 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; - this.simulation.cadence = cadence; - let {crank} = this; - this.server.updateMeasurement({ power, crank }); + this.cadence = cadence; + this.crankSimulation.cadence = cadence; + this.wheelSimulation.speed = speed; + let {crank, wheel} = this; + this.server.updateMeasurement({ power, crank, wheel }); this.antServer.updateMeasurement({ power, cadence }); } diff --git a/src/app/cli-options.js b/src/app/cli-options.js index 791c9ee9..94f63d13 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', @@ -87,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, } }; diff --git a/src/app/simulation.js b/src/app/crankSimulation.js similarity index 85% rename from src/app/simulation.js rename to src/app/crankSimulation.js index 843acdd9..a7d5458a 100644 --- a/src/app/simulation.js +++ b/src/app/crankSimulation.js @@ -1,10 +1,12 @@ 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. */ -export class Simulation extends EventEmitter { +export class CrankSimulation extends EventEmitter { constructor() { super(); this._cadence = 0; @@ -60,6 +62,7 @@ export class Simulation 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 new file mode 100644 index 00000000..ee50de1a --- /dev/null +++ b/src/app/wheelSimulation.js @@ -0,0 +1,74 @@ +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. + */ + +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; + debuglog(`Wheel Simulation: Interval=${this._interval} Next interval=${timeSinceLast+timeUntilNext} sinceLast=${timeSinceLast} untilNext=${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..dbe56bc5 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,7 +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); - 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/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..93b79474 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 = 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'); @@ -208,9 +208,23 @@ 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 = calcPowerToSpeed(power); + + 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/ + 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)) * 1.609344 ).toFixed(2); + } else { + 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 72692ee0..51052c7a 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}); @@ -84,7 +85,8 @@ export class PelotonBikeClient extends EventEmitter { */ onStatsUpdate() { const {power, cadence} = this; - this.emit('stats', {power, cadence}); + const speed = calcPowerToSpeed(power); + this.emit('stats', {power, cadence, speed}); } onSerialMessage(data) { @@ -117,6 +119,7 @@ export class PelotonBikeClient extends EventEmitter { onStatsTimeout() { this.power = 0; this.cadence = 0; + this.speed = 0; tracelog("StatsTimeout exceeded"); this.onStatsUpdate(); } @@ -161,3 +164,16 @@ 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/ + 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)) * 1.609344 ).toFixed(2); + } else { + 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/servers/ant/index.js b/src/servers/ant/index.js index 306d7c59..ba0de31b 100644 --- a/src/servers/ant/index.js +++ b/src/servers/ant/index.js @@ -3,20 +3,29 @@ import {Timer} from '../../util/timer'; const debuglog = require('debug')('gym:servers:ant'); -const DEVICE_TYPE = 0x0b; // power meter -const DEVICE_NUMBER = 1; -const PERIOD = 8182; // 8182/32768 ~4hz +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 Sensor +const PWR_DEVICE_NUMBER = 1; +const PWR_PERIOD = 8182; // 8182/32768 ~4hz PWR + +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 = 8192 / 2 ; // 8 Hz; Send PWR & SaC data on every other cycle 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). + * profile (instantaneous cadence and power), as well as ANT+ Bicycle Speed + * and Cadence profile (wheel rotations and timestamp). */ export class AntServer { /** @@ -29,13 +38,21 @@ export class AntServer { 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.pwr_deviceId = opts.deviceId; + this.broadcastCycle = 0; + this.pwr_channel = 1; this.power = 0; this.cadence = 0; + this.eventCount = 0; + this.accumulatedPower = 0; + + this.sac_deviceId = opts.deviceId + 1; + this.sac_channel = 2; + this.wheelRevolutions = 0; + this.wheelTimestamp = 0; + this.crankRevolutions = 0; + this.crankTimestamp = 0; this.broadcastInterval = new Timer(BROADCAST_INTERVAL); this.broadcastInterval.on('timeout', this.onBroadcastInterval.bind(this)); @@ -47,17 +64,32 @@ export class AntServer { * Start the ANT+ server (setup channel and start broadcasting). */ start() { - const {stick, channel, deviceId} = this; - const messages = [ - Ant.Messages.assignChannel(channel, 'transmit'), - Ant.Messages.setDevice(channel, deviceId, DEVICE_TYPE, DEVICE_NUMBER), - Ant.Messages.setFrequency(channel, RF_CHANNEL), - Ant.Messages.setPeriod(channel, PERIOD), - Ant.Messages.openChannel(channel), + const {stick, pwr_channel, sac_channel, pwr_deviceId, sac_deviceId} = this; + + // Initialize PWR channel + const pwr_messages = [ + 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), + 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); + } + + // Initialize SaC channel + const sac_messages = [ + 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), + Ant.Messages.openChannel(sac_channel), ]; - debuglog(`ANT+ server start [deviceId=${deviceId} channel=${channel}]`); - for (let m of messages) { - stick.write(m); + 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; @@ -71,48 +103,102 @@ export class AntServer { * Stop the ANT+ server (stop broadcasting and unassign channel). */ stop() { - const {stick, channel} = this; + const {stick, pwr_channel, sac_channel} = this; this.broadcastInterval.cancel(); - const messages = [ - Ant.Messages.closeChannel(channel), - Ant.Messages.unassignChannel(channel), + + 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), ]; - for (let m of messages) { - stick.write(m); + + // Close PWR and SaC channels + // Wait between PWR and SaC close messages + for (let pm of pwr_messages) { + stick.write(pm); + } + for (let scm of sac_messages) { + stick.write(scm); } } /** - * Update instantaneous power and cadence. + * 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.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 }) { + 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; + } } /** - * Broadcast instantaneous power and cadence. + * Broadcast instantaneous power, cadence and wheel revolution data. */ 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}W cadence=${cadence}rpm accumulatedPower=${this.accumulatedPower}W eventCount=${this.eventCount} message=${message.toString('hex')}`); - stick.write(message); - this.eventCount++; - this.eventCount &= 0xff; + 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); + } + } + this.broadcastCycle++; } } 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..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,6 +14,7 @@ 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([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 4e4b85f8..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 @@ -1,10 +1,15 @@ 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 = 2048 / 1000; // timestamp resolution is 1/2048 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() { @@ -24,27 +29,40 @@ 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; + let debugOutput = ""; const value = Buffer.alloc(8); value.writeInt16LE(power, 2); - // include crank data if provided +// 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 revolutions16bit = crank.revolutions & 0xffff; - const timestamp16bit = Math.round(crank.timestamp * CRANK_TIMESTAMP_SCALE) & 0xffff; - value.writeUInt16LE(revolutions16bit, 4); - value.writeUInt16LE(timestamp16bit, 6); + const crankRevolutions16bit = crank.revolutions & 0xffff; + const crankTimestamp16bit = Math.round(crank.timestamp * CRANK_TIMESTAMP_SCALE) & 0xffff; + value.writeUInt16LE(crankRevolutions16bit, 4); + value.writeUInt16LE(crankTimestamp16bit, 6); + debugOutput += ` crank revolutions=${crankRevolutions16bit} crank timestamp=${crankTimestamp16bit}` flags |= FLAG_HASCRANKDATA; } value.writeUInt16LE(flags, 0); - + 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-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..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 @@ -1,10 +1,15 @@ 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. + * 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() { @@ -26,22 +31,37 @@ 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 }) { + updateMeasurement({ crank, wheel }) { let flags = 0; + let debugOutput = ""; - const value = Buffer.alloc(5); + if ( crank && wheel ) { - 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 value = Buffer.alloc(11); - value.writeUInt8(flags, 0); + 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}` - if (this.updateValueCallback) { - this.updateValueCallback(value) + const crankRevolutions16bit = crank.revolutions & 0xffff; + const crankTimestamp16bit = Math.round(crank.timestamp * CRANK_TIMESTAMP_SCALE) & 0xffff; + 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) + } } } } 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/crankSimulation.js similarity index 97% rename from src/test/app/simulation.js rename to src/test/app/crankSimulation.js index 33c244e1..4c835935 100644 --- a/src/test/app/simulation.js +++ b/src/test/app/crankSimulation.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) { 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..55dbf54c 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, '27.63', 'speed (km/h)'); t.end(); }); @@ -19,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(); }); @@ -27,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(); }); diff --git a/src/test/bikes/peloton.js b/src/test/bikes/peloton.js index add39ada..b38b6693 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, '36.24', 'speed (km/h)'); t.end(); });