Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 122 additions & 32 deletions app/peripherals/PeripheralManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export function createPeripheralManager (config) {
* @type {BluetoothModes}
*/
let bleMode
let isBleRestartInProgress = false

/**
* @type {ReturnType<createFEPeripheral> | undefined}
Expand Down Expand Up @@ -168,15 +169,15 @@ export function createPeripheralManager (config) {
* @param {BluetoothModes} [newMode]
*/
async function switchBlePeripheralMode (newMode) {
if (isPeripheralChangeInProgress) { return }
isPeripheralChangeInProgress = true
// if no mode was passed, select the next one from the list
if (newMode === undefined) {
newMode = bleModes[(bleModes.indexOf(bleMode) + 1) % bleModes.length]
}
config.bluetoothMode = newMode
await createBlePeripheral(newMode)
isPeripheralChangeInProgress = false
await runExclusivePeripheralChange(async () => {
// if no mode was passed, select the next one from the list
if (newMode === undefined) {
newMode = bleModes[(bleModes.indexOf(bleMode) + 1) % bleModes.length]
}

config.bluetoothMode = newMode
await createBlePeripheral(newMode)
})
}

/**
Expand All @@ -202,12 +203,13 @@ export function createPeripheralManager (config) {
*/
async function createBlePeripheral (newMode) {
try {
if (_bleManager === undefined && newMode !== 'OFF') {
_bleManager = new BleManager()
if (newMode !== 'OFF') {
ensureBleManager()
}
} catch (error) {
log.error('BleManager creation error: ', error)
return

return false
}

if (blePeripheral) {
Expand Down Expand Up @@ -250,7 +252,8 @@ export function createPeripheralManager (config) {
}
} catch (error) {
log.error(error)
return

return false
}
}

Expand All @@ -260,20 +263,22 @@ export function createPeripheralManager (config) {
data: {}
}
})

return true
}

/**
* @param {AntPlusModes} [newMode]
*/
async function switchAntPeripheralMode (newMode) {
if (isPeripheralChangeInProgress) { return }
isPeripheralChangeInProgress = true
if (newMode === undefined) {
newMode = antModes[(antModes.indexOf(antMode) + 1) % antModes.length]
}
config.antPlusMode = newMode
await createAntPeripheral(newMode)
isPeripheralChangeInProgress = false
await runExclusivePeripheralChange(async () => {
if (newMode === undefined) {
newMode = antModes[(antModes.indexOf(antMode) + 1) % antModes.length]
}

config.antPlusMode = newMode
await createAntPeripheral(newMode)
})
}

/**
Expand Down Expand Up @@ -325,14 +330,14 @@ export function createPeripheralManager (config) {
* @param {HeartRateModes} [newMode]
*/
async function switchHrmMode (newMode) {
if (isPeripheralChangeInProgress) { return }
isPeripheralChangeInProgress = true
if (newMode === undefined) {
newMode = hrmModes[(hrmModes.indexOf(hrmMode) + 1) % hrmModes.length]
}
config.heartRateMode = newMode
await createHrmPeripheral(newMode)
isPeripheralChangeInProgress = false
await runExclusivePeripheralChange(async () => {
if (newMode === undefined) {
newMode = hrmModes[(hrmModes.indexOf(hrmMode) + 1) % hrmModes.length]
}

config.heartRateMode = newMode
await createHrmPeripheral(newMode)
})
}

/**
Expand Down Expand Up @@ -372,9 +377,7 @@ export function createPeripheralManager (config) {
case 'BLE':
log.info('heart rate profile: BLE')
try {
if (_bleManager === undefined) {
_bleManager = new BleManager()
}
ensureBleManager()
} catch (error) {
log.error('BleManager creation error: ', error)
return
Expand Down Expand Up @@ -449,6 +452,93 @@ export function createPeripheralManager (config) {
return true
}

function ensureBleManager () {
if (_bleManager !== undefined) {
return _bleManager
}

_bleManager = new BleManager()
_bleManager.on('hardwareError', async (error) => {
try {
await restartBlePeripheral(error)
} catch (error) {
log.error('BLE restart failed unexpectedly:', error)
}
})

return _bleManager
}

/**
* @param {() => Promise<void>} change
*/
async function runExclusivePeripheralChange (change) {
if (isPeripheralChangeInProgress) {
return false
}

isPeripheralChangeInProgress = true

try {
await change()

return true
} finally {
isPeripheralChangeInProgress = false
}
}

