From 5e9c4afc44ccd501271ef7d6643a1262d2f64350 Mon Sep 17 00:00:00 2001 From: Anthony Date: Thu, 16 Apr 2026 11:59:08 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20add=20BLE=20peripheral=20mode=20?= =?UTF-8?q?=E2=80=94=20Nitro=20spec,=20JS=20manager,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GATTCharacteristicProperty, GATTCharacteristicPermission enums - Add GATTCharacteristicConfig, ReadRequestCallback, WriteRequestCallback types - Add 14 peripheral methods to NativeBleNitro interface - Add JS wrapper methods to BleNitroManager (advertising, GATT, requests) - Add 11 new unit tests for peripheral mode - Export new types from package index Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__tests__/index.test.ts | 132 ++++++++++++++++++++++++ src/index.ts | 6 ++ src/manager.ts | 166 ++++++++++++++++++++++++++++++ src/specs/NativeBleNitro.nitro.ts | 53 ++++++++++ 4 files changed, 357 insertions(+) diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 1783ddc..2e3a3db 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -25,6 +25,21 @@ const mockNativeInstance = { unsubscribeFromStateChange: jest.fn(), openSettings: jest.fn(), restoreStateIdentifier: null, + // Peripheral methods + startAdvertising: jest.fn(), + stopAdvertising: jest.fn(), + isAdvertising: jest.fn(), + addService: jest.fn(), + removeService: jest.fn(), + removeAllServices: jest.fn(), + updateCharacteristicValue: jest.fn(), + onReadRequest: jest.fn(), + onWriteRequest: jest.fn(), + respondToRequest: jest.fn(), + notifyCharacteristic: jest.fn(), + peripheralState: jest.fn(), + subscribeToPeripheralStateChange: jest.fn(), + unsubscribeFromPeripheralStateChange: jest.fn(), }; jest.mock('../specs/NativeBleNitro', () => ({ @@ -483,4 +498,121 @@ describe('BleNitro', () => { expect.any(Function) ); }); + + // --- Peripheral Mode Tests --- + + describe('Peripheral Mode', () => { + test('startAdvertising calls native with correct parameters', () => { + mockNative.startAdvertising.mockImplementation(() => {}); + BleManager.startAdvertising(['d71f0000-5b7e-4e38-9c2a-1e0d3a8b7f21']); + expect(mockNative.startAdvertising).toHaveBeenCalledWith( + ['d71f0000-5b7e-4e38-9c2a-1e0d3a8b7f21'], + '' + ); + }); + + test('startAdvertising with local name', () => { + mockNative.startAdvertising.mockImplementation(() => {}); + // Reset advertising state + mockNative.isAdvertising.mockReturnValue(false); + BleManager.stopAdvertising(); + BleManager.startAdvertising(['d71f0000-5b7e-4e38-9c2a-1e0d3a8b7f21'], 'TestDevice'); + expect(mockNative.startAdvertising).toHaveBeenCalledWith( + ['d71f0000-5b7e-4e38-9c2a-1e0d3a8b7f21'], + 'TestDevice' + ); + }); + + test('stopAdvertising calls native', () => { + // Ensure advertising is started first + mockNative.startAdvertising.mockImplementation(() => {}); + mockNative.isAdvertising.mockReturnValue(false); + BleManager.stopAdvertising(); // reset state + BleManager.startAdvertising(['test-uuid']); + + mockNative.stopAdvertising.mockImplementation(() => {}); + BleManager.stopAdvertising(); + expect(mockNative.stopAdvertising).toHaveBeenCalled(); + }); + + test('isAdvertising returns native state', () => { + mockNative.isAdvertising.mockReturnValue(true); + expect(BleManager.isAdvertising()).toBe(true); + + mockNative.isAdvertising.mockReturnValue(false); + expect(BleManager.isAdvertising()).toBe(false); + }); + + test('addService calls native with correct config', () => { + mockNative.addService.mockImplementation(() => {}); + BleManager.addService('d71f0000-5b7e-4e38-9c2a-1e0d3a8b7f21', true, [ + { + uuid: 'd71f0001-5b7e-4e38-9c2a-1e0d3a8b7f21', + properties: [0], // Read + permissions: [0], // Readable + value: null, + }, + ]); + expect(mockNative.addService).toHaveBeenCalledWith( + 'd71f0000-5b7e-4e38-9c2a-1e0d3a8b7f21', + true, + [ + { + uuid: 'd71f0001-5b7e-4e38-9c2a-1e0d3a8b7f21', + properties: [0], + permissions: [0], + value: null, + }, + ] + ); + }); + + test('removeAllServices calls native', () => { + mockNative.removeAllServices.mockImplementation(() => {}); + BleManager.removeAllServices(); + expect(mockNative.removeAllServices).toHaveBeenCalled(); + }); + + test('updateCharacteristicValue converts ByteArray to ArrayBuffer', () => { + mockNative.updateCharacteristicValue.mockImplementation(() => {}); + BleManager.updateCharacteristicValue('service-uuid', 'char-uuid', [0x41, 0x42, 0x43]); + expect(mockNative.updateCharacteristicValue).toHaveBeenCalledWith( + 'service-uuid', + 'char-uuid', + expect.any(ArrayBuffer) + ); + }); + + test('onReadRequest registers callback', () => { + const callback = jest.fn(); + mockNative.onReadRequest.mockImplementation(() => {}); + BleManager.onReadRequest(callback); + expect(mockNative.onReadRequest).toHaveBeenCalledWith(expect.any(Function)); + }); + + test('respondToRequest calls native with correct params', () => { + mockNative.respondToRequest.mockImplementation(() => {}); + BleManager.respondToRequest(42, 0, 0, [0xAA, 0xBB]); + expect(mockNative.respondToRequest).toHaveBeenCalledWith( + 42, + 0, + 0, + expect.any(ArrayBuffer) + ); + }); + + test('peripheralState returns mapped state', () => { + mockNative.peripheralState.mockReturnValue(5); // PoweredOn + expect(BleManager.peripheralState()).toBe('PoweredOn'); + }); + + test('subscribeToPeripheralStateChange registers callback', () => { + mockNative.subscribeToPeripheralStateChange.mockReturnValue({ success: true }); + mockNative.peripheralState.mockReturnValue(5); + const callback = jest.fn(); + const sub = BleManager.subscribeToPeripheralStateChange(callback, true); + expect(callback).toHaveBeenCalledWith('PoweredOn'); // emitInitial + expect(sub).toHaveProperty('remove'); + }); + }); }); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 88d78a2..55f1abc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,4 +18,10 @@ export { BleTimeoutError, } from "./manager"; +export { + type GATTCharacteristicConfig, + GATTCharacteristicProperty, + GATTCharacteristicPermission, +} from './specs/NativeBleNitro.nitro'; + export { BleNitro } from './singleton'; \ No newline at end of file diff --git a/src/manager.ts b/src/manager.ts index a2539fb..07a3946 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -5,6 +5,7 @@ import { BLEState as NativeBLEState, ScanCallback as NativeScanCallback, AndroidScanMode as NativeAndroidScanMode, + type GATTCharacteristicConfig, } from './specs/NativeBleNitro'; export class BleTimeoutError extends Error { @@ -799,4 +800,169 @@ export class BleNitroManager { public openSettings(): Promise { return this.Instance.openSettings(); } + + // --- Peripheral / GATT Server --- + + private _isAdvertising: boolean = false; + + /** + * Start BLE advertising with the given service UUIDs + * @param serviceUUIDs Array of service UUIDs to advertise + * @param localName Optional local name to advertise + */ + public startAdvertising(serviceUUIDs: string[], localName: string = ''): void { + if (this._isAdvertising) return; + this.Instance.startAdvertising(serviceUUIDs, localName); + this._isAdvertising = true; + } + + /** + * Stop BLE advertising + */ + public stopAdvertising(): void { + if (!this._isAdvertising) return; + this.Instance.stopAdvertising(); + this._isAdvertising = false; + } + + /** + * Check if currently advertising + */ + public isAdvertising(): boolean { + this._isAdvertising = this.Instance.isAdvertising(); + return this._isAdvertising; + } + + /** + * Add a GATT service with characteristics. + * Must be called before startAdvertising. + */ + public addService( + serviceUUID: string, + isPrimary: boolean, + characteristics: GATTCharacteristicConfig[] + ): void { + this.Instance.addService(serviceUUID, isPrimary, characteristics); + } + + /** + * Remove a GATT service + */ + public removeService(serviceUUID: string): void { + this.Instance.removeService(serviceUUID); + } + + /** + * Remove all GATT services + */ + public removeAllServices(): void { + this.Instance.removeAllServices(); + } + + /** + * Update the cached value of a characteristic + */ + public updateCharacteristicValue( + serviceUUID: string, + characteristicUUID: string, + data: ByteArray + ): void { + this.Instance.updateCharacteristicValue( + serviceUUID, + characteristicUUID, + byteArrayToArrayBuffer(data) + ); + } + + /** + * Register a callback for GATT read requests. + * Called when a central device reads a dynamic characteristic (value: null). + */ + public onReadRequest( + callback: ( + deviceId: string, + characteristicUUID: string, + requestId: number, + offset: number + ) => void + ): void { + this.Instance.onReadRequest(callback); + } + + /** + * Register a callback for GATT write requests + */ + public onWriteRequest( + callback: ( + deviceId: string, + characteristicUUID: string, + requestId: number, + data: ByteArray, + responseNeeded: boolean + ) => void + ): void { + this.Instance.onWriteRequest( + (deviceId, charUUID, requestId, data, responseNeeded) => { + callback(deviceId, charUUID, requestId, arrayBufferToByteArray(data), responseNeeded); + } + ); + } + + /** + * Respond to a pending read or write request + * @param requestId The request ID from the callback + * @param status GATT status (0 = success) + * @param offset Byte offset for the response + * @param data Response data as ByteArray + */ + public respondToRequest( + requestId: number, + status: number, + offset: number, + data: ByteArray + ): void { + this.Instance.respondToRequest(requestId, status, offset, byteArrayToArrayBuffer(data)); + } + + /** + * Send a notification to subscribed centrals + */ + public notifyCharacteristic( + serviceUUID: string, + characteristicUUID: string, + data: ByteArray + ): void { + this.Instance.notifyCharacteristic( + serviceUUID, + characteristicUUID, + byteArrayToArrayBuffer(data) + ); + } + + /** + * Get the peripheral manager state + */ + public peripheralState(): BLEState { + return mapNativeBLEStateToBLEState(this.Instance.peripheralState()); + } + + /** + * Subscribe to peripheral state changes + */ + public subscribeToPeripheralStateChange( + callback: (state: BLEState) => void, + emitInitial = false + ): Subscription { + if (emitInitial) { + callback(this.peripheralState()); + } + this.Instance.subscribeToPeripheralStateChange((nativeState: NativeBLEState) => { + callback(mapNativeBLEStateToBLEState(nativeState)); + }); + return { + remove: () => { + this.Instance.unsubscribeFromPeripheralStateChange(); + }, + }; + } } diff --git a/src/specs/NativeBleNitro.nitro.ts b/src/specs/NativeBleNitro.nitro.ts index 78f7bf9..8d9cfb5 100644 --- a/src/specs/NativeBleNitro.nitro.ts +++ b/src/specs/NativeBleNitro.nitro.ts @@ -65,6 +65,43 @@ export type OperationResult = { error?: string; }; +// --- Peripheral / GATT Server Types --- + +export enum GATTCharacteristicProperty { + Read = 0, + Write = 1, + WriteWithoutResponse = 2, + Notify = 3, + Indicate = 4, +} + +export enum GATTCharacteristicPermission { + Readable = 0, + Writeable = 1, +} + +export interface GATTCharacteristicConfig { + uuid: string; + properties: GATTCharacteristicProperty[]; + permissions: GATTCharacteristicPermission[]; + value: BLEValue | null; +} + +export type ReadRequestCallback = ( + deviceId: string, + characteristicUUID: string, + requestId: number, + offset: number +) => void; + +export type WriteRequestCallback = ( + deviceId: string, + characteristicUUID: string, + requestId: number, + data: BLEValue, + responseNeeded: boolean +) => void; + /** * Native BLE Nitro Module Specification * Defines the interface between TypeScript and native implementations @@ -110,4 +147,20 @@ export interface NativeBleNitro extends HybridObject<{ ios: 'swift'; android: 'k subscribeToStateChange(stateCallback: StateCallback): OperationResult; unsubscribeFromStateChange(): OperationResult; openSettings(): Promise; + + // --- Peripheral / GATT Server --- + startAdvertising(serviceUUIDs: string[], localName: string): void; + stopAdvertising(): void; + isAdvertising(): boolean; + addService(serviceUUID: string, isPrimary: boolean, characteristics: GATTCharacteristicConfig[]): void; + removeService(serviceUUID: string): void; + removeAllServices(): void; + updateCharacteristicValue(serviceUUID: string, characteristicUUID: string, data: BLEValue): void; + onReadRequest(callback: ReadRequestCallback): void; + onWriteRequest(callback: WriteRequestCallback): void; + respondToRequest(requestId: number, status: number, offset: number, data: BLEValue): void; + notifyCharacteristic(serviceUUID: string, characteristicUUID: string, data: BLEValue): void; + peripheralState(): BLEState; + subscribeToPeripheralStateChange(callback: StateCallback): OperationResult; + unsubscribeFromPeripheralStateChange(): OperationResult; } \ No newline at end of file From ac1cec1216c8beb9a018165aada2e19da47e67d8 Mon Sep 17 00:00:00 2001 From: Anthony Date: Thu, 16 Apr 2026 12:02:59 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20native=20peripheral=20mode=20?= =?UTF-8?q?=E2=80=94=20iOS=20(CoreBluetooth)=20+=20Android=20(BLE=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS: - CBPeripheralManager with BleGattServerDelegate - GATT service/characteristic construction from config - Read request handling with 60s native value cache - Write request forwarding to JS callbacks - Central subscription tracking for notifications - Background advertising support via state restoration Android: - BluetoothLeAdvertiser with LOW_POWER mode - BluetoothGattServer with full callback handling - Read request cache (60s TTL) with JS fallback - Write request forwarding with ArrayBuffer wrapping - CCCD descriptor management for notify/indicate - Lazy GATT server initialization Co-Authored-By: Claude Opus 4.6 (1M context) --- .../nitro/co/zyke/ble/BleNitroBleManager.kt | 508 ++++++++++++++++++ ios/BleGattServerDelegate.swift | 56 ++ ios/BleNitroBleManager.swift | 348 ++++++++++++ 3 files changed, 912 insertions(+) create mode 100644 ios/BleGattServerDelegate.swift diff --git a/android/src/main/java/com/margelo/nitro/co/zyke/ble/BleNitroBleManager.kt b/android/src/main/java/com/margelo/nitro/co/zyke/ble/BleNitroBleManager.kt index 553aa8b..ee3e630 100644 --- a/android/src/main/java/com/margelo/nitro/co/zyke/ble/BleNitroBleManager.kt +++ b/android/src/main/java/com/margelo/nitro/co/zyke/ble/BleNitroBleManager.kt @@ -7,9 +7,15 @@ import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattServer +import android.bluetooth.BluetoothGattServerCallback import android.bluetooth.BluetoothGattService import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothProfile +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.BluetoothLeAdvertiser import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter @@ -53,6 +59,22 @@ class BleNitroBleManager : HybridNativeBleNitroSpec() { private val connectedDevices = ConcurrentHashMap() private val deviceCallbacks = ConcurrentHashMap() + // BLE Peripheral / GATT Server + private var gattServer: BluetoothGattServer? = null + private var bleAdvertiser: BluetoothLeAdvertiser? = null + private var isCurrentlyAdvertising = false + private var advertiseCallback: AdvertiseCallback? = null + private val addedGattServices = ConcurrentHashMap() + private val characteristicValueCache = ConcurrentHashMap>() + private val cacheTimeToLiveMs = 60_000L + private var readRequestCallback: ((deviceId: String, characteristicUUID: String, requestId: Double, offset: Double) -> Unit)? = null + private var writeRequestCallback: ((deviceId: String, characteristicUUID: String, requestId: Double, data: ArrayBuffer, responseNeeded: Boolean) -> Unit)? = null + private var peripheralStateCallback: ((state: BLEState) -> Unit)? = null + private var peripheralStateReceiver: BroadcastReceiver? = null + private val pendingReadRequests = ConcurrentHashMap>() + private val pendingWriteRequests = ConcurrentHashMap>() + private var nextRequestId = 1 + // Read callback storage for proper response handling (key: deviceId:characteristicId) private val readCallbacks = ConcurrentHashMap Unit>() @@ -1133,4 +1155,490 @@ class BleNitroBleManager : HybridNativeBleNitroSpec() { } return promise } + + // ==================== Peripheral / GATT Server ==================== + + private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + private fun createGattServerCallback(): BluetoothGattServerCallback { + return object : BluetoothGattServerCallback() { + override fun onCharacteristicReadRequest( + device: BluetoothDevice, + requestId: Int, + offset: Int, + characteristic: BluetoothGattCharacteristic + ) { + val charUuid = characteristic.uuid.toString() + val cacheKey = "${characteristic.service?.uuid}:$charUuid" + + // Check cache with TTL + val cached = characteristicValueCache[cacheKey] + if (cached != null && (System.currentTimeMillis() - cached.second) < cacheTimeToLiveMs) { + val value = cached.first + val responseValue = if (offset > 0 && offset < value.size) { + value.copyOfRange(offset, value.size) + } else if (offset == 0) { + value + } else { + byteArrayOf() + } + try { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, responseValue) + } catch (e: SecurityException) { + android.util.Log.w("BleNitro", "SecurityException sending read response: ${e.message}") + } + return + } + + // Fallback to JS callback + val jsCallback = readRequestCallback + if (jsCallback != null) { + val jsRequestId = nextRequestId++ + pendingReadRequests[jsRequestId] = Pair(device, requestId) + jsCallback(device.address, charUuid, jsRequestId.toDouble(), offset.toDouble()) + } else { + // No callback registered, send empty response + try { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, byteArrayOf()) + } catch (e: SecurityException) { + android.util.Log.w("BleNitro", "SecurityException sending default read response: ${e.message}") + } + } + } + + override fun onCharacteristicWriteRequest( + device: BluetoothDevice, + requestId: Int, + characteristic: BluetoothGattCharacteristic, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray? + ) { + val charUuid = characteristic.uuid.toString() + val jsCallback = writeRequestCallback + + if (jsCallback != null) { + val jsRequestId = nextRequestId++ + if (responseNeeded) { + pendingWriteRequests[jsRequestId] = Pair(device, requestId) + } + + val bytes = value ?: byteArrayOf() + val directBuffer = java.nio.ByteBuffer.allocateDirect(bytes.size) + directBuffer.put(bytes) + directBuffer.flip() + val data = ArrayBuffer.wrap(directBuffer) + + jsCallback(device.address, charUuid, jsRequestId.toDouble(), data, responseNeeded) + } else if (responseNeeded) { + // No callback registered, acknowledge write + try { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } catch (e: SecurityException) { + android.util.Log.w("BleNitro", "SecurityException sending write response: ${e.message}") + } + } + } + + override fun onDescriptorReadRequest( + device: BluetoothDevice, + requestId: Int, + offset: Int, + descriptor: BluetoothGattDescriptor + ) { + if (descriptor.uuid == CCCD_UUID) { + val value = descriptor.value ?: BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + try { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } catch (e: SecurityException) { + android.util.Log.w("BleNitro", "SecurityException sending descriptor read response: ${e.message}") + } + } else { + try { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, descriptor.value ?: byteArrayOf()) + } catch (e: SecurityException) { + android.util.Log.w("BleNitro", "SecurityException sending descriptor read response: ${e.message}") + } + } + } + + override fun onDescriptorWriteRequest( + device: BluetoothDevice, + requestId: Int, + descriptor: BluetoothGattDescriptor, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray? + ) { + if (descriptor.uuid == CCCD_UUID) { + descriptor.value = value + } + if (responseNeeded) { + try { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } catch (e: SecurityException) { + android.util.Log.w("BleNitro", "SecurityException sending descriptor write response: ${e.message}") + } + } + } + } + } + + private fun createAdvertiseCallback(): AdvertiseCallback { + return object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) { + isCurrentlyAdvertising = true + } + + override fun onStartFailure(errorCode: Int) { + isCurrentlyAdvertising = false + val errorMessage = when (errorCode) { + ADVERTISE_FAILED_DATA_TOO_LARGE -> "Advertise data too large" + ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "Too many advertisers" + ADVERTISE_FAILED_ALREADY_STARTED -> "Already started" + ADVERTISE_FAILED_INTERNAL_ERROR -> "Internal error" + ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported" + else -> "Advertise failed with error code: $errorCode" + } + android.util.Log.e("BleNitro", "Advertising failed: $errorMessage") + } + } + } + + private fun openGattServerIfNeeded() { + if (gattServer != null) return + val context = appContext ?: return + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager ?: return + try { + gattServer = bluetoothManager.openGattServer(context, createGattServerCallback()) + } catch (e: SecurityException) { + android.util.Log.e("BleNitro", "SecurityException opening GATT server: ${e.message}") + } + } + + override fun startAdvertising(serviceUUIDs: Array, localName: String) { + try { + initializeBluetoothIfNeeded() + val adapter = bluetoothAdapter ?: return + + if (!adapter.isEnabled) return + if (isCurrentlyAdvertising) return + + // Open GATT server if needed + openGattServerIfNeeded() + + // Get advertiser + bleAdvertiser = adapter.bluetoothLeAdvertiser ?: return + + // Build advertise settings + val settingsBuilder = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) + .setConnectable(true) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .setTimeout(0) // Advertise indefinitely + + val settings = settingsBuilder.build() + + // Build advertise data + val dataBuilder = AdvertiseData.Builder() + .setIncludeDeviceName(localName.isNotEmpty()) + + for (uuid in serviceUUIDs) { + try { + dataBuilder.addServiceUuid(ParcelUuid(UUID.fromString(uuid))) + } catch (e: Exception) { + // Invalid UUID, skip + } + } + + val data = dataBuilder.build() + + // Create and store the callback + advertiseCallback = createAdvertiseCallback() + + // Start advertising + bleAdvertiser?.startAdvertising(settings, data, advertiseCallback) + + } catch (e: SecurityException) { + isCurrentlyAdvertising = false + android.util.Log.e("BleNitro", "SecurityException starting advertising: ${e.message}") + } catch (e: Exception) { + isCurrentlyAdvertising = false + android.util.Log.e("BleNitro", "Error starting advertising: ${e.message}") + } + } + + override fun stopAdvertising() { + try { + advertiseCallback?.let { callback -> + bleAdvertiser?.stopAdvertising(callback) + } + } catch (e: SecurityException) { + android.util.Log.w("BleNitro", "SecurityException stopping advertising: ${e.message}") + } catch (e: Exception) { + android.util.Log.w("BleNitro", "Error stopping advertising: ${e.message}") + } + isCurrentlyAdvertising = false + advertiseCallback = null + } + + override fun isAdvertising(): Boolean { + return isCurrentlyAdvertising + } + + override fun addService(serviceUUID: String, isPrimary: Boolean, characteristics: Array) { + try { + openGattServerIfNeeded() + val server = gattServer ?: return + + val serviceType = if (isPrimary) { + BluetoothGattService.SERVICE_TYPE_PRIMARY + } else { + BluetoothGattService.SERVICE_TYPE_SECONDARY + } + + val service = BluetoothGattService(UUID.fromString(serviceUUID), serviceType) + + for (charConfig in characteristics) { + // Map properties + var properties = 0 + var needsCccd = false + for (prop in charConfig.properties) { + when (prop) { + GATTCharacteristicProperty.READ -> properties = properties or BluetoothGattCharacteristic.PROPERTY_READ + GATTCharacteristicProperty.WRITE -> properties = properties or BluetoothGattCharacteristic.PROPERTY_WRITE + GATTCharacteristicProperty.WRITEWITHOUTRESPONSE -> properties = properties or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE + GATTCharacteristicProperty.NOTIFY -> { + properties = properties or BluetoothGattCharacteristic.PROPERTY_NOTIFY + needsCccd = true + } + GATTCharacteristicProperty.INDICATE -> { + properties = properties or BluetoothGattCharacteristic.PROPERTY_INDICATE + needsCccd = true + } + } + } + + // Map permissions + var permissions = 0 + for (perm in charConfig.permissions) { + when (perm) { + GATTCharacteristicPermission.READABLE -> permissions = permissions or BluetoothGattCharacteristic.PERMISSION_READ + GATTCharacteristicPermission.WRITEABLE -> permissions = permissions or BluetoothGattCharacteristic.PERMISSION_WRITE + } + } + + val characteristic = BluetoothGattCharacteristic( + UUID.fromString(charConfig.uuid), + properties, + permissions + ) + + // Set initial value if provided + charConfig.value?.let { variant -> + variant.asSecondOrNull()?.let { arrayBuffer -> + val byteBuffer = arrayBuffer.getBuffer(copyIfNeeded = true) + val bytes = ByteArray(byteBuffer.remaining()) + byteBuffer.get(bytes) + characteristic.value = bytes + // Also cache it + val cacheKey = "$serviceUUID:${charConfig.uuid}" + characteristicValueCache[cacheKey] = Pair(bytes, System.currentTimeMillis()) + } + } + + // Add CCCD descriptor for notify/indicate characteristics + if (needsCccd) { + val cccdDescriptor = BluetoothGattDescriptor( + CCCD_UUID, + BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE + ) + cccdDescriptor.value = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + characteristic.addDescriptor(cccdDescriptor) + } + + service.addCharacteristic(characteristic) + } + + server.addService(service) + addedGattServices[serviceUUID] = service + + } catch (e: SecurityException) { + android.util.Log.e("BleNitro", "SecurityException adding service: ${e.message}") + } catch (e: Exception) { + android.util.Log.e("BleNitro", "Error adding service: ${e.message}") + } + } + + override fun removeService(serviceUUID: String) { + try { + val server = gattServer ?: return + val service = addedGattServices.remove(serviceUUID) ?: return + server.removeService(service) + } catch (e: SecurityException) { + android.util.Log.w("BleNitro", "SecurityException removing service: ${e.message}") + } catch (e: Exception) { + android.util.Log.w("BleNitro", "Error removing service: ${e.message}") + } + } + + override fun removeAllServices() { + try { + gattServer?.clearServices() + addedGattServices.clear() + characteristicValueCache.clear() + } catch (e: SecurityException) { + android.util.Log.w("BleNitro", "SecurityException removing all services: ${e.message}") + } catch (e: Exception) { + android.util.Log.w("BleNitro", "Error removing all services: ${e.message}") + } + } + + override fun updateCharacteristicValue(serviceUUID: String, characteristicUUID: String, data: ArrayBuffer) { + val byteBuffer = data.getBuffer(copyIfNeeded = true) + val bytes = ByteArray(byteBuffer.remaining()) + byteBuffer.get(bytes) + + val cacheKey = "$serviceUUID:$characteristicUUID" + characteristicValueCache[cacheKey] = Pair(bytes, System.currentTimeMillis()) + } + + override fun onReadRequest(callback: (deviceId: String, characteristicUUID: String, requestId: Double, offset: Double) -> Unit) { + readRequestCallback = callback + } + + override fun onWriteRequest(callback: (deviceId: String, characteristicUUID: String, requestId: Double, data: ArrayBuffer, responseNeeded: Boolean) -> Unit) { + writeRequestCallback = callback + } + + override fun respondToRequest(requestId: Double, status: Double, offset: Double, data: ArrayBuffer) { + val jsRequestId = requestId.toInt() + val nativeStatus = status.toInt() + val nativeOffset = offset.toInt() + + val byteBuffer = data.getBuffer(copyIfNeeded = true) + val bytes = ByteArray(byteBuffer.remaining()) + byteBuffer.get(bytes) + + // Check pending read requests first + pendingReadRequests.remove(jsRequestId)?.let { (device, nativeRequestId) -> + try { + gattServer?.sendResponse(device, nativeRequestId, nativeStatus, nativeOffset, bytes) + } catch (e: SecurityException) { + android.util.Log.w("BleNitro", "SecurityException sending read response: ${e.message}") + } + return + } + + // Check pending write requests + pendingWriteRequests.remove(jsRequestId)?.let { (device, nativeRequestId) -> + try { + gattServer?.sendResponse(device, nativeRequestId, nativeStatus, nativeOffset, bytes) + } catch (e: SecurityException) { + android.util.Log.w("BleNitro", "SecurityException sending write response: ${e.message}") + } + return + } + + android.util.Log.w("BleNitro", "respondToRequest: no pending request found for id $jsRequestId") + } + + override fun notifyCharacteristic(serviceUUID: String, characteristicUUID: String, data: ArrayBuffer) { + try { + val server = gattServer ?: return + val context = appContext ?: return + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager ?: return + + val service = addedGattServices[serviceUUID] ?: return + val characteristic = service.getCharacteristic(UUID.fromString(characteristicUUID)) ?: return + + val byteBuffer = data.getBuffer(copyIfNeeded = true) + val bytes = ByteArray(byteBuffer.remaining()) + byteBuffer.get(bytes) + characteristic.value = bytes + + // Also update cache + val cacheKey = "$serviceUUID:$characteristicUUID" + characteristicValueCache[cacheKey] = Pair(bytes, System.currentTimeMillis()) + + // Notify all connected devices + val connectedDevices = bluetoothManager.getConnectedDevices(BluetoothProfile.GATT_SERVER) + val isIndication = (characteristic.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0 + for (device in connectedDevices) { + try { + server.notifyCharacteristicChanged(device, characteristic, isIndication) + } catch (e: SecurityException) { + android.util.Log.w("BleNitro", "SecurityException notifying device ${device.address}: ${e.message}") + } + } + + } catch (e: SecurityException) { + android.util.Log.e("BleNitro", "SecurityException notifying characteristic: ${e.message}") + } catch (e: Exception) { + android.util.Log.e("BleNitro", "Error notifying characteristic: ${e.message}") + } + } + + override fun peripheralState(): BLEState { + // Android has no separate peripheral state; reuse adapter state + return state() + } + + override fun subscribeToPeripheralStateChange(callback: (state: BLEState) -> Unit): OperationResult { + try { + val context = appContext ?: return OperationResult(success = false, error = "Context not available") + + // Unsubscribe from any existing peripheral state subscription + unsubscribeFromPeripheralStateChange() + + // Store the callback + this.peripheralStateCallback = callback + + // Create and register broadcast receiver + peripheralStateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == BluetoothAdapter.ACTION_STATE_CHANGED) { + val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) + val bleState = bluetoothStateToBlEState(state) + peripheralStateCallback?.invoke(bleState) + } + } + } + val intentFilter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(peripheralStateReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED) + } else { + context.registerReceiver(peripheralStateReceiver, intentFilter) + } + + return OperationResult(success = true, error = null) + } catch (e: Exception) { + return OperationResult(success = false, error = "Error subscribing to peripheral state changes: ${e.message}") + } + } + + override fun unsubscribeFromPeripheralStateChange(): OperationResult { + try { + this.peripheralStateCallback = null + + peripheralStateReceiver?.let { receiver -> + val context = appContext + if (context != null) { + try { + context.unregisterReceiver(receiver) + } catch (e: IllegalArgumentException) { + // Receiver was not registered, ignore + } + } + peripheralStateReceiver = null + } + + return OperationResult(success = true, error = null) + } catch (e: Exception) { + return OperationResult(success = false, error = "Error unsubscribing from peripheral state changes: ${e.message}") + } + } } \ No newline at end of file diff --git a/ios/BleGattServerDelegate.swift b/ios/BleGattServerDelegate.swift new file mode 100644 index 0000000..372e6a7 --- /dev/null +++ b/ios/BleGattServerDelegate.swift @@ -0,0 +1,56 @@ +import Foundation +import CoreBluetooth + +/** + * Delegate for CBPeripheralManager events + * Forwards all peripheral manager callbacks to BleNitroBleManager + */ +class BleGattServerDelegate: NSObject, CBPeripheralManagerDelegate { + + // MARK: - Properties + weak var manager: BleNitroBleManager? + + // MARK: - Initialization + init(manager: BleNitroBleManager) { + self.manager = manager + super.init() + } + + // MARK: - CBPeripheralManagerDelegate + + func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { + guard let manager = manager else { return } + // CBPeripheralManager.state is CBPeripheralManagerState but maps to same raw values as CBManagerState + let cbState = CBManagerState(rawValue: peripheral.state.rawValue) ?? .unknown + let bleState = manager.mapCBManagerStateToBLEState(cbState) + manager.handlePeripheralStateChange(bleState) + } + + func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) { + manager?.handleAdvertisingStarted(error: error) + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { + if let error = error { + print("[BleNitro] Failed to add service \(service.uuid.uuidString): \(error.localizedDescription)") + } else { + print("[BleNitro] Successfully added service \(service.uuid.uuidString)") + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) { + manager?.handleReadRequest(request) + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { + manager?.handleWriteRequests(requests) + } + + func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { + manager?.handleCentralSubscribed(central: central, characteristic: characteristic) + } + + func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { + manager?.handleCentralUnsubscribed(central: central, characteristic: characteristic) + } +} diff --git a/ios/BleNitroBleManager.swift b/ios/BleNitroBleManager.swift index 5ade36d..5c1f85c 100644 --- a/ios/BleNitroBleManager.swift +++ b/ios/BleNitroBleManager.swift @@ -23,6 +23,24 @@ public class BleNitroBleManager: HybridNativeBleNitroSpec { private var centralManagerDelegate: BleCentralManagerDelegate! private let lazyInitEnabled: Bool private var isCentralManagerInitialized = false + + // MARK: - Peripheral Mode Properties + private var peripheralManager: CBPeripheralManager! + private var gattServerDelegate: BleGattServerDelegate! + private var isCurrentlyAdvertising = false + private var addedServices: [String: CBMutableService] = [:] + /// Cache for characteristic values with 60s TTL. Key format: "serviceUUID:characteristicUUID" + private var characteristicValueCache: [String: (data: Data, timestamp: Date)] = [:] + private var readRequestCallback: ((String, String, Double, Double) -> Void)? + private var writeRequestCallback: ((String, String, Double, ArrayBuffer, Bool) -> Void)? + private var peripheralStateCallback: ((BLEState) -> Void)? + /// Pending read requests keyed by requestId + private var pendingReadRequests: [Double: CBATTRequest] = [:] + /// Pending write requests keyed by requestId + private var pendingWriteRequests: [Double: [CBATTRequest]] = [:] + private var nextRequestId: Double = 1 + /// Subscribed centrals keyed by characteristic UUID string + private var subscribedCentrals: [String: [CBCentral]] = [:] // MARK: - Restore State Properties internal var restoreStateCallback: (([BLEDevice]) -> Void)? @@ -55,6 +73,11 @@ public class BleNitroBleManager: HybridNativeBleNitroSpec { } centralManager = CBCentralManager(delegate: centralManagerDelegate, queue: DispatchQueue.main, options: options.isEmpty ? nil : options) + + // Initialize peripheral manager for GATT server / advertising + gattServerDelegate = BleGattServerDelegate(manager: self) + peripheralManager = CBPeripheralManager(delegate: gattServerDelegate, queue: DispatchQueue.main) + isCentralManagerInitialized = true } @@ -778,6 +801,331 @@ public class BleNitroBleManager: HybridNativeBleNitroSpec { } } } + + // MARK: - Peripheral / GATT Server Methods + + public func startAdvertising(serviceUUIDs: [String], localName: String) throws { + ensureCentralManager() + guard peripheralManager.state == .poweredOn else { + throw NSError(domain: "BleNitroError", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Peripheral manager is not powered on" + ]) + } + + var advertisementData: [String: Any] = [:] + if !serviceUUIDs.isEmpty { + advertisementData[CBAdvertisementDataServiceUUIDsKey] = serviceUUIDs.map { CBUUID(string: $0) } + } + if !localName.isEmpty { + advertisementData[CBAdvertisementDataLocalNameKey] = localName + } + + peripheralManager.startAdvertising(advertisementData.isEmpty ? nil : advertisementData) + isCurrentlyAdvertising = true + } + + public func stopAdvertising() throws { + ensureCentralManager() + peripheralManager.stopAdvertising() + isCurrentlyAdvertising = false + } + + public func isAdvertising() throws -> Bool { + return isCurrentlyAdvertising + } + + public func addService(serviceUUID: String, isPrimary: Bool, characteristics: [GATTCharacteristicConfig]) throws { + ensureCentralManager() + + let cbUUID = CBUUID(string: serviceUUID) + let service = CBMutableService(type: cbUUID, primary: isPrimary) + + var cbCharacteristics: [CBMutableCharacteristic] = [] + for config in characteristics { + let charUUID = CBUUID(string: config.uuid) + + // Map properties + var cbProperties: CBCharacteristicProperties = [] + for prop in config.properties { + switch prop { + case .read: + cbProperties.insert(.read) + case .write: + cbProperties.insert(.write) + case .writewithoutresponse: + cbProperties.insert(.writeWithoutResponse) + case .notify: + cbProperties.insert(.notify) + case .indicate: + cbProperties.insert(.indicate) + } + } + + // Map permissions + var cbPermissions: CBAttributePermissions = [] + for perm in config.permissions { + switch perm { + case .readable: + cbPermissions.insert(.readable) + case .writeable: + cbPermissions.insert(.writeable) + } + } + + // Extract value: nil triggers didReceiveReadRequest (dynamic), non-nil is served by CoreBluetooth directly + var charValue: Data? = nil + if let variantValue = config.value { + switch variantValue { + case .first(_): + // NullType — dynamic value, use nil + charValue = nil + case .second(let arrayBuffer): + charValue = arrayBuffer.toData(copyIfNeeded: true) + } + } + + let characteristic = CBMutableCharacteristic( + type: charUUID, + properties: cbProperties, + value: charValue, + permissions: cbPermissions + ) + cbCharacteristics.append(characteristic) + } + + service.characteristics = cbCharacteristics + addedServices[serviceUUID] = service + peripheralManager.add(service) + } + + public func removeService(serviceUUID: String) throws { + ensureCentralManager() + guard let service = addedServices[serviceUUID] else { + throw NSError(domain: "BleNitroError", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Service not found: \(serviceUUID)" + ]) + } + peripheralManager.remove(service) + addedServices.removeValue(forKey: serviceUUID) + } + + public func removeAllServices() throws { + ensureCentralManager() + peripheralManager.removeAllServices() + addedServices.removeAll() + } + + public func updateCharacteristicValue(serviceUUID: String, characteristicUUID: String, data: ArrayBuffer) throws { + ensureCentralManager() + let writeData = data.toData(copyIfNeeded: true) + + // Update cache with 60s TTL + let cacheKey = "\(serviceUUID):\(characteristicUUID)" + characteristicValueCache[cacheKey] = (data: writeData, timestamp: Date()) + + // Also update the CBMutableCharacteristic value if possible + if let service = addedServices[serviceUUID], + let chars = service.characteristics { + let targetUUID = CBUUID(string: characteristicUUID) + if let mutableChar = chars.first(where: { $0.uuid == targetUUID }) as? CBMutableCharacteristic { + mutableChar.value = writeData + } + } + } + + public func onReadRequest(callback: @escaping (String, String, Double, Double) -> Void) throws { + self.readRequestCallback = callback + } + + public func onWriteRequest(callback: @escaping (String, String, Double, ArrayBuffer, Bool) -> Void) throws { + self.writeRequestCallback = callback + } + + public func respondToRequest(requestId: Double, status: Double, offset: Double, data: ArrayBuffer) throws { + ensureCentralManager() + + let attResult = CBATTError.Code(rawValue: Int(status)) ?? .success + let responseData = data.toData(copyIfNeeded: true) + + // Handle read request response + if let request = pendingReadRequests[requestId] { + if !responseData.isEmpty { + let requestOffset = Int(request.offset) + if requestOffset < responseData.count { + request.value = responseData.subdata(in: requestOffset.. BLEState { + ensureCentralManager() + let cbState = CBManagerState(rawValue: peripheralManager.state.rawValue) ?? .unknown + return mapCBManagerStateToBLEState(cbState) + } + + public func subscribeToPeripheralStateChange( + callback: @escaping (BLEState) -> Void + ) throws -> OperationResult { + ensureCentralManager() + self.peripheralStateCallback = callback + return OperationResult(success: true, error: nil) + } + + public func unsubscribeFromPeripheralStateChange() throws -> OperationResult { + self.peripheralStateCallback = nil + return OperationResult(success: true, error: nil) + } + + // MARK: - Internal Peripheral Handlers + + internal func handlePeripheralStateChange(_ state: BLEState) { + peripheralStateCallback?(state) + } + + internal func handleAdvertisingStarted(error: Error?) { + if let error = error { + print("[BleNitro] Advertising failed: \(error.localizedDescription)") + isCurrentlyAdvertising = false + } else { + print("[BleNitro] Advertising started successfully") + isCurrentlyAdvertising = true + } + } + + internal func handleReadRequest(_ request: CBATTRequest) { + let deviceId = request.central.identifier.uuidString + let characteristicUUID = request.characteristic.uuid.uuidString + + // Check cache first with 60s TTL + let cacheKey = findCacheKey(forCharacteristicUUID: characteristicUUID) + if let cacheKey = cacheKey, + let cached = characteristicValueCache[cacheKey], + Date().timeIntervalSince(cached.timestamp) < 60.0 { + let offset = Int(request.offset) + if offset < cached.data.count { + request.value = cached.data.subdata(in: offset.. String? { + for (key, _) in characteristicValueCache { + if key.hasSuffix(":\(charUUID)") { + return key + } + } + return nil + } } // MARK: - CBCentralManagerDelegate Implementation From 0967816e224dd61d429c387186e57ca1474c9565 Mon Sep 17 00:00:00 2001 From: Anthony Date: Thu, 16 Apr 2026 12:05:21 -0400 Subject: [PATCH 3/3] docs: add peripheral mode to example app and README Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 113 ++++++++++++++++++++++++++++++++++++++++++++++++ example/App.tsx | 73 ++++++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 06836e3..0e06053 100644 --- a/README.md +++ b/README.md @@ -470,6 +470,119 @@ type DisconnectEventCallback = (deviceId: string, interrupted: boolean, error: s type CharacteristicUpdateCallback = (characteristicId: string, data: ArrayBuffer) => void; ``` +## 📡 Peripheral Mode + +Peripheral mode lets your device act as a BLE server — advertising services and responding to read/write requests from nearby centrals. + +### Config Plugin Setup + +Enable peripheral mode in your Expo config: + +```json +{ + "expo": { + "plugins": [ + [ + "react-native-ble-nitro", + { + "modes": ["peripheral"], + "androidAdvertisingEnabled": true + } + ] + ] + } +} +``` + +- `modes: ['peripheral']` adds the `bluetooth-peripheral` background mode to `Info.plist` on iOS +- `androidAdvertisingEnabled: true` adds the `BLUETOOTH_ADVERTISE` permission on Android + +### Adding a GATT Service + +Define your service and characteristics before starting to advertise: + +```typescript +import { + BleNitro, + GATTCharacteristicProperty, + GATTCharacteristicPermission, +} from 'react-native-ble-nitro'; + +const ble = BleNitro.instance(); + +const MY_SERVICE_UUID = '12345678-1234-1234-1234-123456789abc'; +const MY_CHAR_UUID = '12345678-1234-1234-1234-123456789abd'; + +ble.addService(MY_SERVICE_UUID, true, [ + { + uuid: MY_CHAR_UUID, + properties: [GATTCharacteristicProperty.Read, GATTCharacteristicProperty.Notify], + permissions: [GATTCharacteristicPermission.Readable], + value: null, // null = dynamic value, respond via onReadRequest + }, +]); +``` + +### Starting & Stopping Advertising + +```typescript +// Start advertising (pass service UUIDs and optional local name) +ble.startAdvertising([MY_SERVICE_UUID], 'MyDevice'); + +// Check advertising state +const advertising = ble.isAdvertising(); + +// Stop advertising +ble.stopAdvertising(); +``` + +### Handling Read Requests + +When a characteristic has `value: null`, read requests are delivered to your callback. You must respond with `respondToRequest`: + +```typescript +ble.onReadRequest((deviceId, characteristicUUID, requestId, offset) => { + console.log(`Read request from ${deviceId} for ${characteristicUUID}`); + + // Respond with data (status 0 = GATT_SUCCESS) + const responseData = [0x48, 0x65, 0x6c, 0x6c, 0x6f]; // "Hello" + ble.respondToRequest(requestId, 0, offset, responseData); +}); +``` + +### Updating Characteristic Values + +Update the cached value of a characteristic (used when the characteristic has a static value or before sending notifications): + +```typescript +const newValue = [0x42, 0x50, 0x4d]; // "BPM" +ble.updateCharacteristicValue(MY_SERVICE_UUID, MY_CHAR_UUID, newValue); +``` + +### Sending Notifications + +Push data to subscribed centrals: + +```typescript +const notificationData = [0x01, 0x02, 0x03]; +ble.notifyCharacteristic(MY_SERVICE_UUID, MY_CHAR_UUID, notificationData); +``` + +### Service & Cleanup + +```typescript +// Remove a single service +ble.removeService(MY_SERVICE_UUID); + +// Remove all services +ble.removeAllServices(); +``` + +### iOS Background Behavior + +> [!NOTE] +> When your app is backgrounded on iOS, the advertisement packet is reduced to the service UUID only — the local name is stripped by the system. GATT read/write requests from connected centrals continue to work normally in the background. + ## 🏗️ Architecture ### Nitro Modules Foundation diff --git a/example/App.tsx b/example/App.tsx index 9e2dac8..23af4e6 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -2,7 +2,8 @@ import { StatusBar } from 'expo-status-bar'; import { AppState, PermissionsAndroid, Platform, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { createBle } from './src/bluetooth'; import { useLayoutEffect, useRef, useState } from 'react'; -import type { BLEDevice, AsyncSubscription } from 'react-native-ble-nitro'; +import type { BLEDevice, AsyncSubscription, GATTCharacteristicConfig } from 'react-native-ble-nitro'; +import { GATTCharacteristicProperty, GATTCharacteristicPermission } from 'react-native-ble-nitro'; import { SafeAreaView } from 'react-native-safe-area-context'; const HEART_RATE_SERVICE_UUID = '0000180d-0000-1000-8000-00805f9b34fb'.toLowerCase(); @@ -25,6 +26,10 @@ const PROFILE_SOFTWARE_UUID = '00002A28-0000-1000-8000-00805f9b34fb'.toLowerCase const PROFILE_MODEL_UUID = '00002A24-0000-1000-8000-00805f9b34fb'.toLowerCase(); const PROFILE_SYSTEMID_UUID = '00002A23-0000-1000-8000-00805f9b34fb'.toLowerCase(); +// Peripheral mode constants +const PERIPHERAL_SERVICE_UUID = '0000aa00-0000-1000-8000-00805f9b34fb'; +const PERIPHERAL_CHAR_UUID = '0000aa01-0000-1000-8000-00805f9b34fb'; + let unsubscribeRx: AsyncSubscription['remove'] | null = null; let unsubscribeHr: AsyncSubscription['remove'] | null = null; @@ -39,6 +44,8 @@ export default function App() { const [connectedDeviceCharacteristics, setConnectedDeviceCharacteristics] = useState>({}); const [bleNotificationSubscription, setBleNotificationSubscription] = useState(false); const [hrNotificationSubscription, setHrNotificationSubscription] = useState(false); + const [showPeripheral, setShowPeripheral] = useState(false); + const [isAdvertising, setIsAdvertising] = useState(false); const [logs, setLogs] = useState([]); const bleModule = useRef(createBle({ onEnabledChange: (enabled) => setIsEnabled(enabled), @@ -330,6 +337,47 @@ export default function App() { logMessage('System ID', hexFromBytes(systemid)); } + // --- Peripheral Mode --- + + const setupPeripheralService = () => { + const characteristics: GATTCharacteristicConfig[] = [ + { + uuid: PERIPHERAL_CHAR_UUID, + properties: [GATTCharacteristicProperty.Read], + permissions: [GATTCharacteristicPermission.Readable], + value: null, // dynamic — handled by onReadRequest + }, + ]; + ble.instance.addService(PERIPHERAL_SERVICE_UUID, true, characteristics); + logMessage('Added peripheral service'); + + ble.instance.onReadRequest((deviceId, characteristicUUID, requestId, offset) => { + logMessage(`Read request from ${deviceId} char=${characteristicUUID}`); + const response = Array.from(new TextEncoder().encode('Hello from peripheral')); + ble.instance.respondToRequest(requestId, 0, offset, response); + logMessage('Responded to read request'); + }); + logMessage('Registered onReadRequest handler'); + }; + + const startPeripheralAdvertising = () => { + try { + setupPeripheralService(); + ble.instance.startAdvertising([PERIPHERAL_SERVICE_UUID], 'BleNitroExample'); + setIsAdvertising(true); + logMessage('Advertising started'); + } catch (e) { + logMessage('Advertising error: ' + String(e)); + } + }; + + const stopPeripheralAdvertising = () => { + ble.instance.stopAdvertising(); + ble.instance.removeAllServices(); + setIsAdvertising(false); + logMessage('Advertising stopped'); + }; + const logMessage = (...message: (string | number)[]) => { const date = new Date().toLocaleTimeString('de-DE'); setLogs((prev) => [...prev, `${date} - ${message.join(' ')}`]); @@ -535,6 +583,29 @@ export default function App() { )} )} + {!connectedDeviceId && ( + setShowPeripheral((v) => !v)} + > + {showPeripheral ? 'Hide Peripheral Mode' : 'Show Peripheral Mode'} + + )} + {showPeripheral && !connectedDeviceId && ( + + Peripheral Mode + Advertising: {isAdvertising.toString()} + {!isAdvertising ? ( + + Start Advertising + + ) : ( + + Stop Advertising + + )} + + )} {connectedDeviceId && ( <> Connected Device: