Ok bare with me because this will be a long read. I have an app that I am trying to integrate BLE into instead of classical Bluetooth. The app was made by freelancers but I am tasked to alter it. It uses a MVVM architecture.
originally this app used classical Bluetooth. There is a Bluetooth Controller class, and it gets instantiated in a DataStore class. The DataStore has functions that use the instance made of the BluetoothController to call on functions in that class that request codes that correspond to the desired parameter. Meaning, There is an enum class that has the names of every parameter to be read or written and represents each one with a code.
for example:
`enum class ReadRequestCodes(val value:String) {
KEY_ADDRESS ("08 00 00 00 20 30 05 11 00 00 00 00 00"),
TOOL_ADDRESS ("08 00 00 00 20 30 05 27 00 00 00 00 00"),
RPM_THRESHOLD("08 00 00 00 20 30 05 13 00 00 00 00 00"),
BACKLASH ("08 00 00 00 20 30 05 22 00 00 00 00 00"),
POWER_SRC_TYPE ("08 00 00 00 20 30 05 26 00 00 00 00 00"),
BATTERY1_PERCENTAGE("08 00 00 00 20 30 11 00 00 00 00 00 00"),
BATTERY2_PERCENTAGE("08 00 00 00 20 30 12 00 00 00 00 00 00"),
HOME_POSITION ("08 00 00 00 20 30 05 15 00 00 00 00 00"),
BYPASS_POSITION ("08 00 00 00 20 30 05 17 00 00 00 00 00"),
HC_POSITION ("08 00 00 00 20 30 05 19 00 00 00 00 00"),
ISOLATION_POSITION("08 00 00 00 20 30 05 1B 00 00 00 00 00"),
PRESSURE_SENSOR_GAIN("08 00 00 00 20 30 05 2B 00 00 00 00 00"),
PRESSURE_SENSOR_OFFSET("08 00 00 00 20 30 05 2C 00 00 00 00 00"),
PRESSURE_SENSOR_RANGE("08 00 00 00 20 30 05 2A 00 00 00 00 00"),
PRESSURE_SENSOR_EXCITATION("08 00 00 00 20 30 05 29 00 00 00 00 00"),
PRESSURE_SENSOR_SERIAL("08 00 00 00 20 30 05 2D 00 00 00 00 00"),
GOTO_CURRENT_LIMIT("08 00 00 00 20 30 05 50 00 00 00 00 00"),
GOTO_RPM_LIMIT("08 00 00 00 20 30 05 52 00 00 00 00 00"),
FW_HW_VERSION("08 00 00 00 20 30 09 00 00 00 00 00 00"),
READ_EEPROM_MEMORY("08 00 00 00 20 30 0A 00 00 00 00 00 00")
}
`
This is in the Bluetooth Controller class. The DataStore has these functions:
fun requestKeyAddress(){
bluetoothController.requestReadValues(ReadRequestCodes.KEY_ADDRESS.value)
}
fun requestToolAddress(){
bluetoothController.requestReadValues(ReadRequestCodes.TOOL_ADDRESS.value)
}
fun requestRpmThreshold(){
bluetoothController.requestReadValues(ReadRequestCodes.RPM_THRESHOLD.value)
}
fun requestBacklash(){
bluetoothController.requestReadValues(ReadRequestCodes.BACKLASH.value)
}
fun requestPowerSrc(){
bluetoothController.requestReadValues(ReadRequestCodes.POWER_SRC_TYPE.value)
}
fun requestBattery1Percentage(){
bluetoothController.requestReadValues(ReadRequestCodes.BATTERY1_PERCENTAGE.value)
}
fun requestBattery2Percentage(){
bluetoothController.requestReadValues(ReadRequestCodes.BATTERY2_PERCENTAGE.value)
}
fun requestHomePos(){
bluetoothController.requestReadValues(ReadRequestCodes.HOME_POSITION.value)
}
fun requestBypassPos(){
bluetoothController.requestReadValues(ReadRequestCodes.BYPASS_POSITION.value)
}
fun requestIsolationPos(){
bluetoothController.requestReadValues(ReadRequestCodes.ISOLATION_POSITION.value)
}
fun requestHcPos(){
bluetoothController.requestReadValues(ReadRequestCodes.HC_POSITION.value)
}
Each fragment or activity in the app has a viewmodel, viewmodelfactory, and a repository. Let me describe the flow of things:
- We connect to a device via Bluetooth
- BluetoothController handles data exchange between the app and the device it is connected to
- Data is store din the DataStore
- UI(Fragment or activity) writes or request to read certain data
- ViewModel of every UI requests the data through the corresponding repository
- Repository of a particular fragment gets data from the DataStore
- And so on depending on whether we want to request data from the device or write to it
ALL of this is done via classical Bluetooth. Now I want to change that to BLE. I was following a guide and made small app(Let's call it prototype) that scans for BLE devices, connects to the selected device and displays its characteristics and their properties. I made an activity in the app at hand (the main app I want to change) that contains the part for scanning and connecting. From what I have seen there is usually a Connection Manager Class that manages the part related to connections and data transfer. I'm not sure about that one beyond some of these functions and values:
private val operationQueue = ConcurrentLinkedQueue<BleOperationType>()
private var pendingOperation : BleOperationType? = null //Operations types found in the BleOperationType sealed class
private var listeners: MutableSet<WeakReference<ConnectionEventListener>> = mutableSetOf()
fun servicesOnDevice(device: BluetoothDevice): List<BluetoothGattService>? = deviceGattMap[device]?.services
fun listenToBondStateChanges(context: Context) {
context.applicationContext.registerReceiver(
broadcastReceiver,
IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
)
}
fun registerListener(listener: ConnectionEventListener){
if(listeners.map { it.get() }.contains(listener)){ return }
listeners.add(WeakReference(listener))
listeners = listeners.filter { it.get() != null }.toMutableSet()
Log.d("RegisteredListener","Added listener $listener, ${listeners.size} listeners total")
}
fun unregisterListener(listener: ConnectionEventListener){
// Removing elements while in a loop results in a java.util.ConcurrentModificationException
var toRemove : WeakReference<ConnectionEventListener>? = null
listeners.forEach{
if (it.get() == listener){
toRemove = it
}
}
toRemove?.let {
listeners.remove(it)
Log.d("UnregisteredListener","Removed listener ${it.get()}, ${listeners.size} listeners total")
}
}
fun connect(device: BluetoothDevice, context: Context){
if(device.isConnected()){
Log.e("CheckConnection","${device.name} - ${device.address} is already connected")
}
else{
enqueueOperation(Connect(device, context.applicationContext))
}
}
fun terminateConnection(device: BluetoothDevice){
if(device.isConnected())
enqueueOperation(Disconnect(device))
else{
Log.e("CheckConnection","Not connected to ${device.name} - ${device.address}, cannot teardown connection")
}
}
fun characteristicWrite(device: BluetoothDevice, characteristic: BluetoothGattCharacteristic, payload: ByteArray){
val writeType = when {
//This is is to make sure that a characteristic can be written to,
// and whether it has a response or not
characteristic.isWritable() -> BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
characteristic.isWritableWithoutResponse() -> {BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE}
else -> error("Characteristic ${characteristic.uuid} cannot be written to")
}
if(device.isConnected()){
enqueueOperation(CharacteristicWrite(device, characteristic.uuid, writeType, payload))
}else{
Log.e("Check_Writable","Not connected to ${device.address}, cannot perform characteristic write")
return
}
// bluetoothGattRef?.let { gatt ->
// characteristic.writeType = writeType
// characteristic.value = payload
// gatt.writeCharacteristic(characteristic)
// } ?: error("Not connected to a BLE device!")
}
fun characteristicRead(device: BluetoothDevice, characteristic: BluetoothGattCharacteristic){
if(device.isConnected() && characteristic.isReadable()){
enqueueOperation(CharacteristicRead(device, characteristic.uuid))
}else if(!characteristic.isReadable()){
Log.e("Check_Readable", "Attempting to read ${characteristic.uuid} is not readable")
}else if(!device.isConnected()){
Log.e("Check_Connected","Not connected to ${device.address}, cannot perform characteristic read")
}
}
fun writeDescriptor(device: BluetoothDevice, descriptor:BluetoothGattDescriptor, payload: ByteArray){
if(device.isConnected() && (descriptor.isWritable() || descriptor.isCccd())){
enqueueOperation(DescriptorWrite(device, descriptor.uuid, payload))
} else if (!device.isConnected()){
Log.e("Check_Descrip_Connected","Not connected to ${device.address}, cannot perform descriptor write")
} else if (!descriptor.isWritable() && !descriptor.isCccd()){
Log.e("Check_Descrip_Writable","Descriptor ${descriptor.uuid} cannot be written to")
}
// bluetoothGattRef?.let { gatt ->
// descriptor.value = payload
// gatt.writeDescriptor(descriptor)
// } ?: error("Not connected to a BLE device!")
}
fun readDescriptor(device: BluetoothDevice, descriptor: BluetoothGattDescriptor){
if (device.isConnected() && descriptor.isReadable()) {
enqueueOperation(DescriptorRead(device,descriptor.uuid))
} else if (!descriptor.isReadable()) {
Log.e("Check_Descrip_Readable","Attempting to read ${descriptor.uuid} that isn't readable!")
} else if (!device.isConnected()) {
Log.e("Check_Descrip_Connected","Not connected to ${device.address}, cannot perform descriptor read")
}
}
fun enableNotifications(device: BluetoothDevice, characteristic: BluetoothGattCharacteristic) {
if (device.isConnected() &&
(characteristic.isIndicatable() || characteristic.isNotifiable())
) {
enqueueOperation(EnableNotifications(device, characteristic.uuid))
} else if (!device.isConnected()) {
Log.e("Error","Not connected to ${device.address}, cannot enable notifications")
} else if (!characteristic.isIndicatable() && !characteristic.isNotifiable()) {
Log.e("Error","Characteristic ${characteristic.uuid} doesn't support notifications/indications")
}
}
fun disableNotifications(device: BluetoothDevice, characteristic: BluetoothGattCharacteristic) {
if (device.isConnected() &&
(characteristic.isIndicatable() || characteristic.isNotifiable())
) {
enqueueOperation(DisableNotifications(device, characteristic.uuid))
} else if (!device.isConnected()) {
Log.e("Error","Not connected to ${device.address}, cannot disable notifications")
} else if (!characteristic.isIndicatable() && !characteristic.isNotifiable()) {
Log.e("Error","Characteristic ${characteristic.uuid} doesn't support notifications/indications")
}
}
It's actually an Object since I'll only instantiate the ConnectionManager once. Now all this was in my prototype app just for building the skeleton for the BLE features that would be integrated into my Main app.
This is what I have added to my Main app so far regarding BLE integration:
class ScanForDevices : AppCompatActivity() {
private val bluetoothAdapter: BluetoothAdapter by lazy {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
bluetoothManager.adapter
}
private val bleScanner by lazy {
bluetoothAdapter.bluetoothLeScanner
}
private val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
//Check whether the app is scanning or not
private var isScanning = false
set(value) {
field = value
runOnUiThread {
if(value) binding.scanBtn.text = "Stop Scanning" else binding.scanBtn.text = "Start Scanning"
}
}
private val scanResults = mutableListOf<ScanResult>()
private val scanResultAdapter: ScanResultAdapter by lazy {
ScanResultAdapter(scanResults) { result ->
// User tapped on a scan result
if(isScanning){
stopBleScan()
}
with(result.device){
Log.w("ScanResultAdapter", "Connecting to $address")
Toast.makeText(this@ScanForDevices,"Connecting to $address",Toast.LENGTH_SHORT).show()
connectGatt(this@ScanForDevices, false, gattCallback)
//////////////////////////////////////////////////////////////////////
// ConnectionManager.connect(this, this@ScanForDevices )
}
}
}
//To check that the permission is granted
//It is only needed when performing a BLE scan
private val isLocationPermissionGranted
get() = hasPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
private lateinit var bluetoothGattRef : BluetoothGatt
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityScanForDevicesBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
//Check for Bluetooth availability
if(bluetoothAdapter == null){
Log.w("BluetoothSupport","Device does not support Bluetooth")
}else{
Log.i("BluetoothSupport","Device supports Bluetooth")
}
binding.scanBtn.setOnClickListener {
if(isScanning) stopBleScan() else startBleScan()
}
initRecyclerView()
}
override fun onResume() {
super.onResume()
if(!bluetoothAdapter.isEnabled){
promptEnableBluetooth()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
ENABLE_BLUETOOTH_REQUEST_CODE -> {
if (resultCode != Activity.RESULT_OK) {
promptEnableBluetooth()
}
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
LOCATION_PERMISSION_REQUEST_CODE -> {
if (grantResults.firstOrNull() == PackageManager.PERMISSION_DENIED) {
requestLocationPermission()
} else {
startBleScan()
}
}
}
}
private fun promptEnableBluetooth() {
if (!bluetoothAdapter.isEnabled) {
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivityForResult(enableBtIntent, ENABLE_BLUETOOTH_REQUEST_CODE)
}
}
//Perform BLE scan after receiving permission to do so
private fun startBleScan() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !isLocationPermissionGranted) {
requestLocationPermission()
} else { /* TODO: Actually perform scan */
scanResults.clear()
scanResultAdapter.notifyDataSetChanged()
bleScanner.startScan(null, scanSettings, scanCallback)
isScanning = true //Check that the app is scanning
}
}
//Stop BLE scan after the corresponding button is clicked
private fun stopBleScan(){
bleScanner.stopScan(scanCallback)
isScanning = false
}
//request location permission
private fun requestLocationPermission() {
if (isLocationPermissionGranted) {
return
}
runOnUiThread {
//Using "org.jetbrains.anko:anko:0.10.8"
alert {
title = "Location permission required"
message = "Starting from Android M (6.0), the system requires apps to be granted " +
"location access in order to scan for BLE devices."
isCancelable = false
positiveButton(android.R.string.ok) {
requestPermission(
android.Manifest.permission.ACCESS_FINE_LOCATION,
LOCATION_PERMISSION_REQUEST_CODE
)
}
}.show()
}
}
//For scan results
private fun initRecyclerView(){
binding.scanResultsRecyclerView.apply {
adapter = scanResultAdapter
// val topSpacingDecoration = TopSpacingItemDecoration(30)
// addItemDecoration(topSpacingDecoration)
layoutManager = LinearLayoutManager(
this@ScanForDevices,
RecyclerView.VERTICAL,
false
)
isNestedScrollingEnabled = false
}
val animator = binding.scanResultsRecyclerView.itemAnimator
if(animator is SimpleItemAnimator) {
animator.supportsChangeAnimations = false
}
}
/**********************/
/**CALLBACK FUNCTIONS**/
/*********************/
/** Since we didn't explicitly specify CALLBACK_TYPE_FIRST_MATCH as the callback type under our ScanSettings,
* our onScanResult callback is flooded by ScanResults belonging to the same set of devices,
* but with updated signal strength (RSSI) readings. In order to keep the UI up to date with the latest RSSI readings,
* we first check to see if our scanResults List already has a scan result whose MAC address is identical to the new incoming ScanResult.
* If so, we replace the older entry with the newer one. In the event that this is a new scan result,
* we’ll add it to our scanResults List. For both cases,
* we’ll inform our scanResultAdapter of the updated item so that our RecyclerView can be updated accordingly.**/
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val indexQuery = scanResults.indexOfFirst { it.device.address == result.device.address }
if (indexQuery != -1) { // A scan result already exists with the same address
scanResults[indexQuery] = result
// serviceUUIDsList = getServiceUUIDsList(result)!!
// Log.d("Service",""+ serviceUUIDsList+"\n")
Log.d("DeviceName",""+result.device.name)
scanResultAdapter.notifyItemChanged(indexQuery)
} else {
with(result.device) {
Log.i("ScanCallback", "Found BLE device! Name: ${name ?: "Unnamed"}, address: $address")
}
scanResults.add(result)
scanResultAdapter.notifyItemInserted(scanResults.size - 1)
}
}
override fun onScanFailed(errorCode: Int) {
Log.e("ScanCallback", "onScanFailed: code $errorCode")
}
}
/**we want to stop the BLE scan if it’s ongoing,
* and we call connectGatt() on the relevant ScanResult’s BluetoothDevice handle,
* passing in a BluetoothGattCallback object that is defined as: **/
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
val deviceAddress = gatt.device.address
if (status == BluetoothGatt.GATT_SUCCESS) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
Log.w("BluetoothGattCallback", "Successfully connected to $deviceAddress")
Handler(Looper.getMainLooper()).post {
Toast.makeText(this@ScanForDevices, "Successfully connected to $deviceAddress", Toast.LENGTH_SHORT).show()
}
// TODO: Store a reference to BluetoothGatt
bluetoothGattRef = gatt
Log.d("ConnectedDevice","")
runOnUiThread {
bluetoothGattRef?.discoverServices()
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.w("BluetoothGattCallback", "Successfully disconnected from $deviceAddress")
gatt.close()
}
} else {
Log.w("BluetoothGattCallback", "Error $status encountered for $deviceAddress! Disconnecting...")
gatt.close()
}
}
//On the discovery of the BLE device's services
//We display a log message of the number of services and the device's address, then print
//the GATT table in a recycler view beneath the one of the scanned devices -----> services and characteristics available on a BLE device.
override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
with(bluetoothGattRef) {
if (status == BluetoothGatt.GATT_SUCCESS) {
Log.d(
"BluetoothGattCallback",
"Discovered ${services.size} services for ${device.address}"
)
printGattTable()
}
}
}
}
}
let's say I already connected to the desired device, how do I exchange data of the parameters in the fragments I mentioned whether it is something to be read from the device or written to the device but with BLE instead of classical Bluetooth?
I am completely lost and don't know what to do. Any insight? Or even list me the things I should do or the path to be taken to make the necessary changes?