Skip to content

Commit

Permalink
Add optional ANT+ Bike Power output.
Browse files Browse the repository at this point in the history
  • Loading branch information
ptx2 authored Jan 31, 2021
1 parent 737f8bf commit d72804f
Show file tree
Hide file tree
Showing 11 changed files with 206 additions and 4 deletions.
48 changes: 47 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"ini": "^1.3.8",
"parser-delimiter": "^1.0.2",
"serialport": "^9.0.1",
"gd-ant-plus": "0.0.29",
"yargs": "^15.3.1"
},
"devDependencies": {
Expand Down
29 changes: 28 additions & 1 deletion src/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import bleno from '@abandonware/bleno';
import {once} from 'events';

import {createBikeClient, getBikeTypes} from '../bikes';
import {GymnasticonServer} from '../server';
import {GymnasticonServer} from '../servers/ble';
import {AntServer} from '../servers/ant';
import {Simulation} from './simulation';
import {Timer} from '../util/timer';
import {Logger} from '../util/logger';
import {createAntStick} from '../util/ant-stick';

const debuglog = util.debuglog('gymnasticon:app:app');

Expand Down Expand Up @@ -39,6 +41,9 @@ export const defaults = {
serverName: 'Gymnasticon', // how the Gymnasticon will appear to apps
serverPingInterval: 1, // send a power measurement update at least this often

// ANT+ server options
antDeviceId: 11234, // random default ANT+ device id

// power adjustment (to compensate for inaccurate power measurements on bike)
powerScale: 1.0, // multiply power by this
powerOffset: 0.0, // add this to power
Expand Down Expand Up @@ -71,6 +76,11 @@ export class App {
this.bike = createBikeClient(opts, noble);
this.simulation = new Simulation();
this.server = new GymnasticonServer(bleno, opts.serverName);

this.antStick = createAntStick(opts);
this.antServer = new AntServer(this.antStick, {deviceId: opts.antDeviceId});
this.antStick.on('startup', this.onAntStickStartup.bind(this));

this.pingInterval = new Timer(opts.serverPingInterval);
this.statsTimeout = new Timer(opts.bikeStatsTimeout, {repeats: false});
this.connectTimeout = new Timer(opts.bikeConnectTimeout, {repeats: false});
Expand All @@ -97,6 +107,7 @@ export class App {
this.connectTimeout.cancel();
this.logger.log(`bike connected ${this.bike.address}`);
this.server.start();
this.startAnt();
this.pingInterval.reset();
this.statsTimeout.reset();
} catch (e) {
Expand Down Expand Up @@ -128,6 +139,7 @@ export class App {
this.simulation.cadence = cadence;
let {crank} = this;
this.server.updateMeasurement({ power, crank });
this.antServer.updateMeasurement({ power, cadence });
}

onBikeStatsTimeout() {
Expand All @@ -144,4 +156,19 @@ export class App {
this.logger.log(`bike connection timed out after ${this.connectTimeout.interval}s`);
process.exit(1);
}

startAnt() {
if (!this.antStick.is_present()) {
this.logger.log('no ANT+ stick found');
return;
}
if (!this.antStick.open()) {
this.logger.error('failed to open ANT+ stick');
}
}

onAntStickStartup() {
this.logger.log('ANT+ stick opened');
this.antServer.start();
}
}
6 changes: 5 additions & 1 deletion src/app/cli-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ export const options = {
type: 'number',
default: defaults.serverPingInterval,
},

'ant-device-id': {
describe: '<id> ANT+ device id for bike power broadcast',
type: 'number',
default: defaults.antDeviceId,
},
'power-scale': {
describe: '<value> scale watts by this multiplier',
type: 'number',
Expand Down
112 changes: 112 additions & 0 deletions src/servers/ant/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import Ant from 'gd-ant-plus';
import util from 'util';
import {Timer} from '../../util/timer';

const debuglog = util.debuglog('gymnasticon:servers:ant');

const DEVICE_TYPE = 0x0b; // power meter
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 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.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));
}

/**
* 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),
];
debuglog(`ANT+ server start [deviceId=${deviceId} channel=${channel}]`);
for (let m of messages) {
stick.write(m);
}
this.broadcastInterval.reset();
}

/**
* 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}W cadence=${cadence}rpm accumulatedPower=${this.accumulatedPower}W eventCount=${this.eventCount} message=${message.toString('hex')}`);
stick.write(message);
this.eventCount++;
this.eventCount &= 0xff;
}
}
2 changes: 1 addition & 1 deletion src/server/index.js → src/servers/ble/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {CyclingPowerService} from './services/cycling-power'
import {BleServer} from '../util/ble-server'
import {BleServer} from '../../util/ble-server'

export const DEFAULT_NAME = 'Gymnasticon';

Expand Down
File renamed without changes.
12 changes: 12 additions & 0 deletions src/util/ant-stick.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Ant from 'gd-ant-plus';

/**
* Create ANT+ stick.
*/
export function createAntStick() {
let stick = new Ant.GarminStick3; // 0fcf:1009
if (!stick.is_present()) {
stick = new Ant.GarminStick2; // 0fcf:1008
}
return stick;
}

0 comments on commit d72804f

Please sign in to comment.