Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Experimental: ANT+ / BLE support for speed #89

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8897c61
Support for Speed data via ANT+ / BLE
chriselsen Oct 17, 2021
1976b64
Corrected CSC feature data
chriselsen Oct 17, 2021
455aab1
Fixed typo in test
chriselsen Oct 17, 2021
e6d381c
Updated BLE debug for CSC
chriselsen Oct 17, 2021
92f36da
Fixed wheel timestamp issue
chriselsen Oct 17, 2021
d3fa314
Test other channel period for ANT+ SPD
chriselsen Oct 17, 2021
b63909d
Test single ANT+ server
chriselsen Oct 18, 2021
2da4b9a
Sending SPD broadcast twice for better stability
chriselsen Oct 18, 2021
b115495
Fixed speed support in IC4 bike driver
chriselsen Oct 18, 2021
2281120
Various small fixes for ANT+ speed support
chriselsen Oct 18, 2021
71ca7cd
Merge branch 'antble-speed-testing' into antble-speed
chriselsen Oct 18, 2021
f686eab
Support for Wheel Revolution Data in BLE Cycling Power
chriselsen Oct 18, 2021
e96b151
Fixed typo
chriselsen Oct 18, 2021
36d333e
Added speed calculation support for Peleton
chriselsen Oct 18, 2021
208e4e0
Added speed support for Keiser based on Peleton formula
chriselsen Oct 18, 2021
7bfc0b3
Added speed support for Keiser
chriselsen Oct 18, 2021
6346caf
Fixed typo
chriselsen Oct 18, 2021
d1873b8
Added support for combined speed+cadence over ANT+
chriselsen Oct 19, 2021
7f54361
Fixed typo
chriselsen Oct 19, 2021
fe7f2d5
Added test cases for speed
chriselsen Oct 19, 2021
1f172fe
Various fixes
chriselsen Oct 20, 2021
351887f
Merge branch 'ant-testing' into antble-speed
chriselsen Oct 20, 2021
ec5f1e1
Fixed Keiser power drops
chriselsen Oct 20, 2021
14c1b3e
Fixed Keiser power drop test case
chriselsen Oct 20, 2021
f17569d
Fixed Peloton and Keiser speed calculation
chriselsen Oct 21, 2021
86ab9cc
Fixed typo in test
chriselsen Oct 21, 2021
955385b
Fixed mph to km/h conversion for Power->Speed calc in Peloton and Keiser
chriselsen Oct 21, 2021
b8b6545
Corrected BLE CPS Wheel Revolution unit resolution
chriselsen Oct 23, 2021
377a507
Remove speed from ANT BLE CPS
chriselsen Oct 24, 2021
da3cbfe
Re-Enable BLE CSC
chriselsen Oct 24, 2021
6b372bd
Support for speed based offset, independently of power based offset.
chriselsen Sep 8, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 41 additions & 13 deletions src/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,

Expand All @@ -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
};

/**
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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 });
}

Expand Down
15 changes: 15 additions & 0 deletions src/app/cli-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export const options = {
type: 'number',
default: defaults.botCadence,
},
'bot-speed': {
describe: '<km/h> initial bot speed',
type: 'number',
default: defaults.botSpeed,
},
'bot-host': {
describe: '<host> for power/cadence control over udp',
type: 'string',
Expand Down Expand Up @@ -87,5 +92,15 @@ export const options = {
describe: '<value> add this value to watts',
type: 'number',
default: defaults.powerOffset,
},
'speed-scale': {
describe: '<value> scale speed by this multiplier',
type: 'number',
default: defaults.speedScale,
},
'speed-offset': {
describe: '<value> add this value to speed',
type: 'number',
default: defaults.speedOffset,
}
};
5 changes: 4 additions & 1 deletion src/app/simulation.js → src/app/crankSimulation.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
Expand Down
74 changes: 74 additions & 0 deletions src/app/wheelSimulation.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 9 additions & 4 deletions src/bikes/bot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -24,6 +25,7 @@ export class BotBikeClient extends EventEmitter {

this.power = power;
this.cadence = cadence;
this.speed = speed;
this._host = host;
this._port = port;

Expand Down Expand Up @@ -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});
}

/**
Expand All @@ -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;
}
}

/**
Expand Down
5 changes: 4 additions & 1 deletion src/bikes/flywheel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
Expand Down
8 changes: 5 additions & 3 deletions src/bikes/ic4.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
1 change: 1 addition & 0 deletions src/bikes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function createBotBikeClient(options, noble) {
const args = [
options.botPower,
options.botCadence,
options.botSpeed,
options.botHost,
options.botPort,
]
Expand Down
18 changes: 16 additions & 2 deletions src/bikes/keiser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
}
Loading