/**
* @param {Error} error
*/
async function restartBlePeripheral (error) {
const savedMode = bleMode

if (savedMode === 'OFF') {
log.warn('Ignoring BLE manager error because BLE advertising is disabled')

return
}

if (isPeripheralChangeInProgress || isBleRestartInProgress) {
log.warn('Ignoring BLE manager error because a change is already in progress')

return
}

isBleRestartInProgress = true
log.error(`BLE manager error, attempting peripheral restart (mode: ${savedMode}): ${error.message}`)

try {
if (blePeripheral !== undefined) {
const activePeripheral = blePeripheral
blePeripheral = undefined
await Promise.race([
activePeripheral.destroy(),
new Promise((resolve) => setTimeout(resolve, 5000))
])
}

await new Promise((resolve) => setTimeout(resolve, 5000))

if (bleMode !== savedMode) {
log.info('BLE mode changed during restart delay, aborting recovery')

return
}

log.info(`Recreating BLE peripheral (mode: ${savedMode})`)
try {
await createBlePeripheral(savedMode)
} catch (recreateError) {
log.error(`BLE restart failed, disabling BLE: ${recreateError.message}`)
await switchBlePeripheralMode('OFF')
}
} finally {
isBleRestartInProgress = false
}
}

async function shutdownAllPeripherals () {
log.debug('shutting down all peripherals')

Expand Down
92 changes: 79 additions & 13 deletions app/peripherals/ble/BleManager.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import EventEmitter from 'node:events'
import loglevel from 'loglevel'

import HciSocket from 'hci-socket'
Expand All @@ -9,7 +10,7 @@ import NodeBleHost from 'ble-host'

const log = loglevel.getLogger('Peripherals')

export class BleManager {
export class BleManager extends EventEmitter {
/**
* @type {HciSocket | undefined}
*/
Expand All @@ -22,6 +23,39 @@ export class BleManager {
* @type {Promise<BleHostManager> | undefined}
*/
#managerOpeningTask
#isClosing = false

constructor () {
super()
}

/**
* @param {Error} error
*/
#handleManagerError (error) {
if (this.#isClosing && error.message === 'Transport closed') {
log.debug('BLE transport closed')

return
}

log.error('BLE manager error, clearing cached state:', error.message)

try {
this.#transport?.close()
} catch (e) {
log.debug('BLE transport close during error recovery:', e.message)
}

this.#resetState()
this.emit('hardwareError', error)
}

#resetState () {
this.#transport = undefined
this.#manager = undefined
this.#managerOpeningTask = undefined
}

open () {
if (this.#manager !== undefined) {
Expand All @@ -32,26 +66,49 @@ export class BleManager {
this.#managerOpeningTask = new Promise((resolve, reject) => {
if (this.#manager) {
resolve(this.#manager)

return
}

log.debug('Opening BLE manager')

if (this.#transport === undefined) {
this.#transport = new HciSocket()
}

NodeBleHost.BleManager.create(this.#transport, {}, (/** @type {Error | null} */err, /** @type {BleHostManager} */manager) => {
if (err) { reject(err) }
this.#manager = manager
this.#managerOpeningTask = undefined
resolve(manager)
})
NodeBleHost.BleManager.create(
this.#transport,
{},
(
/** @type {Error | null} */ err,
/** @type {BleHostManager} */ manager
) => {
if (err) {
this.#managerOpeningTask = undefined
this.#transport = undefined
reject(err)

return
}

manager.on('error', (error) => {
this.#handleManagerError(error)
})

this.#manager = manager
this.#managerOpeningTask = undefined
resolve(manager)
}
)
})
}

return this.#managerOpeningTask
}

close () {
this.#isClosing = true

try {
this.#transport?.close()
} catch (e) {
Expand All @@ -62,8 +119,9 @@ export class BleManager {
}

log.debug('Ble socket is closed')
this.#transport = undefined
this.#manager = undefined
} finally {
this.#resetState()
this.#isClosing = false
}
}

Expand All @@ -81,7 +139,8 @@ export class BleManager {
* @param {string} uuid
* @returns
*/
export const toBLEStandard128BitUUID = (uuid) => `0000${uuid}-0000-1000-8000-00805F9B34FB`
export const toBLEStandard128BitUUID = (uuid) =>
`0000${uuid}-0000-1000-8000-00805F9B34FB`

export class GattNotifyCharacteristic {
get characteristic () {
Expand All @@ -106,8 +165,13 @@ export class GattNotifyCharacteristic {
constructor (characteristic) {
this.#characteristic = {
...characteristic,
onSubscriptionChange: (/** @type {import('./ble-host.interface.js').Connection} */connection, /** @type {boolean} */ notification) => {
log.debug(`${this.#characteristic.name} subscription change: ${connection.peerAddress}, notification: ${notification}`)
onSubscriptionChange: (
/** @type {import('./ble-host.interface.js').Connection} */ connection,
/** @type {boolean} */ notification
) => {
log.debug(
`${this.#characteristic.name} subscription change: ${connection.peerAddress}, notification: ${notification}`
)
this.#isSubscribed = notification
this.#connection = notification ? connection : undefined
}
Expand All @@ -119,7 +183,9 @@ export class GattNotifyCharacteristic {
*/
notify (buffer) {
if (this.#characteristic.notify === undefined) {
throw new Error(`Characteristics ${this.#characteristic.name} has not been initialized`)
throw new Error(
`Characteristics ${this.#characteristic.name} has not been initialized`
)
}

if (!this.#isSubscribed || this.#connection === undefined) {
Expand Down
Loading