Par Valentin Brosseau / @c4software
Avant de rentrer dans le détail, que fait notre objet ?

en BLE :
En HTTP :
En Physique :
L'émulateur de la lampe connectée : un PC Linux qui se fait passer pour la carte.



Avec l'application « nRF Connect »
Deux TP : sans librairie puis avec la librairie Nordic.
BluetoothGattCallback), comme des gestionnaires d'événements.Pour comprendre.
Pour produire.
Nordic (le fabricant des puces BLE) en propose deux :
Flow, version 2 en bêta. Celle du TP.Attention aux exemples en ligne : la Kotlin BLE Library v1 a une API totalement différente de la v2.
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" />
<!-- Anciennes permissions BLE (jusqu'à Android 11 inclus) -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<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
// À partir d'Android 12, on demande les permissions BLUETOOTH_CONNECT et BLUETOOTH_SCAN (qui sont moins agressives 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êts à 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 (il vaut null si le Bluetooth est désactivé)
val bluetoothLeScanner = (context.getSystemService(BLUETOOTH_SERVICE) as BluetoothManager).adapter.bluetoothLeScanner ?: return
// 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, s'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
bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback)
try {
// On attend la durée du scan (10 secondes)
delay(scanDuration)
} finally {
// Dans tous les cas (fin du délai ou appel à stopScan), on stoppe 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 s'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"
}
},
// La lampe s'est déconnectée (BluetoothGatt.STATE_DISCONNECTED)
onDisconnect = {
isConnectedToDeviceFlow.value = false
},
// Un descripteur vient d'être écrit : on peut activer la notification suivante
onDescriptorWritten = {
enableNextNotification()
}
))
}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 par la lampe (émulateur ou firmware 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éfinition 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,
val onDescriptorWritten: () -> Unit = {}
) : BluetoothGattCallback() {
/**
* Méthode appelée au moment où les « services » ont été découverts
*/
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
super.onServicesDiscovered(gatt, status)
if (status == BluetoothGatt.GATT_SUCCESS) {
onConnect()
} else {
onDisconnect()
}
}
/**
* Méthode appelée 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ée 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ée 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")
}
// L'écriture est terminée (réussie ou non) : l'opération suivante peut partir
onDescriptorWritten()
}
}
}
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 la lampe)
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
}// Les caractéristiques dont il reste à activer la notification
private val notificationsToEnable = ArrayDeque<BluetoothGattCharacteristic>()
@SuppressLint("MissingPermission")
private fun enableNotify() {
getMainService()?.let { service ->
// Les caractéristiques que nous voulons écouter
notificationsToEnable.clear()
listOf(
BluetoothLEManager.CHARACTERISTIC_NOTIFY_STATE,
BluetoothLEManager.CHARACTERISTIC_GET_COUNT,
BluetoothLEManager.CHARACTERISTIC_GET_SET_WIFI,
).mapNotNull { uuid -> service.getCharacteristic(uuid) }
.forEach { characteristic -> notificationsToEnable.addLast(characteristic) }
// Android n'accepte qu'une opération GATT à la fois :
// on active la première, les suivantes partiront depuis onDescriptorWrite
enableNextNotification()
}
}Android refuse une nouvelle écriture tant que la précédente n'est pas terminée.
Écrire trois descripteurs dans une boucle ? Seule la première notification est activée, sans erreur visible.
La solution : une file, vidée au rythme de onDescriptorWrite.
@SuppressLint("MissingPermission")
private fun enableNextNotification() {
// Plus rien dans la file : toutes les notifications sont actives
val characteristic = notificationsToEnable.removeFirstOrNull() ?: return
val gatt = currentBluetoothGatt ?: return
// Indique que le GATT Client va écouter les notifications sur la caractéristique
gatt.setCharacteristicNotification(characteristic, true)
// Puis on prévient la lampe en écrivant dans le descripteur 0x2902 de la caractéristique
val descriptor = characteristic.getDescriptor(BluetoothLEManager.CHARACTERISTIC_UPDATE_NOTIFICATION_DESCRIPTOR_UUID)
if (descriptor == null) {
// Pas de descripteur : on passe directement à la suivante
enableNextNotification()
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
} else {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
}
}L'objet se pilote aussi en HTTP. La suite se trouve dans les autres supports :
La même application avec la Kotlin BLE Library de Nordic.
Mêmes UUID, mêmes caractéristiques, même lampe. Des coroutines et des Flow à la place des callbacks.
centralManager
.scan(10.seconds)
.distinctByPeripheral() // un seul résultat par appareil
.onEach { resultat -> /* ajouter resultat.peripheral à la liste */ }
.launchIn(viewModelScope)Plus de ScanCallback, plus de stopScan() à ne pas oublier.
centralManager.connect(peripheral, CentralManager.ConnectionOptions.Direct(timeout = 10.seconds, retry = 2))
val services = peripheral.services(listOf(LampeGatt.SERVICE))
.filterIsInstance<RemoteServices.Discovered>()
.first()
.servicesPlus de GattCallback : chaque ligne attend la précédente.
// Écrit le descripteur 0x2902 pour nous, et gère la file
ledState.subscribe()
.onEach { octets -> ledAllumee.value = octets.decodeToString() == "1" }
.launchIn(this)
// suspend : sans exception, la lampe a reçu la valeur
toggleLed.write("1".encodeToByteArray())Android Compose et le BLE avec la librairie Nordic
Bonus : un mode simulé pour travailler sur l'émulateur Android, sans téléphone.
Flow).Place au TP 🚀