Add ANT+ profile manager

- Implement an ANT+ profile manager similar to BLE
- Implement ANT+ Fitness Equipment profile to be able to broadcast data
- Add button to the UI to scroll through the ANT+ profiles
This commit is contained in:
Abász 2022-12-21 13:01:58 +01:00
parent e938f5d8a0
commit 4f62b2322e
9 changed files with 308 additions and 4 deletions

View File

@ -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 {
<div class="peripheral-mode">${this.blePeripheralMode()}</div>
<button @click=${this.switchHrmPeripheralMode}>${icon_heartbeat}</button>
<div class="peripheral-mode">${this.appState?.config?.hrmPeripheralMode}</div>
<button @click=${this.switchAntPeripheralMode}>${icon_antplus}</button>
<div class="peripheral-mode">${this.appState?.config?.antPeripheralMode}</div>
${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' })
}

View File

@ -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

View File

@ -25,3 +25,5 @@ export const icon_expand = svg`<svg aria-hidden="true" focusable="false" class="
export const icon_compress = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M436 192H312c-13.3 0-24-10.7-24-24V44c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v84h84c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm-276-24V44c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v84H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24zm0 300V344c0-13.3-10.7-24-24-24H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm192 0v-84h84c6.6 0 12-5.4 12-12v-40c0-6.6-5.4-12-12-12H312c-13.3 0-24 10.7-24 24v124c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12z"></path></svg>`
export const icon_bluetooth = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M292.6 171.1L249.7 214l-.3-86 43.2 43.1m-43.2 219.8l43.1-43.1-42.9-42.9-.2 86zM416 259.4C416 465 344.1 512 230.9 512S32 465 32 259.4 115.4 0 228.6 0 416 53.9 416 259.4zm-158.5 0l79.4-88.6L211.8 36.5v176.9L138 139.6l-27 26.9 92.7 93-92.7 93 26.9 26.9 73.8-73.8 2.3 170 127.4-127.5-83.9-88.7z"></path></svg>`
export const icon_upload = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"></path></svg>`
export const icon_antplus = svg`<svg id="svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="536.723163841808" viewBox="0 0 380 532" aria-hidden="true" focusable="false" class="icon" role="img"><path fill="currentColor" d="M176.836 0.707 C 60.266 18.305,-16.078 120.160,3.321 232.203 C 26.151 364.067,169.680 436.811,288.977 376.981 C 437.287 302.601,434.584 89.747,284.426 18.617 C 254.109 4.256,206.179 -3.723,176.836 0.707 M229.910 16.544 C 360.710 37.096,427.877 190.879,354.055 300.782 C 274.148 419.742,97.047 408.376,33.765 280.226 C -32.417 146.205,81.393 -6.791,229.910 16.544 M176.836 66.680 C 134.648 79.679,142.046 142.642,186.099 145.521 L 194.232 146.052 200.634 162.009 C 204.154 170.786,207.236 178.475,207.481 179.096 C 207.726 179.718,205.374 181.657,202.253 183.407 C 194.503 187.753,184.428 197.342,179.661 204.911 C 174.868 212.521,176.571 212.453,158.757 205.752 L 144.068 200.227 143.344 192.293 C 138.554 139.803,61.050 143.994,63.509 196.610 C 65.127 231.232,106.117 247.599,131.274 223.668 L 135.841 219.323 152.418 225.525 L 168.995 231.727 168.554 239.542 C 164.794 306.084,254.502 335.622,292.084 280.216 C 322.809 234.919,290.211 174.246,235.028 174.021 L 227.684 173.991 220.504 155.893 L 213.324 137.796 217.881 132.852 C 245.378 103.021,215.455 54.781,176.836 66.680 M61.315 483.337 C 54.332 502.445,47.085 522.295,45.209 527.447 L 41.798 536.815 51.028 536.486 L 60.257 536.158 63.781 526.554 L 67.304 516.949 82.428 516.949 L 97.552 516.949 100.847 526.836 L 104.143 536.723 113.653 536.723 C 118.884 536.723,123.164 536.580,123.164 536.406 C 123.164 536.231,117.013 519.325,109.496 498.835 C 101.978 478.346,94.813 458.658,93.573 455.085 L 91.318 448.588 82.665 448.591 L 74.011 448.595 61.315 483.337 M138.983 492.655 L 138.983 536.723 148.003 536.723 L 157.023 536.723 157.325 508.567 L 157.627 480.410 175.019 508.567 L 192.410 536.723 200.725 536.723 L 209.040 536.723 209.040 492.655 L 209.040 448.588 200.019 448.588 L 190.999 448.588 190.697 476.878 L 190.395 505.168 173.063 476.878 L 155.731 448.588 147.357 448.588 L 138.983 448.588 138.983 492.655 M225.989 456.497 L 225.989 464.407 238.983 464.407 L 251.977 464.407 251.977 500.565 L 251.977 536.723 261.017 536.723 L 270.056 536.723 270.056 500.565 L 270.056 464.407 283.051 464.407 L 296.045 464.407 296.045 456.497 L 296.045 448.588 261.017 448.588 L 225.989 448.588 225.989 456.497 M332.957 464.030 C 332.542 464.444,332.203 469.783,332.203 475.895 L 332.203 487.006 322.034 487.006 L 311.864 487.006 311.864 494.915 L 311.864 502.825 321.994 502.825 L 332.124 502.825 332.446 514.407 L 332.768 525.989 340.395 526.323 L 348.023 526.657 348.023 514.783 L 348.023 502.910 358.475 502.585 L 368.927 502.260 368.927 494.915 L 368.927 487.571 358.475 487.246 L 348.023 486.921 348.023 475.099 L 348.023 463.277 340.866 463.277 C 336.930 463.277,333.371 463.616,332.957 464.030 M86.513 485.028 C 88.831 492.020,90.962 498.376,91.247 499.153 C 91.638 500.213,89.366 500.565,82.141 500.565 L 72.514 500.565 77.190 486.441 C 79.762 478.672,81.963 472.316,82.082 472.316 C 82.202 472.316,84.195 478.037,86.513 485.028 " ></path></g></svg>`

View File

@ -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

View File

@ -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
})

View File

@ -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

View File

@ -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 }

View File

@ -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

View File

@ -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