Vamos a ver cómo crear una aplicación React Native que permita la comunicación Bluetooth entre un dispositivo Android y un ESP32. Usamos React Native para desarrollar un terminal Bluetooth en Android que se comunica con un ESP32 NodeMCU. El NodeMCU se utiliza para probar nuestra aplicación con un objeto conectado, pero la aplicación puede funcionar con cualquier dispositivo Bluetooth.
Hardware
- Un ordenador con React Native y Node.js instalados
- Un dispositivo Android con Bluetooth
- Un cable USB para conectar el ordenador al dispositivo
- Un dispositivo Bluetooth (ESP32)
Código de gestión Bluetooth para ESP32
Para probar la aplicación React Native, vamos a utilizar el código de gestión de Bluetooth para ESP32.
#include "BluetoothSerial.h" #if !defined(CONFIG_BT_ENABLED) || !defined(CONFIG_BLUEDROID_ENABLED) #error Bluetooth is not enabled! Please run `make menuconfig` to and enable it #endif BluetoothSerial SerialBT; void callback(esp_spp_cb_event_t event, esp_spp_cb_param_t *param) { if (event == ESP_SPP_SRV_OPEN_EVT) { Serial.println("Client Connected"); } if (event == ESP_SPP_CLOSE_EVT ) { Serial.println("Client disconnected"); //SerialBT.flush(); //SerialBT.disconnect(); //SerialBT.end(); //SerialBT.begin("ESP32BT"); ESP.restart(); // needed to be able to reconnect } } void setup() { Serial.begin(115200); SerialBT.register_callback(callback); SerialBT.begin("ESP32BT"); //Bluetooth device name Serial.println("The device started, now you can pair it with bluetooth!"); } String msg = ""; void loop() { /*if (Serial.available()) { SerialBT.write(Serial.read()); }*/ readSerialPort(); //Send data to slave if (msg != "") { Serial.println(msg); SerialBT.println(msg); msg = ""; } if (SerialBT.available()) { Serial.write(SerialBT.read()); } delay(20); } 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 callback a la gestión de Bluetooth para detectar la desconexión y reiniciar el ESP32
SerialBT.register_callback(callback);
Nota: es necesario reiniciar el ESP32 para volver a conectar el Bluetooth porque, obviamente, el BluetoothSocket no se cierra al desconectarse.
Aplicación React Native para la gestión de Bluetooth
Para gestionar la comunicación Bluetooth (clásica) en el dispositivo Android, utilizamos la biblioteca react-native-bluetooth-classic
npm install react-native-bluetooth-classic --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 RNBluetoothClassic, {BluetoothDevice,} from 'react-native-bluetooth-classic';
Estamos creando un componente funcional que contendrá los elementos que necesitamos para gestionar la comunicación Bluetooth
const BluetoothClassicTerminal = () => { const [devices, setDevices] = useState<any[]>([]); const [paired, setPaired] = useState<any[]>([]); const [selectedDevice, setSelectedDevice] = useState<BluetoothDevice>(); const [messageToSend, setMessageToSend] = useState(""); const [receivedMessage, setReceivedMessage] = useState(""); const [isConnected, setIsConnected] = useState(false); const [intervalId, setIntervalId] = useState<NodeJS.Timer>();
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()
async function requestBluetoothPermission(){ try { const grantedScan = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, { title: 'Bluetooth Scan Permission', message: 'This app needs Bluetooth Scan permission to discover devices.', buttonPositive: 'OK', buttonNegative: 'Cancel', } ); const grantedConnect = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, { title: 'Bluetooth Connect Permission', message: 'This app needs Bluetooth Connect permission to connect to devices.', buttonPositive: 'OK', buttonNegative: 'Cancel', } ); const grantedLocation = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, { title: 'Fine Location Permission', message: 'This app needs to know location of device.', buttonPositive: 'OK', buttonNegative: 'Cancel', } ); if ( grantedScan === PermissionsAndroid.RESULTS.GRANTED && grantedConnect === PermissionsAndroid.RESULTS.GRANTED && grantedLocation === PermissionsAndroid.RESULTS.GRANTED ) { console.log('Bluetooth permissions granted'); // Vous pouvez maintenant commencer la découverte et la connexion Bluetooth ici. } else { console.log('Bluetooth permissions denied'); } } catch (err) { console.warn(err); } }
Función de gestión de Bluetooth
Las funciones de gestión de Bluetooth son las siguientes:
- descubrir dispositivos bluetooth startDeviceDiscovery() (utilizo dispositivos emparejados)
- conectar con el dispositivo connectToDevice()
- desconectar disconnect()
- enviar mensajes sendMessage()
- leer mensajes de la comunicación readData()
N.B.: la documentación de la biblioteca menciona el uso de un oyente onDataReceived, que no he conseguido utilizar. Por tanto, he introducido la función readData y un Interval para recuperar los datos.
const checkBluetoothEnabled = async () => { try { const enabled = await RNBluetoothClassic.isBluetoothEnabled(); if (!enabled) { await RNBluetoothClassic.requestBluetoothEnabled(); } } catch (error) { console.error('Bluetooth Classic is not available on this device.'); } } const startDeviceDiscovery = async () => { console.log("searching for devices..."); try { const paired = await RNBluetoothClassic.getBondedDevices(); console.log("Bonded peripherals: " + paired.length); setPaired(paired); } catch (error) { console.error('Error bonded devices:', error); } } const connectToDevice = async (device: BluetoothDevice) => { try { console.log("Connecting to device"); let connection = await device.isConnected(); if (!connection) { console.log("Connecting to device"); await device.connect({ connectorType: "rfcomm", DELIMITER: "\n", DEVICE_CHARSET: Platform.OS === "ios" ? 1536 : "utf-8", }); } setSelectedDevice(device); setIsConnected(true); console.log("is connected : ",isConnected); //device.onDataReceived((data) => this.readData()); //const intervalId = setInterval(() => {readData();}, 100); //setIntervalId(intervalId); } catch (error) { console.error('Error connecting to device:', error); } } const sendMessage = async () => { if(selectedDevice && isConnected){ console.log("isConnected in message",isConnected); try { await selectedDevice.write(messageToSend); } catch (error) { console.error('Error sending message:', error); } } } const readData = async () => { if (selectedDevice && isConnected) { try { let message = await selectedDevice.read(); if(message){ message = message.trim(); if (message !== "" && message !== " "){ if(receivedMessage.length>300){ setReceivedMessage(""); } setReceivedMessage(receivedMessage => receivedMessage + message +"\n" ); } } } catch (error) { console.error('Error reading message:', error); } } } useEffect(() => { let intervalId: string | number | NodeJS.Timer | undefined; if (selectedDevice && isConnected) { intervalId = setInterval(() => readData(), 100); } return () => { clearInterval(intervalId); }; }, [isConnected,selectedDevice]); const disconnect = () => { //need to reset esp32 at disconnect if(selectedDevice && isConnected){ try { clearInterval(intervalId); setIntervalId(undefined); selectedDevice.clear().then( () => { console.log("BT buffer cleared"); }); selectedDevice.disconnect().then( () => { setSelectedDevice(undefined); setIsConnected(false); setReceivedMessage(""); console.log("Disconnected from device"); }); } catch (error) { console.error('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 lista de dispositivos que sólo aparece si no estás conectado (!isConnected &&)
- Una inserción de tipo terminal de comunicación que sólo aparece si está conectado (selectedDevice && isConnected &&)
- TextInput para escribir el mensaje a enviar messageToSend
- Un botón de envío
- 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,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}</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={300} 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
/** * https://kenjdavidson.com/react-native-bluetooth-classic/ */ import React, {useState, useEffect} from 'react'; import { StyleSheet, Dimensions, View, ScrollView, Text, Button, TextInput,PermissionsAndroid,Platform, TouchableOpacity } from 'react-native'; import RNBluetoothClassic, {BluetoothDevice,} from 'react-native-bluetooth-classic'; const BluetoothClassicTerminal = () => { const [devices, setDevices] = useState<any[]>([]); const [paired, setPaired] = useState<any[]>([]); const [selectedDevice, setSelectedDevice] = useState<BluetoothDevice>(); const [messageToSend, setMessageToSend] = useState(""); const [receivedMessage, setReceivedMessage] = useState(""); const [isConnected, setIsConnected] = useState(false); const [intervalId, setIntervalId] = useState<NodeJS.Timer>(); /*const [state, setState] = useState({ devices: [], paired: [], selectedDevice: null, messageToSend: "", receivedMessage: "", isConnected: false, intervalId: null, })*/ const checkBluetoothEnabled = async () => { try { const enabled = await RNBluetoothClassic.isBluetoothEnabled(); if (!enabled) { await RNBluetoothClassic.requestBluetoothEnabled(); } } catch (error) { console.error('Bluetooth Classic is not available on this device.'); } } const startDeviceDiscovery = async () => { console.log("searching for devices..."); try { const paired = await RNBluetoothClassic.getBondedDevices(); console.log("Bonded peripherals: " + paired.length); setPaired(paired); } catch (error) { console.error('Error bonded devices:', error); } /*try { const devices = await RNBluetoothClassic.startDiscovery(); this.setState({ devices }); console.log("Discovered peripherals: " + devices.length); } catch (error) { console.error('Error discovering devices:', error); }*/ } const connectToDevice = async (device: BluetoothDevice) => { try { console.log("Connecting to device"); let connection = await device.isConnected(); if (!connection) { console.log("Connecting to device"); await device.connect({ connectorType: "rfcomm", DELIMITER: "\n", DEVICE_CHARSET: Platform.OS === "ios" ? 1536 : "utf-8", }); } setSelectedDevice(device); setIsConnected(true); console.log("is connected : ",isConnected); //device.onDataReceived((data) => this.readData()); //const intervalId = setInterval(() => {readData();}, 100); //setIntervalId(intervalId); } catch (error) { console.error('Error connecting to device:', error); } } /*async onReceivedData() { const { selectedDevice, receivedMessage } = this.state; //console.log("event : recived message", event); try{ //const message = await selectedDevice.read(); console.log("reieved msg from", selectedDevice.name); const messages = await selectedDevice.available(); if (messages.length > 0) { console.log("msg waiting : ", messages.length); } //this.setState({ receivedMessage: message.data }); } catch (error) { console.error('Error receiving data:', error); } }*/ const sendMessage = async () => { if(selectedDevice && isConnected){ console.log("isConnected in message",isConnected); try { await selectedDevice.write(messageToSend); } catch (error) { console.error('Error sending message:', error); } } } /*const readData = async () => { console.log("reading data connected", isConnected); if(selectedDevice && isConnected){ try { console.log("reading data from", selectedDevice.name); //const available = await selectedDevice.available(); //if (available>1){ let message = await selectedDevice.read(); if(message){ message = message.trim(); if (message !== "" && message !== " "){ console.log("reading data from", selectedDevice.name); //console.log(" available : ", available); //console.log("available", selectedDevice.available()); //console.log("read", selectedDevice.read()); setReceivedMessage(receivedMessage + message +"\n" ); console.log('message', message); console.log('message', receivedMessage); } } // } } catch (error) { //console.log("isConnected",isConnected); //console.log("selectedDevice",selectedDevice); console.error('Error reading message:', error); } } }*/ const readData = async () => { if (selectedDevice && isConnected) { try { //const available = await selectedDevice.available(); //if (available>1){ let message = await selectedDevice.read(); if(message){ message = message.trim(); if (message !== "" && message !== " "){ if(receivedMessage.length>300){ setReceivedMessage(""); } setReceivedMessage(receivedMessage => receivedMessage + message +"\n" ); } } // } } catch (error) { //console.log("isConnected",isConnected); //console.log("selectedDevice",selectedDevice); console.error('Error reading message:', error); } } } useEffect(() => { let intervalId: string | number | NodeJS.Timer | undefined; if (selectedDevice && isConnected) { intervalId = setInterval(() => readData(), 100); } return () => { clearInterval(intervalId); }; }, [isConnected,selectedDevice]); const disconnect = () => { //need to reset esp32 at disconnect if(selectedDevice && isConnected){ try { clearInterval(intervalId); setIntervalId(undefined); selectedDevice.clear().then( () => { console.log("BT buffer cleared"); }); selectedDevice.disconnect().then( () => { setSelectedDevice(undefined); setIsConnected(false); setReceivedMessage(""); console.log("Disconnected from device"); }); /*RNBluetoothClassic.unpairDevice(uuid).then( () => { console.log("Unpaired from device"); }); RNBluetoothClassic.pairDevice(uuid).then( () => { console.log("paired from device"); });*/ } catch (error) { console.error('Error disconnecting:', error); } } } useEffect(() => { async function requestBluetoothPermission(){ try { const grantedScan = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, { title: 'Bluetooth Scan Permission', message: 'This app needs Bluetooth Scan permission to discover devices.', buttonPositive: 'OK', buttonNegative: 'Cancel', } ); const grantedConnect = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, { title: 'Bluetooth Connect Permission', message: 'This app needs Bluetooth Connect permission to connect to devices.', buttonPositive: 'OK', buttonNegative: 'Cancel', } ); const grantedLocation = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, { title: 'Fine Location Permission', message: 'This app needs to know location of device.', buttonPositive: 'OK', buttonNegative: 'Cancel', } ); if ( grantedScan === PermissionsAndroid.RESULTS.GRANTED && grantedConnect === PermissionsAndroid.RESULTS.GRANTED && grantedLocation === PermissionsAndroid.RESULTS.GRANTED ) { console.log('Bluetooth permissions granted'); // Vous pouvez maintenant commencer la découverte et la connexion Bluetooth ici. } else { console.log('Bluetooth permissions denied'); } } catch (err) { console.warn(err); } } checkBluetoothEnabled(); requestBluetoothPermission().then( () => { startDeviceDiscovery(); }); }, []) return ( <View> <Text style={{ fontSize: 30, textAlign: 'center', borderBottomWidth: 1, }}> AC Bluetooth 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: BluetoothDevice,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}</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={300} style={{ backgroundColor: '#333333', margin: 10, borderRadius: 2, borderWidth: 1, borderColor: '#EEEEEE', textAlignVertical: 'top', }} > {receivedMessage} </TextInput> </> )} </ScrollView> </View> ); };//end of component const windowHeight = Dimensions.get('window').height; const styles = StyleSheet.create({ mainBody: { flex: 1, justifyContent: 'center', height: windowHeight, }, 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 BluetoothClassicTerminal;