diff --git a/package-lock.json b/package-lock.json index aab10344..fc53ea94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1947,6 +1947,14 @@ "assert-plus": "^1.0.0" } }, + "dbly-linked-list": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/dbly-linked-list/-/dbly-linked-list-0.3.4.tgz", + "integrity": "sha512-327vOlwspi9i1T3Kc9yZhRUR8qDdgMQ4HmXsFDDCQ/HTc3sNe7gnF5b0UrsnaOJ0rvmG7yBZpK0NoOux9rKYKw==", + "requires": { + "lodash.isequal": "^4.5.0" + } + }, "debug": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", @@ -2774,6 +2782,32 @@ "wide-align": "^1.1.0" } }, + "gd-ant-plus": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/gd-ant-plus/-/gd-ant-plus-0.0.29.tgz", + "integrity": "sha512-de84FZWfn9fB6WfisASUaybEIkYrAYGJmq4FUONSMeIUlNuhHhQYr18oH5CV7gbAcqDHyoD6QgBJDA066VPnHw==", + "requires": { + "queue-fifo": "^0.2.6", + "usb": "^1.6.3" + }, + "dependencies": { + "nan": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.13.2.tgz", + "integrity": "sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw==" + }, + "usb": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/usb/-/usb-1.6.4.tgz", + "integrity": "sha512-/QYxyZEcj2iRnNT2HaHurCa/nVc54/d3vXxGH8Wz/shsGDgrf/7vg7N65VTGeR1MWQof7O4EQXfLpKd1k3VU7Q==", + "requires": { + "bindings": "^1.4.0", + "nan": "2.13.2", + "prebuild-install": "^5.3.3" + } + } + } + }, "gensync": { "version": "1.0.0-beta.1", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz", @@ -3541,6 +3575,11 @@ "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", "dev": true }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4221,7 +4260,6 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-5.3.3.tgz", "integrity": "sha512-GV+nsUXuPW2p8Zy7SarF/2W/oiK8bFQgJcncoJ0d7kRpekEA0ftChjfEaF9/Y+QJEc/wFR7RAEa8lYByuUIe2g==", - "optional": true, "requires": { "detect-libc": "^1.0.3", "expand-template": "^2.0.3", @@ -4289,6 +4327,14 @@ "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", "optional": true }, + "queue-fifo": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/queue-fifo/-/queue-fifo-0.2.6.tgz", + "integrity": "sha512-rwlnZHAaTmWEGKC7ziasK8u4QnZW/uN6kSiG+tHNf/1GA+R32FArZi18s3SYUpKcA0Y6jJoUDn5GT3Anoc2mWw==", + "requires": { + "dbly-linked-list": "0.3.4" + } + }, "rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", diff --git a/package.json b/package.json index b8e50212..0760d3be 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app/app.js b/src/app/app.js index ae0c029c..010982c1 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -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'); @@ -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 @@ -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}); @@ -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) { @@ -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() { @@ -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(); + } } diff --git a/src/app/cli-options.js b/src/app/cli-options.js index 7ead97c8..247be348 100644 --- a/src/app/cli-options.js +++ b/src/app/cli-options.js @@ -73,7 +73,11 @@ export const options = { type: 'number', default: defaults.serverPingInterval, }, - + 'ant-device-id': { + describe: ' ANT+ device id for bike power broadcast', + type: 'number', + default: defaults.antDeviceId, + }, 'power-scale': { describe: ' scale watts by this multiplier', type: 'number', diff --git a/src/servers/ant/index.js b/src/servers/ant/index.js new file mode 100644 index 00000000..812db9ac --- /dev/null +++ b/src/servers/ant/index.js @@ -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; + } +} diff --git a/src/server/index.js b/src/servers/ble/index.js similarity index 95% rename from src/server/index.js rename to src/servers/ble/index.js index cd7f57af..7c4dd039 100644 --- a/src/server/index.js +++ b/src/servers/ble/index.js @@ -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'; diff --git a/src/server/services/cycling-power/characteristics/cycling-power-feature.js b/src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js similarity index 100% rename from src/server/services/cycling-power/characteristics/cycling-power-feature.js rename to src/servers/ble/services/cycling-power/characteristics/cycling-power-feature.js diff --git a/src/server/services/cycling-power/characteristics/cycling-power-measurement.js b/src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js similarity index 100% rename from src/server/services/cycling-power/characteristics/cycling-power-measurement.js rename to src/servers/ble/services/cycling-power/characteristics/cycling-power-measurement.js diff --git a/src/server/services/cycling-power/characteristics/sensor-location.js b/src/servers/ble/services/cycling-power/characteristics/sensor-location.js similarity index 100% rename from src/server/services/cycling-power/characteristics/sensor-location.js rename to src/servers/ble/services/cycling-power/characteristics/sensor-location.js diff --git a/src/server/services/cycling-power/index.js b/src/servers/ble/services/cycling-power/index.js similarity index 100% rename from src/server/services/cycling-power/index.js rename to src/servers/ble/services/cycling-power/index.js diff --git a/src/util/ant-stick.js b/src/util/ant-stick.js new file mode 100644 index 00000000..07f2ef5a --- /dev/null +++ b/src/util/ant-stick.js @@ -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; +}