Skip to content

Peripheral connection not properly terminated after central enters sleep mode #23

@eonicum

Description

@eonicum

Environment

  • Central device: MacBook with Apple Silicon
  • Operating system: macOS Sequoia
  • Node.js version: v20.12.2
  • Library version: @stoprocent/noble v1.19.1

Use case

Implement automatic reconnection for a peripheral device after a disconnection event, such as:

  1. When the central device’s Bluetooth module is disabled.
  2. The central device enters sleep mode.
  3. The peripheral moves out of range, or the peripheral is turned off.

Issue

The library correctly emits a stateChange event with poweredOff during sleep and poweredOn upon waking, but the following issues occur:

  1. The peripheral’s state remains connected.
  2. The peripheral does not trigger a disconnect event.
  3. The peripheral stops receiving data or processing read/write requests.
  4. If the physical peripheral device is turned off after waking, noble logs a warning: noble: unknown peripheral {connected peripheral UUID} disconnected!

Steps to reproduce

  1. Connect to a peripheral (code snippet for testing).
  2. Confirm the peripheral is connected.
  3. Monitor the following:
    • Noble’s stateChange event
    • Peripheral’s disconnect event
    • Peripheral’s connection state
    • Ability to perform peripheral operations
  4. Put the MacBook into sleep mode (e.g., close the lid for 5–7 seconds or allow it to sleep automatically).
  5. Wake the MacBook.
  6. Verify that stateChange events reflect poweredOff during sleep and poweredOn upon waking.
  7. Observe whether:
    • The disconnect event is triggered for the peripheral
    • The peripheral’s state updates to disconnected
  8. Turn off the physical peripheral device.
  9. Check for any logged warnings.

Expected behavior

  • When the MacBook enters sleep mode, the peripheral should trigger a disconnect event.
  • The peripheral’s state should update to disconnected.
  • The peripheral should be fully disconnected.
  • No warning messages should be logged.

Actual behavior

  • No disconnect event is triggered when the MacBook enters sleep mode.
  • The peripheral’s state remains connected.
  • The peripheral seems to remain connected at the OS level, but this status is not propagated to the noble library.
  • Turning off the physical peripheral after waking triggers the warning: noble: unknown peripheral {connected peripheral UUID} disconnected!
  • Noble correctly emits stateChange events: poweredOff during sleep and poweredOn upon waking.

Additional information

  • The issue has been observed across multiple different peripheral devices.
  • Testing has been conducted only on macOS due to the unavailability of other operating systems. The issue may occur on other platforms.

Investigation and potential cause

My initial assumption was that the operating system keeps devices connected, while the library marks them as disconnected. I needed to locate the code responsible for cleanup, and I found the following function

noble/lib/noble.js

Lines 90 to 100 in 3a5f04d

Noble.prototype.onStateChange = function (state) {
debug(`stateChange ${state}`);
// If the state is poweredOff and the previous state was poweredOn, clean up the peripherals
if (state === 'poweredOff' && this._state === 'poweredOn') {
this._cleanupPeriperals();
}
this._state = state;
this.emit('stateChange', state);
};

So, I made several attempts to fix it.

Attempted fix 1

I removed the this._cleanupPeripherals() call from the onStateChange function.

Noble.prototype.onStateChange = function (state) {
  debug(`stateChange ${state}`);

  // If the state is poweredOff and the previous state was poweredOn, clean up the peripherals
  if (state === 'poweredOff' && this._state === 'poweredOn') {
    // this._cleanupPeriperals();
  }

  this._state = state;
  this.emit('stateChange', state);
};

This allowed the disconnect event to trigger and correctly updated the peripheral’s state when turning off the physical peripheral. However, if the peripheral remained powered on, its state stayed connected, but no peripheral operations (read, write, subscribe) could be performed.

I assumed that the device connection is not fully operational and requires manual closure. This assumption is supported by the following link.

Attempted fix 2

So I tried forcing disconnection when the state changes to poweredOff:

Noble.prototype.onStateChange = function (state) {
  debug(`stateChange ${state}`);

  if (state === 'poweredOff' && this._state === 'poweredOn') {
    // this._cleanupPeripherals();
    for (let peripheral of Object.values(this._peripherals)) {
      if (peripheral.state === 'connected') {
        peripheral.disconnect();
      }
    }
  }

  this._state = state;
  this.emit('stateChange', state);
};

This left the peripheral in a disconnecting state which is not ideal.

Attempted fix 3

I modified the logic to trigger disconnect after the state transitions from poweredOff to poweredOn:

Noble.prototype.onStateChange = function (state) {
  debug(`stateChange ${state}`);

  if (state === 'poweredOn' && this._state === 'poweredOff') {
    for (let peripheral of Object.values(this._peripherals)) {
      if (peripheral.state === 'connected') {
        peripheral.disconnect();
      }
    }
  }

  this._state = state;
  this.emit('stateChange', state);
};

This approach successfully triggered the disconnect event and updated the peripheral’s state.

Limitations of the fix

Third fix is good for investigation, but it is not production-ready due to the following concerns:

  1. I haven’t researched how the allowDuplicates scanning parameter affects this fix, but it feels like it may cause issues.
  2. Further investigation is needed to determine if this issue occurs on other platforms.
  3. It may be better to address this at the native code level, such as manually triggering disconnects for each connected device when the state changes to CBManagerState.poweredOff.
    - (void)centralManagerDidUpdateState:(CBCentralManager *)central {
    auto state = stateToString(central.state);
    emit.RadioState(state);
    }
    However, this could require JS side updates, as disconnect events might be triggered after the state change. The current implementation clears the peripherals list, which may conflict with this approach and depends on (no. 3).

Thank you! 🙏🙏🙏

I just wanted to say thank you for maintaining this repository. I really appreciate the support and improvements you make to this old library.

✨ ✨ ✨

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions