Télécommande Bluetooth & Réseau
Objectif
Concevoir une application qui va :
- Avoir 3 activités.
- Scanner les périphériques Bluetooth.
- Connexion à notre « Équipement ».
- Commander la LED / afficher l'état.
Projet final
- Gérer correctement les messages d'erreurs (permissions, liste périphérique vide, etc.).
- Gérer correctement le non-accès au réseau (pour la partie, télécommande via Internet).
- Ranger / organiser « correctement le code source » dans des packages comme vus précédemment.
- Ne scanner que les Raspberry Pi.
- Vider la liste après une connexion (et ne pas avoir de doublons dans la liste).
- Personnaliser l'icône de l'application (mettre le logo de l'école + ampoule).
- L'ensemble des textes doivent-être dans le
strings.xml
. - Empêcher la rotation de l'ensemble des activity (
manifest.xml
). - Empêcher l'accès à la partie HTTP si le téléphone est en mode avion ou n'a pas accès à Internet.
- Bonus : Implémenter la partie notification BLE.
- Bonus : Commander en HTTP un équipement sur lequel nous ne nous sommes jamais connectés (intégrer un scan Bluetooth dans un dialog de type liste, en ne dupliquant pas le code).
Les activités
Le scan
- Création d'un Adapter pour les résultats du scan.
- Création d'un objet représentant « Un Device ».
- Création du layout permettant de l'afficher.
- Ajout des permissions.
- Ajout du code « de scan Bluetooth ».
- Pour chaque périphérique « Ajout d'un device » dans la
Datasource
.
Les permissions
<!-- Nouvelles permissions permettant de scanner en BLE Android après 11 -->
<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" />
Vérifier les permissions
/**
* Gère l'action après la demande de permission.
* 2 cas possibles :
* - Réussite 🎉.
* - Échec (refus utilisateur).
*/
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSION_REQUEST_LOCATION) {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED && locationServiceEnabled()) {
// Permission OK => Lancer SCAN
Snackbar.make(binding.root, "Vous devez lancer le scan ", Snackbar.LENGTH_LONG).setAction("Action", null).show()
} else if (!locationServiceEnabled()) {
// Inviter à activer la localisation
startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
} else {
// Permission KO => Gérer le cas.
// Vous devez ici modifier le code pour gérer le cas d'erreur (permission refusé)
// Avec par exemple une Dialog
}
}
}
/**
* Permet de vérifier si l'application possede la permission « Localisation ». OBLIGATOIRE pour scanner en BLE
*/
private fun hasPermission(): Boolean {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
} else {
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED
}
}
/**
* Demande de la permission (ou des permissions) à l'utilisateur.
*/
private fun askForPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), PERMISSION_REQUEST_LOCATION)
} else {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN), PERMISSION_REQUEST_LOCATION)
}
}
Vérifier si la localisation est active
private fun locationServiceEnabled(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
// This is new method provided in API 28
val lm = this.getSystemService(Context.LOCATION_SERVICE) as LocationManager
lm.isLocationEnabled
} else {
// This is Deprecated in API 28
val mode = Settings.Secure.getInt(this.contentResolver, Settings.Secure.LOCATION_MODE, Settings.Secure.LOCATION_MODE_OFF)
mode != Settings.Secure.LOCATION_MODE_OFF
}
}
Le code du scan
/**
* Récupération de l'adapter Bluetooth & vérification si celui-ci est actif
*/
private fun setupBLE() {
(getSystemService(BLUETOOTH_SERVICE) as BluetoothManager?)?.let { bluetoothManager ->
bluetoothAdapter = bluetoothManager.adapter
if (bluetoothAdapter != null && !bluetoothManager.adapter.isEnabled) {
startActivityForResult(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), REQUEST_ENABLE_BLE)
} else {
scanLeDevice()
}
}
}
// Le scan va durer 10 secondes seulement, sauf si vous passez une autre valeur comme paramètre.
private fun scanLeDevice(scanPeriod: Long = 10000) {
if (!mScanning) {
bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
// On vide la liste qui contient les devices actuellement trouvés
bleDevicesFoundList.clear()
mScanning = true
// On lance une tache qui durera « scanPeriod » à savoir donc de base
// 10 secondes
handler.postDelayed({
mScanning = false
bluetoothLeScanner?.stopScan(leScanCallback)
Toast.makeText(this, getString(R.string.scan_ended), Toast.LENGTH_SHORT).show()
}, scanPeriod)
// On lance le scan
bluetoothLeScanner?.startScan(scanFilters, scanSettings, leScanCallback)
}
}
Gestions des résultats
// Callback appelé à chaque périphérique trouvé.
private val leScanCallback: ScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
super.onScanResult(callbackType, result)
// C'est ici que nous allons créer notre « Device » et l'ajouter dans le RecyclerView (Datasource)
//val device = Device(result.device.name, result.device.address, result.device)
// if (!bleDevicesFoundList.contains(device)) {
// bleDevicesFoundList.add(device)
// }
}
}
Quelques variables
// REQUEST Code de gestion
companion object {
const val PERMISSION_REQUEST_LOCATION = 9999
const val REQUEST_ENABLE_BLE = 9997
}
// Gestion du Bluetooth
// L'Adapter permettant de se connecter
private var bluetoothAdapter: BluetoothAdapter? = null
// La connexion actuellement établie
private var currentBluetoothGatt: BluetoothGatt? = null
// « Interface système nous permettant de scanner »
private var bluetoothLeScanner: BluetoothLeScanner? = null
/**
* Gestion du SCAN, recherche des device BLE à proximité
*/
// Parametrage du scan BLE
private val scanSettings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
// On ne retourne que les « Devices » proposant le bon UUID
private var scanFilters: List<ScanFilter> = arrayListOf(
// ScanFilter.Builder().setServiceUuid(ParcelUuid(BluetoothLEManager.DEVICE_UUID)).build()
)
// Variable de fonctionnement
private var mScanning = false
private val handler = Handler()
// Adapter
private val bleDevicesFoundList = emptyDataSourceTyped<Device>()
Le RecyclerView
Le Model
import android.bluetooth.BluetoothDevice
// Représente les données
data class Device (
var name: String?,
var mac: String?,
var device: BluetoothDevice
) {
override fun equals(other: Any?): Boolean {
return other is Device && other.mac == this.mac
}
}
Le Viewholder
class DeviceViewHolder(itemView: View) : ViewHolder(itemView) {
val name: TextView = itemView.findViewById(R.id.title)
}
La Définition
rvDevices.setup {
withDataSource(bleDevicesFoundList)
withItem<Device, DeviceViewHolder>(R.layout.item_device) {
onBind(::DeviceViewHolder) { _, item ->
name.text = item.name.takeIf { !it.isNullOrEmpty() } ?: run { item.mac }
}
onClick {
Toast.makeText(this@ScanActivity, getString(R.string.trying_connection_to, item.name), Toast.LENGTH_SHORT).show()
BluetoothLEManager.currentDevice = item.device
connectToCurrentDevice()
}
}
}
La classe LocalPreferences ?
Cette classe va nous permettre de sauvegarder de manière « persistant » des paramètres.
Pour l'instant nous allons sauvegarder le nom raspberryPi, ça nous sera utile lors de la prochaine étape (connexion HTTP)
import android.content.Context
import android.content.SharedPreferences
class LocalPreferences private constructor(context: Context) {
private val sharedPreferences: SharedPreferences = context.getSharedPreferences("MyPref", Context.MODE_PRIVATE)
fun lastConnectedDeviceName(deviceName: String?) {
sharedPreferences.edit().putString("selectedDevice", deviceName).apply()
}
fun lastConnectedDeviceName(): String? {
return sharedPreferences.getString("selectedDevice", null)
}
companion object {
private var INSTANCE: LocalPreferences? = null
fun getInstance(context: Context): LocalPreferences {
return INSTANCE?.let {
INSTANCE
} ?: run {
INSTANCE = LocalPreferences(context)
return INSTANCE!!
}
}
}
}
Connexion à un périphérique
private fun connectToCurrentDevice() {
BluetoothLEManager.currentDevice?.let { device ->
Toast.makeText(this, "Connexion en cours … $device", Toast.LENGTH_SHORT).show()
currentBluetoothGatt = device.connectGatt(
this,
false,
BluetoothLEManager.GattCallback(
onConnect = {
// On indique à l'utilisateur que nous sommes correctement connecté
runOnUiThread {
// Nous sommes connecté au device, on active les notifications pour être notifié si la LED change d'état.
// À IMPLÉMENTER
// Vous devez appeler la méthode qui active les notifications BLE
// enableListenBleNotify()
// On change la vue « pour être en mode connecté »
setUiMode(true)
// On sauvegarde dans les « LocalPréférence » de l'application le nom du dernier préphérique
// sur lequel nous nous sommes connecté
// À IMPLÉMENTER EN FONCTION DE CE QUE NOUS AVONS DIT ENSEMBLE
}
},
onNotify = { runOnUiThread {
// VOUS DEVEZ APPELER ICI LA MÉTHODE QUI VA GÉRER LE CHANGEMENT D'ÉTAT DE LA LED DANS L'INTERFACE
// Exemple, handleToggleLedNotificationUpdate(it)
} },
onDisconnect = { runOnUiThread { disconnectFromCurrentDevice() } })
)
}
}
Déconnexion
/**
* On demande la déconnexion du device
*/
private fun disconnectFromCurrentDevice() {
currentBluetoothGatt?.disconnect()
BluetoothLEManager.currentDevice = null
setUiMode(false)
}
BluetothLEManager
class BluetoothLEManager {
companion object {
var currentDevice: BluetoothDevice? = null
val DEVICE_UUID: UUID = UUID.fromString("795090c7-420d-4048-a24e-18e60180e23c")
val CHARACTERISTIC_LED_PIN_UUID: UUID = UUID.fromString("31517c58-66bf-470c-b662-e352a6c80cba")
val CHARACTERISTIC_BUTTON_PIN_UUID: UUID = UUID.fromString("0b89d2d4-0ea6-4141-86bb-0c5fb91ab14a")
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")
}
open class GattCallback(
val onConnect: () -> Unit,
val onNotify: (characteristic: BluetoothGattCharacteristic) -> 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
*/
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éthodes appelée à chaque notifications BLE (en cas de changement de la led)
*/
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
super.onCharacteristicChanged(gatt, characteristic)
if (characteristic.uuid == CHARACTERISTIC_NOTIFY_STATE) {
onNotify(characteristic)
}
}
}
}
setUiMode ?
Cette méthode permet de changer l'état de l'interface en fonction de la connexion.
Je vous laisse écrire la méthode mais petite aide :
private fun setUiMode(isConnected: Boolean) {
if (isConnected) {
// Connecté à un périphérique
bleDevicesFoundList.clear()
rvDevices.visibility = View.GONE
startScan.visibility = View.GONE
currentConnexion.visibility = View.VISIBLE
currentConnexion.text = getString(R.string.connected_to, BluetoothLEManager.currentDevice?.name)
disconnect.visibility = View.VISIBLE
toggleLed.visibility = View.VISIBLE
} else {
// Non connecté, reset de la vue.
rvDevices.visibility = View.VISIBLE
startScan.visibility = View.VISIBLE
ledStatus.visibility = View.GONE
currentConnexion.visibility = View.GONE
disconnect.visibility = View.GONE
toggleLed.visibility = View.GONE
}
}
Interagir avec la LED
/**
* Récupération de « service » BLE (via UUID) qui nous permettra d'envoyer / recevoir des commandes
*/
private fun getMainDeviceService(): BluetoothGattService? {
return currentBluetoothGatt?.let { bleGatt ->
val service = bleGatt.getService(BluetoothLEManager.DEVICE_UUID)
service?.let {
return it
} ?: run {
Toast.makeText(this, getString(R.string.uuid_not_found), Toast.LENGTH_SHORT).show()
return null;
}
} ?: run {
Toast.makeText(this, getString(R.string.not_connected), Toast.LENGTH_SHORT).show()
return null
}
}
/**
* On change l'état de la LED (via l'UUID de toggle)
*/
private fun toggleLed() {
getMainDeviceService()?.let { service ->
val toggleLed = service.getCharacteristic(BluetoothLEManager.CHARACTERISTIC_TOGGLE_LED_UUID)
toggleLed.setValue("1")
currentBluetoothGatt?.writeCharacteristic(toggleLed)
}
}
Gérer la compatibilité du mobile
Dans le code de l'activity BLE, si vous souhaitez gérer l'ensembles des cas d'erreurs :
- Équipement non compatible BLE.
- Vérifications des permissions.
- Vérification du service de localisation.
override fun onResume() {
super.onResume()
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
// Test si le téléphone est compatible BLE, si c'est pas le cas, on finish() l'activity
Toast.makeText(this, getString(R.string.not_compatible), Toast.LENGTH_SHORT).show()
finish()
} else if (hasPermission() && locationServiceEnabled()) {
// Lancer suite => Activation BLE + Lancer Scan
setupBLE()
} else if(!hasPermission()) {
// On demande la permission
askForPermission()
} else {
// On demande d'activer la localisation
// Idéalement on demande avec un activité.
// À vous de me proposer mieux (Une activité, une dialog, etc)
startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS));
}
}
Ajout des actions aux cliques sur l'interface
Nous avons nos méthodes, mais elles ne sont actuellement pas appelées par une action utilisateur. Pour vous guider dans la mission, vous devez dans le OnCreate
ajouter des lignes de code similaire à :
startScan.setOnClickListener { askForPermission() }
disconnect.setOnClickListener {
// Appeler la bonne méthode
}
toggleLed.setOnClickListener {
// Appeler la bonne méthode
}
Télécommande via Internet
Nécessite le « nom » du périphérique (donc d'un scan précédent).
- Modifier la vue de la home pour que nous ne puissions pas cliquer sur le bouton.
- L'activité ne doit pas être accessible. (elle doit
finish()
si pas degetCurrentSelectedDevice() == null
) - Créer un nouveau model
LedStatus
La classe LedStatus
/**
* LedStatus model
*/
data class LedStatus(var identifier: String = "", var status: Boolean = false) {
fun setIdentifier(identifier: String): LedStatus {
this.identifier = identifier
return this
}
fun setStatus(status: Boolean): LedStatus {
this.status = status
return this
}
fun reverseStatus(): LedStatus {
return setStatus(!status)
}
fun clone(): LedStatus {
return LedStatus(identifier, status)
}
}
Nouvelles dépendances Gradle
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.9.3'
implementation 'com.squareup.okhttp3:logging-interceptor:4.9.3'
la classe APIService
/**
* ApiService
*/
interface ApiService {
@GET("/status")
suspend fun readStatus(@Query("identifier") identifier: String): LedStatus
@POST("/status")
suspend fun writeStatus(@Body status: LedStatus): LedStatus
companion object {
/**
* Création d'un singleton pour la simplicité, mais normalement nous utilisons plutôt un
* injecteur de dépendances.
*/
val instance = build()
/**
* Création de l'objet qui nous permettra de faire les appels d'API
*/
private fun build(): ApiService {
val gson = GsonBuilder().create() // JSON deserializer/serializer
// Create the OkHttp Instance
val okHttpClient = OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().setLevel(if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE))
.addInterceptor(Interceptor { chain: Interceptor.Chain ->
val request = chain.request().newBuilder().addHeader("Accept", "application/json").build()
chain.proceed(request)
})
.build()
return Retrofit.Builder()
.baseUrl(BuildConfig.URI_REMOTE_SERVER)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(ApiService::class.java)
}
}
}
Exemple d'utilisation
// Récupération de l'état depuis le serveur
private fun getStatus() {
CoroutineScope(Dispatchers.IO).launch {
runCatching {
val readStatus = ApiService.instance.readStatus(ledStatus.identifier)
ledStatus.setStatus(readStatus.status)
setVisualState()
}
}
}
Notification BLE
La Raspberry Pi dispose également d'un service de « Notification ». Les notifications sont envoyées à chaque changement d'état de la LED (local ou via le réseau). Cette notification est envoyée sur l'UUID d75167c8-e6f9-4f0b-b688-09d96e195f00
.
private fun enableListenBleNotify() {
getMainDeviceService()?.let { service ->
Toast.makeText(this, getString(R.string.enable_ble_notifications), Toast.LENGTH_SHORT).show()
// Indique que le GATT Client va écouter les notifications sur le charactérisque
val notification = service.getCharacteristic(BluetoothLEManager.CHARACTERISTIC_NOTIFY_STATE)
currentBluetoothGatt?.setCharacteristicNotification(notification, true)
}
}
private fun handleToggleLedNotificationUpdate(characteristic: BluetoothGattCharacteristic) {
if (characteristic.getStringValue(0).equals("on", ignoreCase = true)) {
ledStatus.setImageResource(R.drawable.led_on)
} else {
ledStatus.setImageResource(R.drawable.led_off)
}
}