You called setNotifyValue and no notifications arrive

By · · 6 min read

Silence is the unhelpful part. A write that fails can at least return an error, but a subscription that never delivers looks exactly like a peripheral with nothing to say. setNotifyValue returns immediately and tells you nothing, because the real work is a descriptor write that completes later and reports somewhere most apps never look.

Six causes account for nearly all of these. They are ordered by how often they turn out to be the one, which is not the order they appear in the documentation.

1. The peripheral has no delegate

Notifications are delivered to the peripheral's delegate, not to the central manager's. Setting the central's delegate and forgetting the peripheral's produces this exact symptom: connection works, discovery works, and no value ever updates.

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    // Without this line every peripheral callback goes nowhere.
    peripheral.delegate = self
    peripheral.discoverServices([serviceUUID])
}

It reads as too obvious to be the answer, and it is the answer often enough to check first. One breakpoint in didUpdateValueFor settles it in ten seconds.

2. Nothing checked whether the subscription succeeded

Enabling notifications is a write to the Client Characteristic Configuration descriptor on the peripheral. It travels over the air, the peripheral can reject it, and the outcome arrives in a delegate method that is optional and therefore usually absent.

func peripheral(_ peripheral: CBPeripheral,
                didUpdateNotificationStateFor characteristic: CBCharacteristic,
                error: Error?) {
    if let error {
        // Insufficient authentication here means bonding is required first.
        print("subscribe failed for \(characteristic.uuid): \(error)")
        return
    }
    print("notifying = \(characteristic.isNotifying) for \(characteristic.uuid)")
}

If isNotifying comes back false with no error, the peripheral accepted the write and declined to enable notifications, which is a firmware answer rather than an app one. If it comes back as an authentication error, the characteristic is protected and you need to bond before subscribing.

3. The CBPeripheral was deallocated

CBCentralManager does not keep a strong reference to peripherals you connect to. If the only thing holding yours was a local variable in the method that started the connection, it is gone by the time notifications would arrive, and callbacks stop without any disconnection event you would notice.

final class BluetoothClient: NSObject {
    private var central: CBCentralManager!
    // Strong, and for the lifetime of the connection. A local variable is a bug.
    private var connected: CBPeripheral?

    func connect(to peripheral: CBPeripheral) {
        connected = peripheral
        central.connect(peripheral)
    }
}

The tell is that everything works for a second or two and then goes quiet, often right after the function that set it up returns.

4. You are holding a characteristic from a previous connection

CBService and CBCharacteristic objects belong to one connection. After a disconnect and reconnect, the objects you cached are stale, and calling setNotifyValue on one is a call about an attribute handle that may no longer mean what it did.

Rediscover services on every connection, including reconnections, and rebuild whatever map you keep from the fresh objects rather than the old ones. Caching a characteristic across a connection cycle is a bug that only appears after your first reconnect, which is usually well into testing.

5. The characteristic indicates, it does not notify

Notify and indicate are different mechanisms. Indicate is acknowledged at the protocol level and notify is not, and a characteristic declares which it supports. setNotifyValue handles either, but only if the property is actually there:

let canSubscribe = characteristic.properties.contains(.notify)
    || characteristic.properties.contains(.indicate)

guard canSubscribe else {
    // Nothing will ever arrive. This is a firmware conversation.
    assertionFailure("\(characteristic.uuid) supports neither notify nor indicate")
    return
}
peripheral.setNotifyValue(true, for: characteristic)

Log the properties of every characteristic you intend to use, once, on first discovery. It costs a line and it ends an entire category of argument with the firmware team about whether the app subscribed.

The variant where the first one arrives and the rest do not

Worth separating, because it is a different fault with the same feeling. If exactly one value lands and then nothing, the subscription plainly succeeded, so causes one through five are all ruled out by the packet that already arrived.

On indications this is almost always the confirmation handshake. Every indication must be acknowledged before the peripheral may send the next one, and firmware that does not process the confirmation, or that waits for one the stack has already sent, stalls after the first. On notifications the same shape usually means the peripheral stopped sending, often because a buffer filled or a state machine left the notifying state. Either way the investigation moves to the peripheral, because the app already proved its half works.

6. The subscription is fine and the device is not sending

If isNotifying is true and the delegate is set and the peripheral is retained, the app side is done. Firmware notifies on a value change, and if the value is not changing, or the notification path is behind a state machine that has not been entered, nothing arrives and nothing is broken.

Settle this with a sniffer rather than a discussion. An nRF Connect log or a packet capture shows whether the notification left the device, which takes the argument from two days to two minutes. If the packets are on the air and your app does not see them, the problem is above the radio. If they are not, it never was.

The order worth checking them in

  • Is peripheral.delegate set? Ten seconds, and it is the answer more often than anyone admits.
  • Implement didUpdateNotificationStateFor and read isNotifying. This splits the problem in half: false means the subscription never took, true means look at firmware.
  • Is the CBPeripheral strongly retained for the life of the connection?
  • Are the service and characteristic objects from this connection, not a previous one?
  • Do the properties include notify or indicate? If neither, no app change will help.
  • Capture the air. If the notification is not in the log, the app was never the problem.

One more condition sits outside that list. If values arrive while the app is on screen and stop when it is backgrounded, no part of the subscription is wrong: the app is simply not permitted to keep running. That needs the Bluetooth background mode declared in Info.plist, and if the peripheral has to survive the app being terminated it needs state restoration as well, which is its own piece of work.

isNotifying is the fork in the road. Everything before it is an app bug and everything after it is a firmware conversation.

The primary source for the callback contracts here is Apple's CoreBluetooth documentation for setNotifyValue(_:for:) and the CBPeripheralDelegate methods, and the notify against indicate distinction is defined in the Bluetooth Core Specification rather than by iOS. If you have a subscription that works on Android and stays silent on iOS, send over the GATT table and a packet capture and we can find where it diverges.

Related writing

All writing · Get in touch about a Bluetooth project