ESP32 BLE shows up on Android but not on iOS
ยท 8 min read
Your ESP32 advertises. nRF Connect on Android finds it instantly. The iPhone app finds nothing, and often nRF Connect on iOS finds nothing either. The asymmetry is the diagnosis, not a mystery: Android tooling scans permissively and shows you everything, while iOS applies rules that a default ESP-IDF advertising config quietly breaks.
1. Your advertising packet is over 31 bytes
This is the most common cause by a wide margin, and it produces total invisibility rather than a warning. Legacy BLE advertising carries 31 bytes. Do the arithmetic on a typical config:
Flags 3 bytes
128-bit service UUID 18 bytes (1 len + 1 type + 16 UUID)
------------------------------------------
21 bytes
Remaining for everything else 10 bytes
Device name "ESP32_Sensor_01" 17 bytes (1 len + 1 type + 15 chars)
-> overflows by 7A custom 128-bit UUID plus any realistic device name does not fit. When it overflows, the stack drops fields rather than reporting an error, and which field survives is not something you should rely on.
Put the service UUID in the advertising packet and the name in the scan response. Scan response buys you a second 31 bytes, and iOS reads it during an active scan.
// Advertising data: the UUID, because that is what iOS filters on.
static esp_ble_adv_data_t adv_data = {
.set_scan_rsp = false,
.include_name = false, // no room for it here
.service_uuid_len = 16,
.p_service_uuid = service_uuid,
.flag = (ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT),
};
// Scan response: the human-readable part.
static esp_ble_adv_data_t scan_rsp_data = {
.set_scan_rsp = true,
.include_name = true,
};A shorter name is the other fix, but it only buys you a few bytes and the next person to rename the device breaks it again.
2. You are filtering on a UUID the device never advertises
scanForPeripherals filters against the advertisement, not against the services the device exposes after connecting. An ESP32 can implement your service perfectly and never mention it while advertising, in which case a filtered iOS scan finds nothing forever.
Test it in thirty seconds: scan with nil and log everything. If the device appears unfiltered and disappears when you add the filter, the UUID is missing from the advertising payload. That is a firmware fix, not an app one, and it is usually the 31-byte problem above.
3. iOS cached your old GATT table
iOS caches the service and characteristic layout per device, aggressively, and it is not obliged to re-read it. Change your GATT table in firmware and iOS may keep serving the old one for days. Characteristics come back missing, or with the wrong handles, or the device connects and then appears to have nothing on it. Android tends to rediscover sooner, which is why the two platforms disagree.
The developer workaround is to toggle Bluetooth in Settings, or forget the device, or reboot the phone. That is fine on your desk and useless in the field. The production fix belongs in firmware:
- Implement the Service Changed characteristic (0x2A05) in the Generic Attribute service and indicate on it when the layout changes. This is what it exists for.
- Bump a firmware version characteristic so the app can detect a mismatch and tell the user what to do.
- During development, change the device name between GATT revisions so you are not fighting a stale cache while debugging something else.
4. Connection parameters iOS will not accept
This one shows up as a device that is discoverable and connects, then drops after a second or two. iOS enforces the ranges in Apple's Accessory Design Guidelines and will reject or renegotiate a request outside them. A connection interval below 15 ms, or a supervision timeout longer than 6 seconds, is the usual offender. Android accepts far more, so the same firmware behaves.
Ask the firmware team what interval and timeout they request, and have them request something Apple accepts rather than whatever the example sketch shipped with.
The MAC address assumption that breaks Android ports
Android hands you the device's MAC address. iOS never does. You get a CBPeripheral identifier which is generated per device per app installation, so it is not the MAC, it is not stable across reinstalls, and it is not the same value another app sees for the same device.
Any protocol design where the phone reports the MAC to a backend, or where the device is keyed by MAC in a pairing flow, has to change for iOS. Put the identifier the system needs to see inside a characteristic the app can read after connecting, and key on that.
The order to check these in
- Scan with a nil filter on iOS and log every advertisement. Present or absent answers most of this immediately.
- If absent, dump the advertising payload byte count in firmware. Look for the 31-byte overflow before anything else.
- If present unfiltered but absent when filtered, the service UUID is not in the advertising data.
- If it connects and characteristics are missing, toggle Bluetooth. If that fixes it, it is the GATT cache.
- If it connects and drops within seconds, read the disconnect error code and check the connection parameters against Apple's ranges.
Android showing the device proves the radio works. It proves nothing about whether the advertisement is well formed.
If you have an ESP32 that Android sees and iOS does not, send over the advertising config and the GATT table and we can work through it.