From cf3ea1ffdbbe31645bcd5e7fef7dfd64fdb2a3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=A1sz?= <32517724+Abasz@users.noreply.github.com> Date: Sun, 3 May 2026 22:06:45 +0200 Subject: [PATCH] Recover from BLE hardware errors without crashing This commit fixes issue #245 by registering a manager 'error' listener immediately after BleManager creation so hardware errors are caught at the ORM wrapper level instead of crashing the process as an uncaught exception. On hardware error the wrapper: - closes the transport so the next open() creates a fresh HCI socket - clears all cached manager/transport/task state - emits an ORM-level 'hardwareError' event As part of the error handling it tries to recover the BLE peripheral: - a single restart attempt: best-effort destroy with a 5 s timeout, a 5 s wait, then recreate the active advertising peripheral - falls back to switchBlePeripheralMode('OFF') if recreation throws, keeping the app running and updating the UI to reflect OFF state --- app/peripherals/PeripheralManager.js | 154 +++++++++++++++++++++------ app/peripherals/ble/BleManager.js | 92 +++++++++++++--- 2 files changed, 201 insertions(+), 45 deletions(-) diff --git a/app/peripherals/PeripheralManager.js b/app/peripherals/PeripheralManager.js index ca29deae15..78bc1949a9 100644 --- a/app/peripherals/PeripheralManager.js +++ b/app/peripherals/PeripheralManager.js @@ -61,6 +61,7 @@ export function createPeripheralManager (config) { * @type {BluetoothModes} */ let bleMode + let isBleRestartInProgress = false /** * @type {ReturnType | undefined} @@ -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) + }) } /** @@ -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) { @@ -250,7 +252,8 @@ export function createPeripheralManager (config) { } } catch (error) { log.error(error) - return + + return false } } @@ -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) + }) } /** @@ -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) + }) } /** @@ -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 @@ -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} 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') diff --git a/app/peripherals/ble/BleManager.js b/app/peripherals/ble/BleManager.js index f42f90d189..13669e580e 100644 --- a/app/peripherals/ble/BleManager.js +++ b/app/peripherals/ble/BleManager.js @@ -1,3 +1,4 @@ +import EventEmitter from 'node:events' import loglevel from 'loglevel' import HciSocket from 'hci-socket' @@ -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} */ @@ -22,6 +23,39 @@ export class BleManager { * @type {Promise | 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) { @@ -32,19 +66,40 @@ 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) + } + ) }) } @@ -52,6 +107,8 @@ export class BleManager { } close () { + this.#isClosing = true + try { this.#transport?.close() } catch (e) { @@ -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 } } @@ -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 () { @@ -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 } @@ -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) {