Par Valentin Brosseau / Playmoweb
Avant de rentrer dans le détail, que fait notre objet ?
en BLE :
En HTTP :
En Physique :
Avec l'application « nRF Connect »
BluetoothDevice
(Ça va être dense, mais on va y arriver)
<!-- Permissions pour le BLE Android 12 et plus -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Ancienne permission pour permettre l'usage du BLE Android avant 11 inclus -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
// Partie 1: Demander la permission
// En fonction de la version d'Android, on demande des permissions différentes
// Pour Android 12, on demande les permissions BLUETOOTH_CONNECT et BLUETOOTH_SCAN (qui sont moins agréssives pour l'utilisateur)
// Pour les autres versions, on demande la permission ACCESS_FINE_LOCATION (Souvent non comprise par l'utilisateur)
val toCheckPermissions = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
listOf(android.Manifest.permission.ACCESS_FINE_LOCATION)
} else {
listOf(android.Manifest.permission.BLUETOOTH_CONNECT, android.Manifest.permission.BLUETOOTH_SCAN)
}
// État de la demande de permission (granted, denied, shouldShowRationale)
val permissionState = rememberMultiplePermissionsState(toCheckPermissions)
// Vérifier si la permission est accordée
if (permissionState.allPermissionsGranted) {
// La permission est accordée
// Nous sommes prêt à scanner
} else {
// La permission n'est pas accordée
// Nous devons demander la permission
Button(onClick = { permissionState.launchMultiplePermissionRequest() }) {
Text(text = "Demander la permission")
}
}
@Composable
fun checkBluetoothEnabled(context: Context, notAvailable: () -> Unit = {}) {
val bluetoothManager: BluetoothManager? = remember {
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?
}
val bluetoothAdapter: BluetoothAdapter? = bluetoothManager?.adapter
val enableBluetoothLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
LaunchedEffect(bluetoothAdapter) {
when {
bluetoothAdapter == null -> { notAvailable() }
!bluetoothAdapter.isEnabled -> {
// Demander l'activation du Bluetooth
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
enableBluetoothLauncher.launch(enableBtIntent)
}
}
}
}
// Vérification si le Bluetooth est activé
checkBluetoothEnabled(context) {
// Le Bluetooth n'est pas disponible
Toast.makeText(context, "Le Bluetooth n'est pas disponible", android.widget.Toast.LENGTH_SHORT).show()
navController.popBackStack()
}
À votre avis, que fait ce code ? Où doit-il être placé ?
Réparti entre le ViewModel et la Vue.
// Le processus de scan
private var scanJob: Job? = null
// Durée du scan
private val scanDuration = 10000L
/**
* Le scanner bluetooth
*/
private val scanFilters: List<ScanFilter> = listOf(
// À décommenter pour filtrer les périphériques
// ScanFilter.Builder().setServiceUuid(ParcelUuid(BluetoothLEManager.DEVICE_UUID)).build()
)
private val scanSettings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
private val scanResultsSet = mutableMapOf<String, ScanResult>()
@SuppressLint("MissingPermission")
fun startScan(context: Context) {
// Récupération du scanner BLE
val bluetoothLeScanner = (context.getSystemService(BLUETOOTH_SERVICE) as BluetoothManager).adapter.bluetoothLeScanner
// Si nous sommes déjà en train de scanner, on ne fait rien
if (isScanningFlow.value) return
// Définition du processus de scan (Coroutine)
// Une coroutine est un moyen de gérer des tâches asynchrones de manière plus simple et plus lisible
scanJob = CoroutineScope(Dispatchers.IO).launch {
// On indique que nous sommes en train de scanner
isScanningFlow.value = true
// Objet qui sera appelé à chaque résultat de scan
val scanCallback = object : ScanCallback() {
/**
* Le callback appelé à chaque résultat de scan (nouvel appareil trouvé)
* Il n'est pas dédoublonné, c'est à nous de le faire (il peut être appelé plusieurs fois pour le même appareil)
*/
override fun onScanResult(callbackType: Int, result: ScanResult) {
super.onScanResult(callbackType, result)
// On ajoute le résultat dans le set, si il n'y est pas déjà
// L'ajout retourne null si l'élément n'était pas déjà présent
if (scanResultsSet.put(result.device.address, result) == null) {
// On envoie la nouvelle liste des appareils scannés
scanItemsFlow.value = scanResultsSet.values.toList()
}
}
}
// On lance le scan BLE a la souscription de scanFlow
bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback)
// On attend la durée du scan (10 secondes)
delay(scanDuration)
// Lorsque scanFlow est stoppé, on stop le scan BLE
bluetoothLeScanner.stopScan(scanCallback)
// On indique que nous ne sommes plus en train de scanner
isScanningFlow.value = false
}
}
fun stopScan() {
scanJob?.cancel()
isScanningFlow.value = false
}
startScan
est une méthode asynchrone.Flow
pour envoyer les résultats de scan.// La liste des appareils scannés autour
val scanItemsFlow = MutableStateFlow<List<ScanResult>>(emptyList())
// Boolean permettant de savoir si nous sommes en train de scanner
val isScanningFlow = MutableStateFlow(false)
Rappel : Écouter les variables du ViewModel dans la Vue.
val list by viewModel.scanItemsFlow.collectAsStateWithLifecycle()
val isScanning by viewModel.isScanningFlow.collectAsStateWithLifecycle()
Qu'allons-nous faire ?
// Flow permettant de savoir si nous sommes en train de nous connecter
val isConnectingFlow = MutableStateFlow(false)
// Flow permettant de savoir si un appareil est connecté
val isConnectedToDeviceFlow = MutableStateFlow(false)
@SuppressLint("MissingPermission")
fun connect(context: Context, bluetoothDevice: BluetoothDevice) {
// On arrête le scan si il est en cours
stopScan()
// On indique que nous sommes en train de nous connecter (pour afficher un loader par exemple)
isConnectingFlow.value = true
// On tente de se connecter à l'appareil
// On utilise le GattCallback pour gérer les événements BLE (connexion, déconnexion, notifications).
currentBluetoothGatt = bluetoothDevice.connectGatt(
context,
false,
BluetoothLEManager.GattCallback(
// La connexion a réussi (onServicesDiscovered)
onConnect = {
isConnectedToDeviceFlow.value = true
isConnectingFlow.value = false
// On active les notifications pour recevoir les événements de la LED et du compteur
// enableNotify()
},
// Nouvelle valeur reçue sur une caractéristique de type notification
onNotify = { characteristic, value ->
when (characteristic.uuid) {
BluetoothLEManager.CHARACTERISTIC_NOTIFY_STATE -> connectedDeviceLedStateFlow.value = value == "1"
}
},
// L'ESP32 s'est déconnecté (BluetoothGatt.STATE_DISCONNECTED)
onDisconnect = {
isConnectedToDeviceFlow.value = false
}
))
}
connect
est une méthode asynchrone.GattCallback
pour gérer les événements BLE.Flow
pour envoyer les informations à la Vue.GattCallback
pour gérer les événements BLE.Ce code est à ajouter dans un fichier BluetoothLEManager.kt
dans un package data.ble
class BluetoothLEManager {
companion object {
var currentDevice: BluetoothDevice? = null
/**
* Les UUIDS sont des identifiants uniques qui permettent d'identifier les services et les caractéristiques.
* Ces UUIDs sont définis dans le code de l'ESP32.
*/
val DEVICE_UUID: UUID = UUID.fromString("795090c7-420d-4048-a24e-18e60180e23c")
val CHARACTERISTIC_TOGGLE_LED_UUID: UUID = UUID.fromString("59b6bf7f-44de-4184-81bd-a0e3b30c919b")
val CHARACTERISTIC_NOTIFY_STATE: UUID = UUID.fromString("d75167c8-e6f9-4f0b-b688-09d96e195f00")
val CHARACTERISTIC_GET_COUNT: UUID = UUID.fromString("a877d87f-60bf-4ad5-ba61-56133b2cd9d4")
val CHARACTERISTIC_GET_SET_WIFI: UUID = UUID.fromString("10f83060-64f8-11ee-8c99-0242ac120002")
val CHARACTERISTIC_SET_DEVICE_NAME: UUID = UUID.fromString("1497b8a8-64f8-11ee-8c99-0242ac120002")
val CHARACTERISTIC_UPDATE_NOTIFICATION_DESCRIPTOR_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
}
/**
* Définitionn de la classe GattCallback qui va nous permettre de gérer les différents événements BLE
* Elle implémente la classe BluetoothGattCallback fournie par Android
*/
open class GattCallback(
val onConnect: () -> Unit,
val onNotify: (characteristic: BluetoothGattCharacteristic, value: String) -> Unit,
val onDisconnect: () -> Unit
) : BluetoothGattCallback() {
/**
* Méthode appelé au moment ou les « services » ont été découvert
*/
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
super.onServicesDiscovered(gatt, status)
if (status == BluetoothGatt.GATT_SUCCESS) {
onConnect()
} else {
onDisconnect()
}
}
/**
* Méthode appelé au moment du changement d'état de la stack BLE
*/
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
super.onConnectionStateChange(gatt, status, newState)
when (newState) {
BluetoothGatt.STATE_CONNECTED -> gatt.discoverServices()
BluetoothProfile.STATE_DISCONNECTED -> onDisconnect()
}
}
/**
* Méthode appelé lorsqu'une caractéristique a été modifiée
* Dans les nouvelles versions d'Android, cette méthode est appelée
*/
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
super.onCharacteristicChanged(gatt, characteristic, value)
onNotify(characteristic, value.toString(Charsets.UTF_8))
}
/**
* Méthode appelé lorsqu'une caractéristique a été modifiée
* Ancienne méthode utilisée sur les versions antérieures d'Android
*/
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
super.onCharacteristicChanged(gatt, characteristic)
onNotify(characteristic, characteristic.value.toString(Charsets.UTF_8))
}
override fun onDescriptorWrite(gatt: BluetoothGatt?, descriptor: BluetoothGattDescriptor?, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
Log.d("BLE", "Descriptor write successful for ${descriptor?.characteristic?.uuid}")
} else {
Log.e("BLE", "Descriptor write failed for ${descriptor?.characteristic?.uuid}, status: $status")
}
}
}
}
fun toggleLed() {
writeCharacteristic(BluetoothLEManager.CHARACTERISTIC_TOGGLE_LED_UUID, "1")
}
⚠️ Méthode générique pour écrire dans une caractéristique. Elle s'applique à toutes les caractéristiques du projet.
@SuppressLint("MissingPermission")
private fun writeCharacteristic(uuid: UUID, value: String) {
// Récupération du service principal (celui de l'ESP32)
getMainService()?.let { service ->
// Récupération de la caractéristique
val characteristic = service.getCharacteristic(uuid)
if (characteristic == null) {
Log.e("BluetoothLEManager", "La caractéristique $uuid n'a pas été trouvée")
return
}
Log.i("BluetoothLEManager", "Ecriture de la valeur $value dans la caractéristique $uuid")
// En fonction de la version de l'OS, on utilise la méthode adaptée
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// On écrit la valeur dans la caractéristique
currentBluetoothGatt?.writeCharacteristic(characteristic, value.toByteArray(), BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
} else {
// On écrit la valeur dans la caractéristique
characteristic.setValue(value)
currentBluetoothGatt?.writeCharacteristic(characteristic)
}
}
}
private fun getMainService(): BluetoothGattService? = currentBluetoothGatt?.getService(BluetoothLEManager.DEVICE_UUID)
Flow
.// Flow permettant de savoir si la LED est allumée ou éteinte
val connectedDeviceLedStateFlow = MutableStateFlow(false)
// Sera mis à jour dans la méthode onNotify du GattCallback
// Si la valeur est "1", la LED est allumée, sinon elle est éteinte
// connectedDeviceLedStateFlow.value = value == "1"
val ledState by viewModel.connectedDeviceLedStateFlow.collectAsStateWithLifecycle()
// … ailleurs dans votre code
if(ledState) {
// La LED est allumée
} else {
// La LED est éteinte
}
@SuppressLint("MissingPermission")
private fun enableNotify() {
getMainService()?.let { service ->
// Indique que le GATT Client va écouter les notifications sur le charactérisque
val notificationStatus = service.getCharacteristic(BluetoothLEManager.CHARACTERISTIC_NOTIFY_STATE)
val notificationLedCount = service.getCharacteristic(BluetoothLEManager.CHARACTERISTIC_GET_COUNT)
val wifiScan = service.getCharacteristic(BluetoothLEManager.CHARACTERISTIC_GET_SET_WIFI)
listOf(notificationStatus, notificationLedCount, wifiScan).forEach { characteristic ->
currentBluetoothGatt?.setCharacteristicNotification(characteristic, true)
characteristic.getDescriptor(BluetoothLEManager.CHARACTERISTIC_UPDATE_NOTIFICATION_DESCRIPTOR_UUID)?.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
currentBluetoothGatt?.writeDescriptor(it, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
} else {
it.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
currentBluetoothGatt?.writeDescriptor(it)
}
}
}
}
}
<uses-permission android:name="android.permission.INTERNET"/>
suspend
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.8'
implementation 'com.squareup.okhttp3:okhttp:4.7.2'
implementation 'com.squareup.okhttp3:logging-interceptor:4.7.2'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.7.2'
implementation 'com.squareup.okhttp3:logging-interceptor:4.7.2'
@GET("/status")
suspend fun readStatus(@Query("identifier") identifier: String): LedStatus
@POST("/status")
suspend fun writeStatus(@Body status: LedStatus): LedStatus
ApiService.kt
(l'interface et le builder pour la partie HTTP)LedStatus
LedStatus
Télécharger le fichier LedStatus.java
(À ranger dans le package ….data.modele
.
Télécharge le fichier ApiService.kt
(À ranger dans le package ….data.service
)
BuildConfig.URI_REMOTE_SERVER ?
defaultConfig {
buildConfigField "String", "URI_REMOTE_SERVER", "\"http://IP.DU.ESP\""
…
}
ApiService.Builder.getInstance();
CoroutineScope(Dispatchers.IO).launch {
runCatching {
val readStatus = ApiService.instance.readStatus(ledStatus.identifier)
ledStatus.setStatus(readStatus.status)
setVisualState()
}
}
On en parle non ?
ActionActivity.kt
)activity_action.xml
Ne pas oublier la méthode static !
companion object {
private const val IDENTIFIANT_ID = "IDENTIFIANT_ID"
fun getStartIntent(context: Context, identifiant: String?): Intent {
return Intent(context, RemoteActivity::class.java).apply {
putExtra(IDENTIFIANT_ID, identifiant)
}
}
}
⚠️ Utiliser la méthode depuis la MainActivity.kt
Oui… On passe des paramètres… Parlons-en des paramètres justement…
private fun getIdentifiant(): String? {
return intent.extras?.getString(IDENTIFIANT_ID, null)
}