I'm using NativeScript (Android) to receive heart rate notifications from a HRM device. I'm able to:
- Connect to the device
- Discover services and characteristics
- Enable notifications via the descriptor
0x2902
However, the onCharacteristicChanged callback never gets called.
What could prevent onCharacteristicChanged from being triggered, even though notifications are clearly enabled?
I've tried two different approaches:
- Using the android api directly in NativeScript
- Using the plugin (
@nativescript-community/ble)
Below are code snippets and logs from both approaches. Any help would be greatly appreciated.
Code for Android Bluetooth API
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { Application, Utils } from '@nativescript/core';
declare const android: any;
@Injectable({
providedIn: 'root',
})
export class BleService {
public heartRate$ = new BehaviorSubject<number | null>(null);
private bluetoothAdapter: any;
receiver: any;
constructor() {
const context = Utils.android.getApplicationContext();
const bluetoothManager = context.getSystemService(android.content.Context.BLUETOOTH_SERVICE);
this.bluetoothAdapter = bluetoothManager.getAdapter();
}
public async startDiscovery() {
console.log('startDiscovery: Method invoked');
if (!this.bluetoothAdapter) {
console.log('startDiscovery: Bluetooth adapter not available');
return;
}
if (!this.bluetoothAdapter.isEnabled()) {
console.log('startDiscovery: Bluetooth is OFF. Please enable Bluetooth.');
return;
}
const activity = Application.android.foregroundActivity || Application.android.startActivity;
if (!activity) {
console.log('startDiscovery: No valid activity context found');
return;
}
const permissionsGranted = await this.requestPermissions(activity);
if (!permissionsGranted) {
console.log('startDiscovery: Permissions not granted');
return;
}
if (this.bluetoothAdapter.isDiscovering()) {
this.bluetoothAdapter.cancelDiscovery();
}
const BluetoothReceiver = (<any>android.content.BroadcastReceiver).extend({
onReceive: (context, intent) => {
try {
const action = intent.getAction();
if (action === android.bluetooth.BluetoothDevice.ACTION_FOUND) {
const device = intent.getParcelableExtra(android.bluetooth.BluetoothDevice.EXTRA_DEVICE);
const name = device?.getName();
const address = device?.getAddress();
console.log(`Found Device: Name = ${name ?? 'Unknown'}, Address = ${address}`);
if (name && name.includes('HRM')) {
console.log('Found Heart Rate Monitor, attempting to connect...');
this.connectToDevice(device);
}
}
if (action === android.bluetooth.BluetoothAdapter.ACTION_DISCOVERY_FINISHED) {
console.log('Discovery finished');
try {
activity.unregisterReceiver(this.receiver);
} catch (err) {
console.warn('Failed to unregister receiver:', err);
}
}
} catch (err) {
console.error('Error in BroadcastReceiver:', err.message);
}
},
});
this.receiver = new BluetoothReceiver();
const filter = new android.content.IntentFilter();
filter.addAction(android.bluetooth.BluetoothDevice.ACTION_FOUND);
filter.addAction(android.bluetooth.BluetoothAdapter.ACTION_DISCOVERY_FINISHED);
activity.registerReceiver(this.receiver, filter);
console.log('Starting Bluetooth discovery...');
this.bluetoothAdapter.startDiscovery();
}
private connectToDevice(device: any) {
const BluetoothGattCallback = android.bluetooth.BluetoothGattCallback.extend({
onConnectionStateChange: (gatt, status, newState) => {
if (newState === android.bluetooth.BluetoothProfile.STATE_CONNECTED) {
console.log('Connected to device');
gatt.discoverServices();
} else if (newState === android.bluetooth.BluetoothProfile.STATE_DISCONNECTED) {
console.log('Disconnected from device');
}
},
onServicesDiscovered: (gatt, status) => {
if (status === android.bluetooth.BluetoothGatt.GATT_SUCCESS) {
console.log('Services discovered');
this.subscribeToHeartRateNotifications(gatt);
} else {
console.error('Failed to discover services');
}
},
onDescriptorWrite: (gatt, descriptor, status) => {
if (status === android.bluetooth.BluetoothGatt.GATT_SUCCESS) {
console.log('Descriptor written successfully');
const readSuccess = gatt.readDescriptor(descriptor);
console.log('gatt.readDescriptor called, success:', readSuccess);
} else {
console.error('Descriptor write failed');
}
},
onDescriptorRead: (gatt, descriptor, status) => {
if (status === android.bluetooth.BluetoothGatt.GATT_SUCCESS) {
const value = descriptor.getValue();
let valueHex = value && value.length
? Array.from(value).map(byte => (byte as any).toString(16).padStart(2, '0')).join('')
: 'null';
console.log(`Read descriptor ${descriptor.getUuid().toString()}. Value: 0x${valueHex}`);
if (valueHex === '0100') {
console.log('Descriptor value confirms notifications are enabled.');
} else {
console.warn('Descriptor value is NOT the expected ENABLE_NOTIFICATION_VALUE!');
}
} else {
console.error(`Failed to read descriptor ${descriptor.getUuid().toString()}, status: ${status}`);
}
},
onCharacteristicChanged: (gatt, characteristic) => {
console.log('Characteristic changed:', characteristic.getUuid().toString());
if (
characteristic.getUuid().toString() ===
android.bluetooth.BluetoothGattCharacteristic.UUID_HEART_RATE_MEASUREMENT.toString()
) {
const heartRate = this.parseHeartRate(characteristic.getValue());
console.log('Heart Rate =', heartRate);
this.heartRate$.next(heartRate);
}
},
});
device.connectGatt(
Application.android.context,
false,
new BluetoothGattCallback()
);
}
private subscribeToHeartRateNotifications(gatt: any) {
const heartRateServiceUUID = java.util.UUID.fromString('0000180d-0000-1000-8000-00805f9b34fb');
const heartRateMeasurementUUID = java.util.UUID.fromString('00002a37-0000-1000-8000-00805f9b34fb');
const service = gatt.getService(heartRateServiceUUID);
if (!service) {
console.error('Heart Rate Service not found');
return;
}
const characteristic = service.getCharacteristic(heartRateMeasurementUUID);
if (!characteristic) {
console.error('Heart Rate Measurement characteristic not found');
return;
}
const setNotification = gatt.setCharacteristicNotification(characteristic, true);
const descriptor = characteristic.getDescriptor(
java.util.UUID.fromString('00002902-0000-1000-8000-00805f9b34fb')
);
if (descriptor) {
descriptor.setValue(android.bluetooth.BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.writeDescriptor(descriptor);
} else {
console.error('Descriptor not found for Heart Rate Measurement characteristic');
}
}
private parseHeartRate(data: any): number {
const flags = data[0];
const heartRate = data[1];
return heartRate;
}
private async requestPermissions(activity: any): Promise<boolean> {
try {
const permissionsToRequest: string[] = [];
const ContextCompat = androidx.core.content.ContextCompat;
const sdkVersion = android.os.Build.VERSION.SDK_INT;
const checkPermission = (permission: string): boolean => {
return (
ContextCompat.checkSelfPermission(activity, permission) ===
android.content.pm.PackageManager.PERMISSION_GRANTED
);
};
if (sdkVersion >= 31) {
const newPerms = [
'android.permission.BLUETOOTH_SCAN',
'android.permission.BLUETOOTH_CONNECT',
'android.permission.BLUETOOTH_ADVERTISE',
'android.permission.ACCESS_FINE_LOCATION',
'android.permission.BLUETOOTH_ADMIN',
];
newPerms.forEach((p) => !checkPermission(p) && permissionsToRequest.push(p));
} else {
if (!checkPermission('android.permission.ACCESS_FINE_LOCATION')) {
permissionsToRequest.push('android.permission.ACCESS_FINE_LOCATION');
}
}
if (permissionsToRequest.length === 0) {
console.log('All required permissions already granted');
return true;
}
return new Promise<boolean>((resolve) => {
try {
androidx.core.app.ActivityCompat.requestPermissions(activity, permissionsToRequest, 12345);
} catch (err) {
console.error('Error requesting permissions:', err);
resolve(false);
}
const onRequestPermissionsResult = (args: any) => {
const { requestCode, grantResults } = args;
if (requestCode === 12345) {
const allGranted = grantResults.every(
(result: number) =>
result === android.content.pm.PackageManager.PERMISSION_GRANTED
);
resolve(allGranted);
Application.off('activityRequestPermissionsResult', onRequestPermissionsResult);
}
};
Application.on('activityRequestPermissionsResult', onRequestPermissionsResult);
});
} catch (err) {
console.error('Error during permission check:', err);
return false;
}
}
}
and its logs
[19:13:33] 🟢 Discovery finished
[19:13:23] 🟢 Descriptor value confirms notifications are enabled.
[19:13:23] 🟢 Read descriptor 00002902-0000-1000-8000-00805f9b34fb. Value: 0x0100
[19:13:22] 🟢 gatt.readDescriptor called, success: true
[19:13:22] 🟢 Descriptor written successfully
[19:13:22] 🟢 Services discovered
[19:13:22] 🟢 Connected to device
[19:13:21] 🟢 Found Device: Name = Unknown, Address = 5B:46:FB:23:FB:F0
[19:13:20] 🟢 Found Heart Rate Monitor, attempting to connect...
[19:13:20] 🟢 Found Device: Name = HRM-Dual:026692, Address = FE:CF:19:E0:65:77
[19:13:20] 🟢 Starting Bluetooth discovery...
[19:13:20] 🟢 All required permissions already granted
[19:13:20] 🟢 startDiscovery: Method invoked
[19:13:20] 🟢 Starting BLE scan and connection...
Code for nativescript-community/ble plugin
import { Injectable } from '@angular/core';
import { Bluetooth, Peripheral } from '@nativescript-community/ble';
import { BehaviorSubject } from 'rxjs';
import { Application, Utils } from '@nativescript/core';
declare const android: any;
@Injectable({
providedIn: 'root'
})
export class BleService2 {
private bluetooth = new Bluetooth();
public heartRate$ = new BehaviorSubject<number | null>(null);
receiver: any;
constructor() {
}
private requestBluetoothPermissions(): Promise<void> {
return new Promise((resolve, reject) => {
try {
if (global.isAndroid) {
const sdkVersion = android.os.Build.VERSION.SDK_INT;
const permissions = [];
if (sdkVersion >= 31) {
permissions.push(
android.Manifest.permission.BLUETOOTH_SCAN,
android.Manifest.permission.BLUETOOTH_CONNECT,
android.Manifest.permission.ACCESS_FINE_LOCATION
);
} else {
permissions.push(android.Manifest.permission.ACCESS_FINE_LOCATION);
}
const activity = Application.android.foregroundActivity || Application.android.startActivity;
const hasPermissions = permissions.every(p =>
androidx.core.content.ContextCompat.checkSelfPermission(activity, p) === android.content.pm.PackageManager.PERMISSION_GRANTED
);
if (hasPermissions) {
console.log('Requesting Bluetooth permissions: All required permissions already granted');
resolve();
return;
}
console.log('Requesting Bluetooth permissions...');
androidx.core.app.ActivityCompat.requestPermissions(
activity,
permissions,
1234
);
setTimeout(() => {
const granted = permissions.every(p =>
androidx.core.content.ContextCompat.checkSelfPermission(activity, p) === android.content.pm.PackageManager.PERMISSION_GRANTED
);
if (granted) {
console.log('Bluetooth permissions granted after request');
resolve();
} else {
console.warn('Bluetooth permissions not granted');
reject('Permissions not granted');
}
}, 2000);
} else {
console.log('No Android permissions needed (iOS)');
resolve();
}
} catch (error) {
console.error('Error requesting Bluetooth permissions:', error);
reject(error);
}
});
}
async startDiscovery() {
console.log('Starting BLE scan and connect process');
try {
const isEnabled = await this.bluetooth.isBluetoothEnabled();
console.log(`Bluetooth enabled status: ${isEnabled}`);
if (!isEnabled) {
console.log('Attempting to enable Bluetooth');
await this.bluetooth.enable();
console.log('Bluetooth enabled successfully');
}
await this.requestBluetoothPermissions();
console.log('Bluetooth permissions granted');
console.log('Starting scanning with filter for HR Service UUID: 180D');
this.bluetooth.startScanning({
filters: [{ serviceUUID: '180D' }],
seconds: 60,
onDiscovered: async (peripheral) => {
console.log(`Device discovered: ${peripheral.UUID}`);
console.log(`Device name: ${peripheral.name || 'Unknown'}`);
console.log(`Device RSSI: ${peripheral.RSSI}`);
console.log(`Device services: ${JSON.stringify(peripheral.services || [])}`);
if (peripheral.name?.includes('HRM') || this.hasHeartRateService(peripheral)) {
console.log(`Found HR device: ${peripheral.name || peripheral.UUID}`);
console.log('Stopping scan to connect...');
this.bluetooth.stopScanning();
try {
console.log(`Attempting to connect to device: ${peripheral.UUID}`);
await this.bluetooth.connect({
UUID: peripheral.UUID,
onConnected: async (connectedPeripheral) => {
console.log(`Successfully connected to device: ${connectedPeripheral.UUID}`);
await this.subscribeToHeartRate(connectedPeripheral);
},
onDisconnected: () => {
console.warn('Device disconnected');
}
});
} catch (error) {
console.error('Error connecting to device:', error);
}
}
}
});
setTimeout(() => {
console.log('Stopping scan after timeout');
this.bluetooth.stopScanning();
}, 60000);
} catch (error) {
console.error('Error during scan and connect process:', error);
}
}
private async subscribeToHeartRate(peripheral: Peripheral) {
console.log('Setting up notification for heart rate measurement');
try {
const res = await this.bluetooth.startNotifying({
peripheralUUID: peripheral.UUID,
serviceUUID: '0000180d-0000-1000-8000-00805f9b34fb',
characteristicUUID: '00002a37-0000-1000-8000-00805f9b34fb',
onNotify: (result) => {
console.log('Received heart rate notification');
try {
const data = new Uint8Array(result.value);
console.log(`Raw data bytes: ${Array.from(data).map(b => b.toString(16)).join(' ')}`);
const flags = data[0];
const isFormat16Bit = (flags & 0x01) !== 0;
const hrValue = isFormat16Bit ? (data[1] + (data[2] << 8)) : data[1];
console.log(`Calculated heart rate value: ${hrValue}`);
this.heartRate$.next(hrValue);
} catch (error) {
console.error('Error processing heart rate data:', error);
}
}
});
console.log('Heart rate notification subscription successful:', res);
} catch (error) {
console.error('Failed to subscribe to heart rate notifications:', error);
}
}
private hasHeartRateService(peripheral: Peripheral): boolean {
if (!peripheral.services) {
console.warn(`No services found on peripheral: ${peripheral.UUID}`);
return false;
}
const HR_SERVICE_UUID = '180D';
return peripheral.services.some(service => {
const serviceUuid = typeof service === 'string' ? service : service.UUID;
const isHeartRateService = serviceUuid === HR_SERVICE_UUID;
console.log(`Checking service UUID ${serviceUuid} against HR_SERVICE_UUID ${HR_SERVICE_UUID}: ${isHeartRateService}`);
return isHeartRateService;
});
}
}
and its logs
[19:14:04] 🟢 Heart rate notification subscription successful: undefined
[19:14:04] 🟢 Setting up notification for heart rate measurement
[19:14:04] 🟢 Successfully connected to device: FE:CF:19:E0:65:77
[19:14:04] 🟢 Attempting to connect to device: FE:CF:19:E0:65:77
[19:14:04] 🟢 Stopping scan to connect...
[19:14:04] 🟢 Found HR device: HRM-Dual:026692
[19:14:04] 🟢 Device services: []
[19:14:04] 🟢 Device RSSI: -63
[19:14:04] 🟢 Device name: HRM-Dual:026692
[19:14:04] 🟢 Device discovered: FE:CF:19:E0:65:77
[19:14:03] 🟢 Starting scanning with filter for HR Service UUID: 180D
[19:14:03] 🟢 Bluetooth permissions granted
[19:14:03] 🟢 Requesting Bluetooth permissions: All required permissions already granted
[19:14:03] 🟢 Bluetooth enabled status: true
[19:14:03] 🟢 Starting BLE scan and connect process
permissions
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>