Vamos ver como criar uma aplicação React Native que permite a comunicação Bluetooth entre um dispositivo Android e um ESP32. Usamos o React Native para desenvolver um terminal Bluetooth no Android que se comunica com um ESP32 NodeMCU. O NodeMCU é utilizado para testar a nossa aplicação com um objeto ligado, mas a aplicação pode funcionar com qualquer dispositivo Bluetooth.
Hardware
- Um computador com o React Native e o Node.js instalados
- Um dispositivo Android com Bluetooth
- Um cabo USB para ligar o computador ao dispositivo
- Um dispositivo Bluetooth (ESP32)
Código de gestão Bluetooth para ESP32
Para testar a aplicação React Native, vamos utilizar o código de gestão Bluetooth para o 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 }
Adicionamos a função de retorno de chamada à gestão Bluetooth para detetar a desconexão e reiniciar o ESP32
SerialBT.register_callback(callback);
N.B.: é necessário reiniciar o ESP32 para voltar a ligar o Bluetooth porque, obviamente, o BluetoothSocket não se fecha quando se desliga.
Aplicação React Native para gestão de Bluetooth
Para gerir a comunicação Bluetooth (clássica) no dispositivo Android, utilizamos a biblioteca react-native-bluetooth-classic
npm install react-native-bluetooth-classic --save
Para configurar o projeto de aplicação, siga o tutorial anterior.
No ficheiro App.tsx, para utilizar a biblioteca, importamo-la utilizando o comando
import RNBluetoothClassic, {BluetoothDevice,} from 'react-native-bluetooth-classic';
Estamos a criar um componente funcional que conterá os elementos de que necessitamos para gerir a comunicação 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>();
Nota: é possível criar um componente derivado de ReactNative.Components
Gestão de autorizações
Para descobrir e estabelecer ligação a dispositivos Bluetooth, são necessárias pelo menos 3 permissões:
- BLUETOOTH_SCAN
- LIGAÇÃO_DE_DISTRIBUIÇÃO
- LOCALIZAÇÃO_DE_ACESSO
N.B.: estas permissões dependem da versão e do sistema operativo utilizado
Eis as etiquetas a adicionar ao ficheiro AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.LIGAÇÃO_DE_DISTRIBUIÇÃO" /> <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.LOCALIZAÇÃO_DE_ACESSO" />
No ficheiro App.tsx, criamos a função 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.LIGAÇÃO_DE_DISTRIBUIÇÃO, { 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.LOCALIZAÇÃO_DE_ACESSO, { 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); } }
Função de gestão Bluetooth
As funções de gestão do Bluetooth são as seguintes
- descobrir dispositivos bluetooth startDeviceDiscovery() (utilizo dispositivos emparelhados)
- ligar ao dispositivo connectToDevice()
- desconectar desconectar()
- enviar mensagens sendMessage()
- ler mensagens da comunicação readData()
N.B.: a documentação da biblioteca menciona a utilização de um ouvinte onDataReceived, que não consegui utilizar. Por isso, introduzi a função readData e um Intervalo para obter os dados.
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); } } }
A função de renderização do ecrã
Para a apresentação, decidimos colocar tudo no mesmo ecrã. Haverá :
- Um título
- A lista de dispositivos que só aparece se não estiver ligado (!isConnected &&)
- Inserção de um tipo de terminal de comunicação que só aparece se o utilizador estiver ligado (selectedDevice && isConnected &&)
- TextInput para escrever a mensagem a enviar messageToSend
- Um botão de envio
- Uma caixa de texto para apresentar receivedMessage
- Um botão para desligar
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 o emparelhamento não é gerido pela aplicação, o ESP32 tem de estar emparelhado antes de utilizar a aplicação. Uma vez carregado o código no ESP32, é possível lançar a aplicação no telemóvel utilizando o comando
npx react-native start
Código completo para a aplicação 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.LIGAÇÃO_DE_DISTRIBUIÇÃO, { 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.LOCALIZAÇÃO_DE_ACESSO, { 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;