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