diff --git a/app/client/components/DashboardActions.js b/app/client/components/DashboardActions.js index d6564df..6cf0c91 100644 --- a/app/client/components/DashboardActions.js +++ b/app/client/components/DashboardActions.js @@ -7,7 +7,7 @@ import { AppElement, html, css } from './AppElement.js' import { customElement, state } from 'lit/decorators.js' -import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload, icon_heartbeat } from '../lib/icons.js' +import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload, icon_heartbeat, icon_antplus } from '../lib/icons.js' import './AppDialog.js' @customElement('dashboard-actions') @@ -69,6 +69,8 @@ export class DashboardActions extends AppElement {
${this.blePeripheralMode()}
${this.appState?.config?.hrmPeripheralMode}
+ +
${this.appState?.config?.antPeripheralMode}
${this.dialog ? this.dialog : ''} ` } @@ -141,6 +143,10 @@ export class DashboardActions extends AppElement { this.sendEvent('triggerAction', { command: 'switchBlePeripheralMode' }) } + switchAntPeripheralMode () { + this.sendEvent('triggerAction', { command: 'switchAntPeripheralMode' }) + } + switchHrmPeripheralMode () { this.sendEvent('triggerAction', { command: 'switchHrmMode' }) } diff --git a/app/client/lib/app.js b/app/client/lib/app.js index e680715..c410489 100644 --- a/app/client/lib/app.js +++ b/app/client/lib/app.js @@ -134,6 +134,10 @@ export function createApp (app) { if (socket)socket.send(JSON.stringify({ command: 'switchBlePeripheralMode' })) break } + case 'switchAntPeripheralMode': { + if (socket)socket.send(JSON.stringify({ command: 'switchAntPeripheralMode' })) + break + } case 'switchHrmMode': { if (socket)socket.send(JSON.stringify({ command: 'switchHrmMode' })) break diff --git a/app/client/lib/icons.js b/app/client/lib/icons.js index 23b9a75..e233280 100644 --- a/app/client/lib/icons.js +++ b/app/client/lib/icons.js @@ -25,3 +25,5 @@ export const icon_expand = svg`` export const icon_bluetooth = svg`` export const icon_upload = svg`` + +export const icon_antplus = svg`` diff --git a/app/client/store/appState.js b/app/client/store/appState.js index ef27e12..77c78e2 100644 --- a/app/client/store/appState.js +++ b/app/client/store/appState.js @@ -15,6 +15,8 @@ export const APP_STATE = { blePeripheralMode: '', // currently can be ANT, BLE, OFF hrmPeripheralMode: '', + // currently can be FE, OFF + antPeripheralMode: '', // true if upload to strava is enabled stravaUploadEnabled: false, // true if remote device shutdown is enabled diff --git a/app/peripherals/PeripheralManager.js b/app/peripherals/PeripheralManager.js index 9dcb4e2..afba110 100644 --- a/app/peripherals/PeripheralManager.js +++ b/app/peripherals/PeripheralManager.js @@ -15,8 +15,10 @@ import { createCscPeripheral } from './ble/CscPeripheral.js' import AntManager from './ant/AntManager.js' import { createAntHrmPeripheral } from './ant/HrmPeripheral.js' import { createBleHrmPeripheral } from './ble/HrmPeripheral.js' +import { createFEPeripheral } from './ant/FEPeripheral.js' const bleModes = ['FTMS', 'FTMSBIKE', 'PM5', 'CSC', 'CPS', 'OFF'] +const antModes = ['FE', 'OFF'] const hrmModes = ['ANT', 'BLE', 'OFF'] function createPeripheralManager () { const emitter = new EventEmitter() @@ -24,11 +26,15 @@ function createPeripheralManager () { let blePeripheral let bleMode + let antPeripheral + let antMode + let hrmPeripheral let hrmMode createBlePeripheral(config.bluetoothMode) createHrmPeripheral(config.heartRateMode) + createAntPeripheral(config.antplusMode) function getBlePeripheral () { return blePeripheral @@ -38,6 +44,14 @@ function createPeripheralManager () { return bleMode } + function getAntPeripheral () { + return antPeripheral + } + + function getAntPeripheralMode () { + return antMode + } + function getHrmPeripheral () { return hrmPeripheral } @@ -56,10 +70,12 @@ function createPeripheralManager () { function notifyMetrics (type, metrics) { if (bleMode !== 'OFF') { blePeripheral.notifyData(type, metrics) } + if (antMode !== 'OFF') { antPeripheral.notifyData(type, metrics) } } function notifyStatus (status) { if (bleMode !== 'OFF') { blePeripheral.notifyStatus(status) } + if (antMode !== 'OFF') { antPeripheral.notifyStatus(status) } } async function createBlePeripheral (newMode) { @@ -117,6 +133,46 @@ function createPeripheralManager () { }) } + function switchAntPeripheralMode (newMode) { + if (newMode === undefined) { + newMode = antModes[(antModes.indexOf(antMode) + 1) % antModes.length] + } + createAntPeripheral(newMode) + } + + async function createAntPeripheral (newMode) { + if (antPeripheral) { + await antPeripheral.destroy() + antPeripheral = undefined + + if (_antManager && hrmMode !== 'ANT' && newMode === 'OFF') { await _antManager.closeAntStick() } + } + + switch (newMode) { + case 'FE': + log.info('ant plus profile: FE') + if (!_antManager) { + _antManager = new AntManager() + } + + antPeripheral = createFEPeripheral(_antManager) + antMode = 'FE' + await antPeripheral.attach() + break + + default: + log.info('ant plus profile: Off') + antMode = 'OFF' + } + + emitter.emit('control', { + req: { + name: 'antPeripheralMode', + peripheralMode: antMode + } + }) + } + function switchHrmMode (newMode) { if (newMode === undefined) { newMode = hrmModes[(hrmModes.indexOf(hrmMode) + 1) % hrmModes.length] @@ -129,7 +185,7 @@ function createPeripheralManager () { await hrmPeripheral.destroy() hrmPeripheral.removeAllListeners() hrmPeripheral = undefined - if (_antManager && newMode !== 'ANT') { await _antManager.closeAntStick() } + if (_antManager && newMode !== 'ANT' && antMode === 'OFF') { await _antManager.closeAntStick() } } switch (newMode) { @@ -176,10 +232,13 @@ function createPeripheralManager () { return Object.assign(emitter, { getBlePeripheral, getBlePeripheralMode, + getAntPeripheral, + getAntPeripheralMode, getHrmPeripheral, getHrmPeripheralMode, switchHrmMode, switchBlePeripheralMode, + switchAntPeripheralMode, notifyMetrics, notifyStatus }) diff --git a/app/peripherals/ant/AntManager.js b/app/peripherals/ant/AntManager.js index c4a5384..e6759d4 100644 --- a/app/peripherals/ant/AntManager.js +++ b/app/peripherals/ant/AntManager.js @@ -10,7 +10,7 @@ - Garmin mini ANT+ (ID 0x1009) */ import log from 'loglevel' -import { AntDevice } from 'incyclist-ant-plus/lib/bindings/index.js' +import { AntDevice } from 'incyclist-ant-plus/lib/ant-device.js' export default class AntManager { _isStickOpen = false diff --git a/app/peripherals/ant/FEPeripheral.js b/app/peripherals/ant/FEPeripheral.js new file mode 100644 index 0000000..86e89a9 --- /dev/null +++ b/app/peripherals/ant/FEPeripheral.js @@ -0,0 +1,218 @@ +'use strict' + +import log from 'loglevel' +import { Messages } from 'incyclist-ant-plus' +import { PeripheralConstants } from '../PeripheralConstants.js' + +/* + Open Rowing Monitor, https://github.com/laberning/openrowingmonitor + + Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are required for + a Cycling Speed and Cadence Profile +*/ + +function createFEPeripheral (antManager) { + const antStick = antManager.getAntStick() + const deviceType = 0x11 // Ant FE-C device + const deviceNumber = 1 + const deviceId = parseInt(PeripheralConstants.serial, 10) & 0xFFFF + const channel = 1 + const broadcastPeriod = 8192 // 8192/32768 ~4hz + const broadcastInterval = broadcastPeriod / 32768 * 1000 // millisecond + const rfChannel = 57 // 2457 MHz + let dataPageCount = 0 + let commonPageCount = 0 + let timer + + let sessionData = { + accumulatedStrokes: 0, + accumulatedDistance: 0, + accumulatedTime: 0, + accumulatedPower: 0, + cycleLinearVelocity: 0, + strokeRate: 0, + instantaneousPower: 0, + distancePerStroke: 0, + fitnessEquipmentState: fitnessEquipmentStates.inUse, + sessionStatus: 'WaitingForStart' + } + + async function attach () { + if (!antManager.isStickOpen()) { await antManager.openAntStick() } + + const messages = [ + Messages.assignChannel(channel, 'transmit'), + Messages.setDevice(channel, deviceId, deviceType, deviceNumber), + Messages.setFrequency(channel, rfChannel), + Messages.setPeriod(channel, broadcastPeriod), + Messages.openChannel(channel) + ] + + log.info(`ANT+ FE server start [deviceId=${deviceId} channel=${channel}]`) + for (const message of messages) { + antStick.write(message) + } + + timer = setInterval(onBroadcastInterval, broadcastInterval) + } + + function destroy () { + return new Promise((resolve) => { + clearInterval(timer) + log.info(`ANT+ FE server stopped [deviceId=${deviceId} channel=${channel}]`) + + const messages = [ + Messages.closeChannel(channel), + Messages.unassignChannel(channel) + ] + for (const message of messages) { + antStick.write(message) + } + resolve() + }) + } + + function onBroadcastInterval () { + dataPageCount++ + let data + + switch (true) { + case dataPageCount === 65 || dataPageCount === 66: + if (commonPageCount % 2 === 0) { // 0x50 - Common Page for Manufacturers Identification (approx twice a minute) + data = [ + channel, + 0x50, // Page 80 + 0xFF, // Reserved + 0xFF, // Reserved + parseInt(PeripheralConstants.hardwareRevision, 10) & 0xFF, // Hardware Revision + ...Messages.intToLEHexArray(40, 2), // Manufacturer ID (value 255 = Development ID, value 40 = concept2) + 0x0001 // Model Number + ] + } + if (commonPageCount % 2 === 1) { // 0x51 - Common Page for Product Information (approx twice a minute) + data = [ + channel, + 0x51, // Page 81 + 0xFF, // Reserved + parseInt(PeripheralConstants.firmwareRevision.slice(-2), 10), // SW Revision (Supplemental) + parseInt(PeripheralConstants.firmwareRevision[0], 10), // SW Version + ...Messages.intToLEHexArray(parseInt(PeripheralConstants.serial, 10), 4) // Serial Number (None) + ] + } + + if (dataPageCount === 66) { + commonPageCount++ + dataPageCount = 0 + } + break + case dataPageCount % 8 === 4: // 0x11 - General Settings Page (once a second) + case dataPageCount % 8 === 7: + data = [ + channel, + 0x11, // Page 17 + 0xFF, // Reserved + 0xFF, // Reserved + ...Messages.intToLEHexArray(sessionData.distancePerStroke, 1), // Stroke Length in 0.01 m + 0x7FFF, // Incline (Not Used) + 0x00, // Resistance (DF may be reported if conversion to the % is worked out (value in % with a resolution of 0.5%). + ...Messages.intToLEHexArray(feCapabilitiesBitField, 1) + ] + if (sessionData.sessionStatus === 'Rowing') { + log.debug(`Page 17 Data Sent. Event=${dataPageCount}. Stroke Length=${sessionData.distancePerStroke}.`) + log.debug(`Hex Stroke Length=0x${sessionData.distancePerStroke.toString(16)}.`) + } + break + case dataPageCount % 8 === 3: // 0x16 - Specific Rower Data (once a second) + case dataPageCount % 8 === 0: + data = [ + channel, + 0x16, // Page 22 + 0xFF, // Reserved + 0xFF, // Reserved + ...Messages.intToLEHexArray(sessionData.accumulatedStrokes, 1), // Stroke Count + ...Messages.intToLEHexArray(sessionData.strokeRate, 1), // Cadence / Stroke Rate + ...Messages.intToLEHexArray(sessionData.instantaneousPower, 2), // Instant Power (2 bytes) + ...Messages.intToLEHexArray((sessionData.fitnessEquipmentState + rowingCapabilitiesBitField), 1) + ] + if (sessionData.sessionStatus === 'Rowing') { + log.debug(`Page 22 Data Sent. Event=${dataPageCount}. Strokes=${sessionData.accumulatedStrokes}. Stroke Rate=${sessionData.strokeRate}. Power=${sessionData.instantaneousPower}`) + log.debug(`Hex Strokes=0x${sessionData.accumulatedStrokes.toString(16)}. Hex Stroke Rate=0x${sessionData.strokeRate.toString(16)}. Hex Power=0x${Messages.intToLEHexArray(sessionData.instantaneousPower, 2)}.`) + } + break + case dataPageCount % 4 === 2: // 0x10 - General FE Data (twice a second) + default: + data = [ + channel, + 0x10, // Page 16 + 0x16, // Rowing Machine (22) + ...Messages.intToLEHexArray(sessionData.accumulatedTime, 1), // elapsed time + ...Messages.intToLEHexArray(sessionData.accumulatedDistance, 1), // distance travelled + ...Messages.intToLEHexArray(sessionData.cycleLinearVelocity, 2), // speed in 0.001 m/s + 0xFF, // heart rate not being sent + ...Messages.intToLEHexArray((sessionData.fitnessEquipmentState + feCapabilitiesBitField), 1) + ] + if (sessionData.sessionStatus === 'Rowing') { + log.debug(`Page 16 Data Sent. Event=${dataPageCount}. Time=${sessionData.accumulatedTime}. Distance=${sessionData.accumulatedDistance}. Speed=${sessionData.cycleLinearVelocity}.`) + log.debug(`Hex Time=0x${sessionData.accumulatedTime.toString(16)}. Hex Distance=0x${sessionData.accumulatedDistance.toString(16)}. Hex Speed=0x${Messages.intToLEHexArray(sessionData.cycleLinearVelocity, 2)}.`) + } + break + } + + const message = Messages.broadcastData(data) + antStick.write(message) + } + + function notifyData (type, data) { + if (type === 'strokeFinished' || type === 'metricsUpdate') { + sessionData = { + ...sessionData, + accumulatedDistance: data.totalLinearDistance & 0xFF, + accumulatedStrokes: data.totalNumberOfStrokes & 0xFF, + accumulatedTime: Math.trunc(data.totalMovingTime * 4) & 0xFF, + cycleLinearVelocity: Math.round(data.cycleLinearVelocity * 1000), + strokeRate: Math.round(data.cycleStrokeRate) & 0xFF, + instantaneousPower: Math.round(data.cyclePower) & 0xFFFF, + distancePerStroke: Math.round(data.cycleDistance * 100), + sessionStatus: data.sessionStatus + } + } + } + + // FE does not have status characteristic + function notifyStatus (status) { + } + + return { + notifyData, + notifyStatus, + attach, + destroy + } +} + +const fitnessEquipmentStates = { + asleep: (1 << 0x04), + ready: (2 << 0x04), + inUse: (3 << 0x04), + finished: (4 << 0x04), + lapToggleBit: (8 << 0x04) +} + +const fitnessEquipmentCapabilities = { + hrDataSourceHandContactSensors: (0x03 << 0), + hrDataSourceEmSensors: (0x02 << 0), + hrDataSourceAntSensors: (0x01 << 0), + hrDataSourceInvalid: (0x00 << 0), + distanceTraveledEnabled: (0x01 << 2), + virtualSpeed: (0x01 << 3), + realSpeed: (0x00 << 3) +} + +const rowingMachineCapabilities = { + accumulatedStrokesEnabled: (0x01 << 0) +} + +const feCapabilitiesBitField = fitnessEquipmentCapabilities.hrDataSourceInvalid | fitnessEquipmentCapabilities.distanceTraveledEnabled | fitnessEquipmentCapabilities.realSpeed +const rowingCapabilitiesBitField = rowingMachineCapabilities.accumulatedStrokesEnabled + +export { createFEPeripheral } diff --git a/app/server.js b/app/server.js index 7ecef86..b64bb26 100644 --- a/app/server.js +++ b/app/server.js @@ -102,6 +102,10 @@ peripheralManager.on('control', (event) => { webServer.notifyClients('config', getConfig()) event.res = true break + case 'antPeripheralMode': + webServer.notifyClients('config', getConfig()) + event.res = true + break case 'hrmPeripheralMode': webServer.notifyClients('config', getConfig()) event.res = true @@ -213,6 +217,9 @@ webServer.on('messageReceived', async (message, client) => { case 'switchBlePeripheralMode': peripheralManager.switchBlePeripheralMode() break + case 'switchAntPeripheralMode': + peripheralManager.switchAntPeripheralMode() + break case 'switchHrmMode': peripheralManager.switchHrmMode() break @@ -241,6 +248,7 @@ webServer.on('clientConnected', (client) => { function getConfig () { return { blePeripheralMode: peripheralManager.getBlePeripheralMode(), + antPeripheralMode: peripheralManager.getAntPeripheralMode(), hrmPeripheralMode: peripheralManager.getHrmPeripheralMode(), stravaUploadEnabled: !!config.stravaClientId && !!config.stravaClientSecret, shutdownEnabled: !!config.shutdownCommand diff --git a/config/default.config.js b/config/default.config.js index 94efe86..099bc40 100644 --- a/config/default.config.js +++ b/config/default.config.js @@ -81,11 +81,16 @@ export default { // - OFF: Turns Bluetooth advertisement off bluetoothMode: 'FTMS', + // Selects the AN+ that is broadcasted to external peripherals and apps. Supported modes: + // - FE: ANT+ Fitness Equipment + // - OFF: Turns Bluetooth advertisement off + antplusMode: 'OFF', + // Selects the heart rate monitor mode. Supported modes: // - BLE: Use Bluetooth Low Energy to connect Heart Rate Monitor (Will currently connect to the first device found) // - ANT: Use Ant+ to connect Heart Rate Monitor // - OFF: turns of Heart Rate Monitor discovery - heartRateMode: 'BLE', + heartRateMode: 'OFF', // Defines the name that is used to announce the FTMS Rower via Bluetooth Low Energy (BLE) // Some rowing training applications expect that the rowing device is announced with a certain name