Nous allons voir comment créer une application React Native permettant la communication Bluetooth entre un appareil Android et un ESP32. Nous utilisons React Native pour développer un terminal Bluetooth sur Android permettant la communication avec un NodeMCU ESP32. Le NodeMCU permet de tester notre application avec un objet connecté mais l’application peut fonctionner avec n’importe quel appareil Bluetooth.
Matériel
- Un ordinateur avec installation de React Native et Node.js
- Un appareil Android avec Bluetooth
- Un câble USB pour relier l’ordinateur à l’appareil
- Un appareil Bluetooth (ESP32)
Code de gestion du Bluetooth pour ESP32
Pour tester l’application React Native, nous allons utiliser le code de gestion du Bluetooth pour 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 }
Nous rajoutons la fonction callback à la gestion du Bluetooth pour détecter la déconnexion et redémarrer l’ESP32
SerialBT.register_callback(callback);
N.B.: le reset de l’ESP32 est nécessaire à la reconnexion du Bluetooth car, visiblement, le BluetoothSocket ne se ferme pas à la deconnexion
Application React Native pour la gestion du Bluetooth
Pour gérer la communciation Bluetooth (classic) sur l’appareil Android, nous utilisons la librairie react-native-bluetooth-classic
npm install react-native-bluetooth-classic --save
Pour mettre en place le projet de l’application, suivez le tutoriel précédent.
Dans le fichier App.tsx, pour utiliser la bibliothèque nous l’importons à l’aide de la commande
import RNBluetoothClassic, {BluetoothDevice,} from 'react-native-bluetooth-classic';
Nous créons un composant fonctionnel qui contiendra les éléments nous permettant de gérer la communication 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.: il est possible de créer un composant qui dérive de ReactNative.Components
Gestion des permissions
Pour pouvoir découvrir et se connecter à des appareils Bluetooth, il faut 3 permissions au minimum:
- BLUETOOTH_SCAN
- BLUETOOTH_CONNECT
- ACCESS_FINE_LOCATION
N.B.: ces permissions dépendent de la version et de l’OS utilisé
Voici les balises à ajouter dans le fichier 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" />
Dans le fichier App.tsx, nous créons la fonction 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); } }
Fonction de gestion du Bluetooth
Les fonctions permettant la gestion du Bluetooth sont les suivantes:
- découvrir des appareils bluetooth startDeviceDiscovery() (j’utilise les appareils apairés)
- se connecter à l’appareil connectToDevice()
- se déconnecter disconnect()
- envoyer des messages sendMessage()
- lire les messages provenant de la communication readData()
N.B.: la documentation de la librairie mentionne l’utilisation d’un listener onDataReceived que je n’ai pas réussi à utiliser. J’introduis donc la fonction readData et un Interval pour récupérer les données.
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 fonction de rendu de l’écran
Pour l’affichage, nous choisissons de tous mettre sur un même écran. Il y aura :
- Un titre
- 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 pour écrire le message à envoyer messageToSend
- Un bouton d’envoi
- Une zone de texte pour afficher receivedMessage
- Un bouton de déconnexion
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> );
Résultat
Comme l’appairage n’est pas gérer par l’application, il faut appairer l’ESP32 avant l’utilisation de l’application. Une fois le code chargé sur l’ESP32, vous pouvez lancer l’application sur le téléphone à l’aide de la commande
npx react-native start
Code complet de l’application 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;