Skip to content

Commit

Permalink
Support for Keiser M Series Bikes (M3i, M3i-TBT, M3iX) (#50)
Browse files Browse the repository at this point in the history
  • Loading branch information
chriselsen authored Feb 21, 2021
1 parent e1c8cd9 commit 6c0e457
Show file tree
Hide file tree
Showing 4 changed files with 235 additions and 1 deletion.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Gymnasticon enables obsolete and/or proprietary exercise bikes to work with Zwif
* Flywheel
* Peloton Bike (requires an [additional cable](https://github.com/ptx2/gymnasticon/pull/12#issuecomment-696345309))
* Schwinn IC4/IC8 aka Bowflex C6
* Keiser M Series Bikes (M3i, M3i-TBT, M3iX)
* LifeFitness IC5 (probably works)

## Apps and devices tested
Expand Down Expand Up @@ -154,7 +155,7 @@ usage: gymnasticon [OPTIONS]
Options:
--config <filename> load options from json file [string]
--bike <type>
[string] [choices: "flywheel", "peloton", "ic4", "bot", "autodetect"]
[string] [choices: "flywheel", "peloton", "ic4", "keiser", "bot", "autodetect"]
[default: "autodetect"]
--bike-connect-timeout <seconds> [number] [default: 0]
--bike-receive-timeout <seconds> [number] [default: 4]
Expand Down
6 changes: 6 additions & 0 deletions src/bikes/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {FlywheelBikeClient} from './flywheel';
import {PelotonBikeClient} from './peloton';
import {Ic4BikeClient} from './ic4';
import {KeiserBikeClient} from './keiser';
import {BotBikeClient} from './bot';
import {macAddress} from '../util/mac-address';
import fs from 'fs';
Expand All @@ -9,6 +10,7 @@ const factories = {
'flywheel': createFlywheelBikeClient,
'peloton': createPelotonBikeClient,
'ic4': createIc4BikeClient,
'keiser': createKeiserBikeClient,
'bot': createBotBikeClient,
'autodetect': autodetectBikeClient,
};
Expand Down Expand Up @@ -47,6 +49,10 @@ function createIc4BikeClient(options, noble) {
return new Ic4BikeClient(noble);
}

function createKeiserBikeClient(options, noble) {
return new KeiserBikeClient(noble);
}

function createBotBikeClient(options, noble) {
const args = [
options.botPower,
Expand Down
212 changes: 212 additions & 0 deletions src/bikes/keiser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import util from 'util';
import {EventEmitter} from 'events';
import {Timer} from '../util/timer';
import {scan} from '../util/ble-scan';
import {macAddress} from '../util/mac-address';

const KEISER_LOCALNAME = "M3";
const KEISER_VALUE_MAGIC = Buffer.from([0x02, 0x01]); // identifies Keiser data message
const KEISER_VALUE_IDX_POWER = 10; // 16-bit power (watts) data offset within packet
const KEISER_VALUE_IDX_CADENCE = 6; // 16-bit cadence (1/10 rpm) data offset within packet
const KEISER_VALUE_IDX_REALTIME = 4; // Indicates whether the data present is realtime (0, or 128 to 227)
const KEISER_STATS_TIMEOUT = 2.0; // If no stats have been received within this time, reset power and cadence to 0
const KEISER_BIKE_TIMEOUT = 300.0; // Consider bike disconnected if no stats have been received for 300 sec / 5 minutes

const debuglog = util.debuglog('gymnasticon:bikes:keiser');

/**
* Handles communication with Keiser bikes
* Developer documentation can be found at https://dev.keiser.com/mseries/direct/
*/

export class KeiserBikeClient extends EventEmitter {
/**
* Create a KeiserBikeClient instance.
* @param {Noble} noble - a Noble instance.
* @param {object} filters - filters to specify bike when more than one is present
* @param {string} filters.address - mac address
* @param {string} filters.name - device name
*/
constructor(noble, filters) {
super();
this.noble = noble;
this.filters = filters;
this.state = 'disconnected';
this.onReceive = this.onReceive.bind(this);
}

/**
* Bike behaves like a BLE beacon. Simulate connect by looking up MAC address
* scanning and filtering subsequent announcements from this address.
*/
async connect() {
if (this.state === 'connected') {
throw new Error('Already connected');
}

// Reset stats to 0 when bike suddenly dissapears
this.statsTimeout = new Timer(KEISER_STATS_TIMEOUT, {repeats: false});
this.statsTimeout.on('timeout', this.onStatsTimeout.bind(this));

// Consider bike disconnected if no stats have been received for certain time
this.bikeTimeout = new Timer(KEISER_BIKE_TIMEOUT, {repeats: false});
this.bikeTimeout.on('timeout', this.onBikeTimeout.bind(this));

// Create filter to fix power and cadence dropouts
this.fixDropout = createDropoutFilter();

// Scan for bike
this.filters = {};
this.filters.name = (v) => v == KEISER_LOCALNAME;
this.peripheral = await scan(this.noble, null, this.filters);

this.state = 'connected';

// Waiting for data
await this.noble.startScanningAsync(null, true);
this.noble.on('discover', this.onReceive);

// Workaround for noble stopping to scan after connect to bleno
// See https://github.com/noble/noble/issues/223
this.noble.on('scanStop', this.restartScan);
}

/**
* Get the bike's MAC address.
* @returns {string} mac address
*/
get address() {
return macAddress(this.peripheral.address);
}

/**
* Handle data received from the bike.
* @param {buffer} data - raw data encoded in proprietary format.
* @emits BikeClient#data
* @emits BikeClient#stats
* @private
*/
onReceive(data) {
try {
if (data.address == this.peripheral.address) {
this.emit('data', data);
const {type, payload} = parse(data.advertisement.manufacturerData);
if (type === 'stats') {
const fixed = this.fixDropout(payload);
if (fixed.power !== payload.power) {
debuglog(`*** replaced zero power with previous power ${fixed.power}`);
}
if (fixed.cadence !== payload.cadence) {
debuglog(`*** replaced zero cadence with previous cadence ${fixed.cadence}`);
}
debuglog('Found Keiser M3: ', data.advertisement.localName, ' Address: ', data.address, ' Data: ', data.advertisement.manufacturerData, 'Power: ', fixed.power, 'Cadence: ', fixed.cadence);
this.emit(type, fixed);
this.statsTimeout.reset();
this.bikeTimeout.reset();
}
}
} catch (e) {
if (!/unable to parse message/.test(e)) {
throw e;
}
}
}

/**
* Set power & cadence to 0 when the bike dissapears
*/
async onStatsTimeout() {
const reset = { power:0, cadence:0 };
debuglog('Stats timeout exceeded');
console.log("Stats timeout: Restarting BLE Scan");
if (this.state === 'connected') {
if (this.noble.state === 'poweredOn') {
try {
await this.noble.startScanningAsync(null, true);
} catch (err) {
console.log("Stats timeout: Unable to restart BLE Scan: " + err);
}
} else {
console.log("Stats timeout: Bluetooth no longer powered on");
this.onBikeTimeout();
}
}
this.emit('stats', reset);
}

/**
* Consider Bike disconnected after certain time
*/
onBikeTimeout() {
debuglog('M3 Bike disconnected');
this.state = 'disconnected';
this.noble.off('scanStop', this.restartScan);
this.emit('disconnect', {address: this.peripheral.address});
}

/**
* Restart BLE scanning while in connected state
* Workaround for noble stopping to scan after connect to bleno
* See https://github.com/noble/noble/issues/223
*/
async restartScan() {
console.log("Restarting BLE Scan");
try {
await this.startScanningAsync(null, true);
} catch (err) {
console.log("Unable to restart BLE Scan: " + err);
}
}
}

/**
* Parse Keiser Bike Data characteristic value.
* Consider if provided value are realtime or review mode
* See https://dev.keiser.com/mseries/direct/#data-type
* @param {buffer} data - raw characteristic value.
* @returns {object} message - parsed message
* @returns {string} message.type - message type
* @returns {object} message.payload - message payload
*/
export function parse(data) {
if (data.indexOf(KEISER_VALUE_MAGIC) === 0) {
const realtime = data.readUInt8(KEISER_VALUE_IDX_REALTIME);
if (realtime === 0 || (realtime > 128 && realtime < 255)) {
// 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}};
}
}
throw new Error('unable to parse message');
}

/**
* Workaround for an issue in the Keiser Bike where it occasionally
* incorrectly reports zero cadence (rpm) or zero power (watts)
* @private
*/
function createDropoutFilter() {
let prev = null;

/**
* Returns stats payload with spurious zero removed.
* @param {object} curr - current stats payload
* @param {number} curr.power - power (watts)
* @param {number} curr.cadence - cadence (rpm)
* @returns {object} fixed - fixed stats payload
* @returns {object} fixed.power - fixed power (watts)
* @returns {object} fixed.cadence - cadence
*/
return function (curr) {
let fixed = {...curr};
if (prev !== null && curr.power === 0 && curr.cadence > 0 && prev.power > 0) {
fixed.power = prev.power;
}
if (prev !== null && curr.cadence === 0 && curr.power > 0 && prev.cadence > 0) {
fixed.cadence = prev.cadence;
}
prev = curr;
return fixed;
}
}
15 changes: 15 additions & 0 deletions src/test/bikes/keiser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import test from 'tape';
import {parse} from '../../bikes/keiser';

/**
* See https://dev.keiser.com/mseries/direct/#data-parse-example for a
* data parse example of the below test case
*/
test('parse() parses Keiser indoor bike data values', t => {
const buf = Buffer.from('0201063000383803460573000D00042701000A', 'hex');
const {type, payload: {power, cadence}} = parse(buf);
t.equal(type, 'stats', 'message type');
t.equal(power, 115, 'power (watts)');
t.equal(cadence, 82, 'cadence (rpm)');
t.end();
});

0 comments on commit 6c0e457

Please sign in to comment.