Vamos a ver cómo crear una aplicación React Native para Adnroid que permita la comunicación BLE (Bluetooth Low Energy) con un ESP32. Usaremos React Native para desarrollar un terminal BLE en Android que pueda comunicarse con un ESP32 NodeMCU o cualquier otro dispositivo compatible.
Hardware
- Un ordenador con React Native y Node.js instalados
- Un dispositivo Android con BLE
- Un cable USB para conectar el ordenador al dispositivo
- Un dispositivo BLE (ESP32)
Código de gestión BLE para ESP32
Para probar la aplicación React Native, vamos a utilizar el código de gestión BLE para ESP32.
/* Based on Neil Kolban example for IDF: https://github.com/nkolban/esp32-snippets/blob/master/cpp_utils/tests/BLE%20Tests/SampleWrite.cpp Ported to Arduino ESP32 by Evandro Copercini */ #include <BLEDevice.h> #include <BLEUtils.h> #include <BLEServer.h> // See the following for generating UUIDs: // https://www.uuidgenerator.net/ #define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b" #define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8" BLECharacteristic *pCharacteristic = NULL; std::string msg; class MyCallbacks: public BLECharacteristicCallbacks { void onWrite(BLECharacteristic *pCharacteristic) { std::string value = pCharacteristic->getValue(); if (value.length() > 0) { Serial.println("*********"); Serial.print("New value: "); for (int i = 0; i < value.length(); i++) Serial.print(value[i]); Serial.println(); Serial.println("*********"); } } }; class MyServerCallbacks: public BLEServerCallbacks { void onConnect(BLEServer* pServer) { Serial.println("Client connected"); } void onDisconnect(BLEServer* pServer) { Serial.println("Client disconnected"); BLEDevice::startAdvertising(); // needed for reconnection } }; void setup() { Serial.begin(115200); Serial.println("1- Download and install an BLE Terminal Free"); Serial.println("2- Scan for BLE devices in the app"); Serial.println("3- Connect to ESP32BLE"); Serial.println("4- Go to CUSTOM CHARACTERISTIC in CUSTOM SERVICE and write something"); BLEDevice::init("ESP32BLE"); BLEServer *pServer = BLEDevice::createServer(); pServer->setCallbacks(new MyServerCallbacks()); BLEService *pService = pServer->createService(SERVICE_UUID); pCharacteristic = pService->createCharacteristic( CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE ); pCharacteristic->setCallbacks(new MyCallbacks()); pCharacteristic->setValue("Hello World"); pService->start(); BLEAdvertising *pAdvertising = pServer->getAdvertising(); pAdvertising->addServiceUUID(SERVICE_UUID); pAdvertising->setScanResponse(true); pAdvertising->setMinPreferred(0x06); // functions that help with iPhone connections issue pAdvertising->setMinPreferred(0x12); pAdvertising->start(); Serial.print("Server address:"); Serial.println(BLEDevice::getAddress().toString().c_str()); } void loop() { readSerialPort(); //Send data to slave if(msg!=""){ pCharacteristic->setValue(msg); msg=""; } delay(2000); } void readSerialPort(){ while (Serial.available()) { delay(10); if (Serial.available() >0) { char c = Serial.read(); //gets one byte from serial buffer msg += c; //add to String } } Serial.flush(); //clean buffer }
Añadimos la función BLEServerCallbacks a la gestión del Servidor BLE para detectar la desconexión e iniciar la publicidad para poder reconectar el ESP32.
pServer->setCallbacks(new MyServerCallbacks());
Aplicación React Native para la gestión de BLE
Para gestionar la comunicación BLE (Bluetooth Low Energy) en el dispositivo Android, utilizamos la biblioteca react-native-ble-manager
npm install react-native-ble-manager --save
Para configurar el proyecto de aplicación, siga el tutorial anterior.
En el archivo App.tsx, para utilizar la biblioteca la importamos utilizando el comando
import BleManager from 'react-native-ble-manager';
Estamos creando un componente funcional que contendrá los elementos que necesitamos para gestionar la comunicación BLE
let serviceid="4fafc201-1fb5-459e-8fcc-c5c9c331914b"; let caracid="beb5483e-36e1-4688-b7f5-ea07361b26a8"; const BluetoothBLETerminal = () => { const [devices, setDevices] = useState<any[]>([]); const [paired, setPaired] = useState<any[]>([]); const [selectedDevice, setSelectedDevice] = useState<Peripheral>(); const [messageToSend, setMessageToSend] = useState(""); const [receivedMessage, setReceivedMessage] = useState(""); const [isConnected, setIsConnected] = useState(false); const [intervalId, setIntervalId] = useState<NodeJS.Timer>(); const [isScanning, setIsScanning] = useState(false);
N.B.: es posible crear un componente derivado de ReactNative.Components
Gestión de permisos
Para descubrir y conectarte a dispositivos Bluetooth, necesitas al menos 3 permisos:
- BLUETOOTH_SCAN
- BLUETOOTH_CONNECT
- ACCESS_FINE_LOCATION
N.B.: estos permisos dependen de la versión y el SO utilizados
Estas son las etiquetas que hay que añadir al archivo AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
En el archivo App.tsx, creamos la función requestBluetoothPermission()
if (Platform.OS === 'android' && Platform.Version >= 23) { PermissionsAndroid.requestMultiple([ PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, PermissionsAndroid.PERMISSIONS.LIGAÇÃO_DE_DISTRIBUIÇÃO, PermissionsAndroid.PERMISSIONS.LOCALIZAÇÃO_DE_ACESSO, ]).then(result => { if ( (result['android.permission.BLUETOOTH_SCAN'] && result['android.permission.LIGAÇÃO_DE_DISTRIBUIÇÃO'] && result['android.permission.LOCALIZAÇÃO_DE_ACESSO'] === 'granted') || (result['android.permission.BLUETOOTH_SCAN'] && result['android.permission.LIGAÇÃO_DE_DISTRIBUIÇÃO'] && result['android.permission.LOCALIZAÇÃO_DE_ACESSO'] === 'never_ask_again') ) { console.log('User accepted'); } else { console.log('User refused'); } }); }
Función de gestión BLE
Las funciones para gestionar Bluetooth LE son las siguientes:
- descubrir dispositivos bluetooth startDeviceDiscovery() (utilizo dispositivos emparejados)
- conectar con el dispositivo connectToDevice()
- desconectarDelDispositivo()
- enviar mensajes sendMessage()
- leer mensajes de la comunicación readData()
N.B.: En este ejemplo, estamos escribiendo y leyendo de la misma característica. Por lo tanto, leemos el valor registrado pulsando la tecla
const checkBluetoothEnabled = async () => { try { // turn on bluetooth if it is not on BleManager.enableBluetooth().then(() => { console.log('Bluetooth is turned on!'); }); } catch (error) { console.error('BLE is not available on this device.'); } } const startScan = () => { if (!isScanning) { BleManager.scan([], 5, true) .then(() => { console.log('Scanning...'); setIsScanning(true); }) .catch(error => { console.error(error); }); } }; const startDeviceDiscovery = async () => { BleManager.getBondedPeripherals().then((bondedPeripheralsArray) => { // Each peripheral in returned array will have id and name properties console.log("Bonded peripherals: " + bondedPeripheralsArray.length); setPaired(bondedPeripheralsArray); }); /*BleManager.getDiscoveredPeripherals().then((peripheralsArray) => { // Success code console.log("Discovered peripherals: " + peripheralsArray.length); });*/ } const connectToDevice = async (device: Peripheral) => { BleManager.connect(device.id) .then(() => { // Success code console.log("Connected"); setSelectedDevice(device); setIsConnected(true); BleManager.retrieveServices(device.id).then( (deviceInfo) => { // Success code console.log("Device info:", deviceInfo); } ); }) .catch((error) => { // Failure code console.log(error); }); } const sendMessage = async () => { if(selectedDevice && isConnected){ try { const buffer = Buffer.from(messageToSend); BleManager.write( selectedDevice.id, serviceid, caracid, buffer.toJSON().data ).then(() => { // Success code console.log("Write: " + buffer.toJSON().data); }) .catch((error) => { // Failure code console.log(error); }); } catch (error) { console.error('Error sending message:', error); } } } const readData = async () => { if (selectedDevice && isConnected) { BleManager.read( selectedDevice.id, serviceid, caracid ) .then((readData) => { // Success code console.log("Read: " + readData); const message = Buffer.from(readData); //const sensorData = buffer.readUInt8(1, true); if(receivedMessage.length>300){ setReceivedMessage(""); //reset received message if length higher than 300 } setReceivedMessage(receivedMessage => receivedMessage + message +"\n" ); console.log("receivedMessage length",receivedMessage.length) }) .catch((error) => { // Failure code console.log("Error reading message:",error); }); } } // disconnect from device const disconnectFromDevice = (device: Peripheral) => { BleManager.disconnect(device.id) .then(() => { setSelectedDevice(undefined); setIsConnected(false); setReceivedMessage(""); clearInterval(intervalId); console.log("Disconnected from device"); }) .catch((error) => { // Failure code console.log("Error disconnecting:",error); }); };
La función de representación en pantalla
Para la visualización, hemos decidido ponerlo todo en la misma pantalla. Habrá :
- Un título
- La liste des appareils qui n’apparait que si on n’est pas connecté (!isConnected &&)
- Un encart type terminal de communication qui n’apparait que si on est connecté (selectedDevice && isConnected &&)
- TextInput para escribir el mensaje a enviar messageToSend
- Un botón de envío
- Un botón de reproducción
- Un cuadro de texto para mostrar receivedMessage
- Un botón de desconexión
return ( <View> <Text style={{ fontSize: 30, textAlign: 'center', borderBottomWidth: 1, }}> AC Bluetooth Terminal </Text> <ScrollView> {!isConnected && ( <> {/* <Text>Available Devices:</Text> {devices.map((device) => ( <Button key={device.id} title={device.name || 'Unnamed Device'} onPress={() => this.connectToDevice(device)} /> ))} */} <Text>Paired Devices:</Text> {paired.map((pair: BluetoothDevice) => ( <View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2, }}> <View style={styles.deviceItem}> <Text style={styles.deviceName}>{pair.name}</Text> <Text style={styles.deviceInfo}>{pair.id}</Text> </View> <TouchableOpacity onPress={() => isConnected ? disconnect() : connectToDevice(pair) } style={styles.deviceButton}> <Text style={[ styles.scanButtonText, {fontWeight: 'bold', fontSize: 12}, ]}> {isConnected ? 'Disconnect' : 'Connect'} </Text> </TouchableOpacity> </View> ))} </> )} {selectedDevice && isConnected && ( <> <View style={{ flexDirection: 'row', justifyContent: 'space-between', margin: 5, }}> <View style={styles.deviceItem}> <Text style={styles.deviceName}>{selectedDevice.name}</Text> <Text style={styles.deviceInfo}>{selectedDevice.id}</Text> </View> <TouchableOpacity onPress={() => isConnected ? disconnect() : connectToDevice(selectedDevice) } style={styles.deviceButton}> <Text style={styles.scanButtonText}> {isConnected ? 'Disconnect' : 'Connect'} </Text> </TouchableOpacity> </View> <View style={{ flexDirection: 'row', justifyContent: 'space-between', margin: 5, }}> <TextInput style={{ backgroundColor: '#888888', margin: 2, borderRadius: 15, flex:3, }} placeholder="Enter a message" value={messageToSend} onChangeText={(text) => setMessageToSend(text )} /> <TouchableOpacity onPress={() => sendMessage() } style={[styles.sendButton]}> <Text style={[ styles.scanButtonText, ]}> SEND </Text> </TouchableOpacity> </View> <Text>Received Message:</Text> <TextInput editable = {false} multiline numberOfLines={20} maxLength={100} style={{ backgroundColor: '#333333', margin: 10, borderRadius: 2, borderWidth: 1, borderColor: '#EEEEEE', textAlignVertical: 'top', }} > {receivedMessage} </TextInput> </> )} </ScrollView> </View> );
Resultados
Como el emparejamiento no está gestionado por la aplicación, el ESP32 debe estar emparejado antes de utilizar la aplicación. Una vez que el código se ha cargado en el ESP32, puede iniciar la aplicación en el teléfono mediante el comando
npx react-native start
Código completo de la aplicación React Native
/** * Sample React Native App * https://github.com/facebook/react-native * * @format * https://github.com/innoveit/react-native-ble-manager * https://blog.logrocket.com/using-react-native-ble-manager-mobile-app/ */ import React, {useState, useEffect} from 'react'; import { StyleSheet, Dimensions, View, ScrollView, Text, TextInput, PermissionsAndroid, TouchableOpacity, Platform} from 'react-native'; import BleManager,{Peripheral} from 'react-native-ble-manager'; import { Buffer } from 'buffer'; let serviceid="4fafc201-1fb5-459e-8fcc-c5c9c331914b"; let caracid="beb5483e-36e1-4688-b7f5-ea07361b26a8"; const BluetoothBLETerminal = () => { const [devices, setDevices] = useState<any[]>([]); const [paired, setPaired] = useState<any[]>([]); const [selectedDevice, setSelectedDevice] = useState<Peripheral>(); const [messageToSend, setMessageToSend] = useState(""); const [receivedMessage, setReceivedMessage] = useState(""); const [isConnected, setIsConnected] = useState(false); const [intervalId, setIntervalId] = useState<NodeJS.Timer>(); const [isScanning, setIsScanning] = useState(false); const checkBluetoothEnabled = async () => { try { // turn on bluetooth if it is not on BleManager.enableBluetooth().then(() => { console.log('Bluetooth is turned on!'); }); } catch (error) { console.error('BLE is not available on this device.'); } } const startScan = () => { if (!isScanning) { BleManager.scan([], 5, true) .then(() => { console.log('Scanning...'); setIsScanning(true); }) .catch(error => { console.error(error); }); } }; const startDeviceDiscovery = async () => { BleManager.getBondedPeripherals().then((bondedPeripheralsArray) => { // Each peripheral in returned array will have id and name properties console.log("Bonded peripherals: " + bondedPeripheralsArray.length); setPaired(bondedPeripheralsArray); }); /*BleManager.getDiscoveredPeripherals().then((peripheralsArray) => { // Success code console.log("Discovered peripherals: " + peripheralsArray.length); });*/ } const connectToDevice = async (device: Peripheral) => { BleManager.connect(device.id) .then(() => { // Success code console.log("Connected"); setSelectedDevice(device); setIsConnected(true); BleManager.retrieveServices(device.id).then( (deviceInfo) => { // Success code console.log("Device info:", deviceInfo); } ); }) .catch((error) => { // Failure code console.log(error); }); } const sendMessage = async () => { if(selectedDevice && isConnected){ try { const buffer = Buffer.from(messageToSend); BleManager.write( selectedDevice.id, serviceid, caracid, buffer.toJSON().data ).then(() => { // Success code console.log("Write: " + buffer.toJSON().data); }) .catch((error) => { // Failure code console.log(error); }); } catch (error) { console.error('Error sending message:', error); } } } const readData = async () => { if (selectedDevice && isConnected) { BleManager.read( selectedDevice.id, serviceid, caracid ) .then((readData) => { // Success code console.log("Read: " + readData); const message = Buffer.from(readData); //const sensorData = buffer.readUInt8(1, true); if(receivedMessage.length>300){ setReceivedMessage(""); } setReceivedMessage(receivedMessage => receivedMessage + message +"\n" ); console.log("receivedMessage length",receivedMessage.length) }) .catch((error) => { // Failure code console.log("Error reading message:",error); }); } } /*useEffect(() => { let intervalId: string | number | NodeJS.Timer | undefined; if (selectedDevice && isConnected) { intervalId = setInterval(() => readData(), 100); setIntervalId(intervalId); } return () => { clearInterval(intervalId); }; }, [isConnected,selectedDevice]);*/ // disconnect from device const disconnectFromDevice = (device: Peripheral) => { BleManager.disconnect(device.id) .then(() => { setSelectedDevice(undefined); setIsConnected(false); setReceivedMessage(""); clearInterval(intervalId); console.log("Disconnected from device"); }) .catch((error) => { // Failure code console.log("Error disconnecting:",error); }); /*BleManager.removeBond(peripheral.id) .then(() => { peripheral.connected = false; peripherals.set(peripheral.id, peripheral); setConnectedDevices(Array.from(peripherals.values())); setDiscoveredDevices(Array.from(peripherals.values())); Alert.alert(`Disconnected from ${peripheral.name}`); }) .catch(() => { console.log('fail to remove the bond'); });*/ }; useEffect(() => { checkBluetoothEnabled(); if (Platform.OS === 'android' && Platform.Version >= 23) { PermissionsAndroid.requestMultiple([ PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, ]).then(result => { if ( (result['android.permission.BLUETOOTH_SCAN'] && result['android.permission.BLUETOOTH_CONNECT'] && result['android.permission.ACCESS_FINE_LOCATION'] === 'granted') || (result['android.permission.BLUETOOTH_SCAN'] && result['android.permission.BLUETOOTH_CONNECT'] && result['android.permission.ACCESS_FINE_LOCATION'] === 'never_ask_again') ) { console.log('User accepted'); } else { console.log('User refused'); } }); } BleManager.start({showAlert: false}).then(() => { console.log('BleManager initialized'); startDeviceDiscovery(); }).catch((error) => { // Failure code console.log("Error requesting permission:",error); }); BleManager.checkState().then((state) => console.log(`current BLE state = '${state}'.`) ); BleManager.getConnectedPeripherals([]).then((peripheralsArray) => { // Success code console.log("Connected peripherals: " + peripheralsArray.length); }); BleManager.getBondedPeripherals().then((bondedPeripheralsArray) => { // Each peripheral in returned array will have id and name properties console.log("Bonded peripherals: " + bondedPeripheralsArray.length); //setBoundedDevices(bondedPeripheralsArray); }); BleManager.getDiscoveredPeripherals().then((peripheralsArray) => { // Success code console.log("Discovered peripherals: " + peripheralsArray.length); }); /*let stopDiscoverListener = BleManagerEmitter.addListener( 'BleManagerDiscoverPeripheral', peripheral => { peripherals.set(peripheral.id, peripheral); }, );*/ /*let stopConnectListener = BleManagerEmitter.addListener( 'BleManagerConnectPeripheral', peripheral => { console.log('BleManagerConnectPeripheral:', peripheral); peripherals.set(peripheral.id, peripheral); setConnectedDevices(Array.from(peripherals.values())); }, );*/ /*let stopScanListener = BleManagerEmitter.addListener( 'BleManagerStopScan', () => { setIsScanning(false); console.log('scan stopped'); BleManager.getDiscoveredPeripherals().then((peripheralsArray) => { // Success code console.log("Discovered peripherals: " + peripheralsArray.length); for (let i = 0; i < peripheralsArray.length; i++) { let peripheral = peripheralsArray[i]; console.log("item:", peripheral); //peripheral.connected = true; peripherals.set(peripheral.id, peripheral); setDiscoveredDevices(peripheralsArray); } }); }, );*/ return () => { /*stopDiscoverListener.remove(); stopConnectListener.remove(); stopScanListener.remove();*/ }; }, []) return ( <View style={[styles.mainBody]}> <Text style={{ fontSize: 30, textAlign: 'center', borderBottomWidth: 1, }}> AC BLE Terminal </Text> <ScrollView> {!isConnected && ( <> <TouchableOpacity onPress={() => startDeviceDiscovery() } style={[styles.deviceButton]}> <Text style={[ styles.scanButtonText, ]}> SCAN </Text> </TouchableOpacity> {/* <Text>Available Devices:</Text> {devices.map((device) => ( <Button key={device.id} title={device.name || 'Unnamed Device'} onPress={() => this.connectToDevice(device)} /> ))} */} <Text>Paired Devices:</Text> {paired.map((pair,i) => ( <View key={i} style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2, }}> <View style={styles.deviceItem}> <Text style={styles.deviceName}>{pair.name}</Text> <Text style={styles.deviceInfo}>{pair.id}, rssi: {pair.rssi}</Text> </View> <TouchableOpacity onPress={() => isConnected ? disconnectFromDevice(pair) : connectToDevice(pair) } style={styles.deviceButton}> <Text style={[ styles.scanButtonText, {fontWeight: 'bold', fontSize: 12}, ]}> {isConnected ? 'Disconnect' : 'Connect'} </Text> </TouchableOpacity> </View> ))} </> )} {selectedDevice && isConnected && ( <> <View style={{ flexDirection: 'row', justifyContent: 'space-between', margin: 5, }}> <View style={styles.deviceItem}> <Text style={styles.deviceName}>{selectedDevice.name}</Text> <Text style={styles.deviceInfo}>{selectedDevice.id}, rssi: {selectedDevice.rssi}</Text> </View> <TouchableOpacity onPress={() => isConnected ? disconnectFromDevice(selectedDevice) : connectToDevice(selectedDevice) } style={styles.deviceButton}> <Text style={styles.scanButtonText}> {isConnected ? 'Disconnect' : 'Connect'} </Text> </TouchableOpacity> </View> <View style={{ flexDirection: 'row', justifyContent: 'space-between', margin: 5, }}> <TextInput style={{ backgroundColor: '#888888', margin: 2, borderRadius: 15, flex:3, }} placeholder="Enter a message" value={messageToSend} onChangeText={(text) => setMessageToSend(text)} /> <TouchableOpacity onPress={() => sendMessage() } style={[styles.sendButton]}> <Text style={[ styles.scanButtonText, ]}> SEND </Text> </TouchableOpacity> </View> <View style={{ flexDirection: 'row', justifyContent: 'space-between', margin: 5, }}> <Text style={{textAlignVertical: 'center'}}>Received Message:</Text> <TouchableOpacity onPress={() => readData() } style={[styles.deviceButton]}> <Text style={[ styles.scanButtonText, ]}> READ </Text> </TouchableOpacity> </View> <TextInput editable = {false} multiline numberOfLines={20} maxLength={300} style={{ backgroundColor: '#333333', margin: 10, borderRadius: 2, borderWidth: 1, borderColor: '#EEEEEE', textAlignVertical: 'top', }} > {receivedMessage} </TextInput> </> )} </ScrollView> </View> ); };//end of component //https://medium.com/supercharges-mobile-product-guide/reactive-styles-in-react-native-79a41fbdc404 export const theme = { smallPhone: 0, phone: 290, tablet: 750, } const windowHeight = Dimensions.get('window').height; const styles = StyleSheet.create({ mainBody: { flex: 1, justifyContent: 'center', height: windowHeight, ...Platform.select ({ ios: { fontFamily: "Arial", }, android: { fontFamily: "Roboto", }, }), }, scanButtonText: { color: 'white', fontWeight: 'bold', fontSize: 12, textAlign: 'center', }, noDevicesText: { textAlign: 'center', marginTop: 10, fontStyle: 'italic', }, deviceItem: { marginBottom: 2, }, deviceName: { fontSize: 14, fontWeight: 'bold', }, deviceInfo: { fontSize: 8, }, deviceButton: { backgroundColor: '#2196F3', padding: 10, borderRadius: 10, margin: 2, paddingHorizontal: 20, }, sendButton: { backgroundColor: '#2196F3', padding: 15, borderRadius: 15, margin: 2, paddingHorizontal: 20, }, }); export default BluetoothBLETerminal;