Migrate to ble ktx

This commit is contained in:
Sylwester Zieliński
2022-02-02 10:59:02 +01:00
parent 5b23af133e
commit 9dcd603ff5
34 changed files with 716 additions and 1069 deletions

View File

@@ -7,6 +7,7 @@ dependencies {
implementation project(":lib_utils")
implementation libs.nordic.ble.common
implementation libs.nordic.ble.ktx
implementation libs.nordic.navigation
implementation libs.nordic.log

View File

@@ -21,94 +21,48 @@
*/
package no.nordicsemi.android.bps.repository
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import no.nordicsemi.android.ble.common.callback.bps.BloodPressureMeasurementDataCallback
import no.nordicsemi.android.ble.common.callback.bps.IntermediateCuffPressureDataCallback
import no.nordicsemi.android.ble.common.profile.bp.BloodPressureTypes
import no.nordicsemi.android.ble.data.Data
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.common.callback.bps.BloodPressureMeasurementResponse
import no.nordicsemi.android.ble.common.callback.bps.IntermediateCuffPressureResponse
import no.nordicsemi.android.ble.ktx.asValidResponseFlow
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.bps.data.BPSRepository
import no.nordicsemi.android.log.Logger
import no.nordicsemi.android.service.BatteryManager
import no.nordicsemi.android.service.CloseableCoroutineScope
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
/** Blood Pressure service UUID. */
val BPS_SERVICE_UUID = UUID.fromString("00001810-0000-1000-8000-00805f9b34fb")
/** Blood Pressure Measurement characteristic UUID. */
private val BPM_CHARACTERISTIC_UUID = UUID.fromString("00002A35-0000-1000-8000-00805f9b34fb")
/** Intermediate Cuff Pressure characteristic UUID. */
private val ICP_CHARACTERISTIC_UUID = UUID.fromString("00002A36-0000-1000-8000-00805f9b34fb")
@Singleton
@ViewModelScoped
internal class BPSManager @Inject constructor(
@ApplicationContext context: Context,
private val dataHolder: BPSRepository
) : BatteryManager(context) {
private val scope = CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var bpmCharacteristic: BluetoothGattCharacteristic? = null
private var icpCharacteristic: BluetoothGattCharacteristic? = null
private val intermediateCuffPressureCallback = object : IntermediateCuffPressureDataCallback() {
override fun onIntermediateCuffPressureReceived(
device: BluetoothDevice,
cuffPressure: Float,
unit: Int,
pulseRate: Float?,
userID: Int?,
status: BloodPressureTypes.BPMStatus?,
calendar: Calendar?
) {
dataHolder.setIntermediateCuffPressure(
cuffPressure,
unit,
pulseRate,
userID,
status,
calendar
)
}
override fun onInvalidDataReceived(device: BluetoothDevice, data: Data) {
log(Log.WARN, "Invalid ICP data received: $data")
}
}
private val bloodPressureMeasurementDataCallback = object : BloodPressureMeasurementDataCallback() {
override fun onBloodPressureMeasurementReceived(
device: BluetoothDevice,
systolic: Float,
diastolic: Float,
meanArterialPressure: Float,
unit: Int,
pulseRate: Float?,
userID: Int?,
status: BloodPressureTypes.BPMStatus?,
calendar: Calendar?
) {
dataHolder.setBloodPressureMeasurement(
systolic,
diastolic,
meanArterialPressure,
unit,
pulseRate,
userID,
status,
calendar
)
}
override fun onInvalidDataReceived(device: BluetoothDevice, data: Data) {
log(Log.WARN, "Invalid BPM data received: $data")
}
private val exceptionHandler = CoroutineExceptionHandler { _, t->
Log.e("COROUTINE-EXCEPTION", "Uncaught exception", t)
}
override fun onBatteryLevelChanged(batteryLevel: Int) {
@@ -119,19 +73,40 @@ internal class BPSManager @Inject constructor(
override fun initialize() {
super.initialize()
setNotificationCallback(icpCharacteristic)
.with(intermediateCuffPressureCallback)
setIndicationCallback(bpmCharacteristic)
.with(bloodPressureMeasurementDataCallback)
enableNotifications(icpCharacteristic)
.fail { device, status ->
log(
Log.WARN,
"Intermediate Cuff Pressure characteristic not found"
setNotificationCallback(icpCharacteristic).asValidResponseFlow<IntermediateCuffPressureResponse>()
.onEach {
dataHolder.setIntermediateCuffPressure(
it.cuffPressure,
it.unit,
it.pulseRate,
it.userID,
it.status,
it.timestamp
)
}
.enqueue()
enableIndications(bpmCharacteristic).enqueue()
}.launchIn(scope)
setIndicationCallback(bpmCharacteristic).asValidResponseFlow<BloodPressureMeasurementResponse>()
.onEach {
dataHolder.setBloodPressureMeasurement(
it.systolic,
it.diastolic,
it.meanArterialPressure,
it.unit,
it.pulseRate,
it.userID,
it.status,
it.timestamp
)
}.launchIn(scope)
scope.launch(exceptionHandler) {
enableNotifications(icpCharacteristic).suspend()
}
scope.launch(exceptionHandler) {
enableIndications(bpmCharacteristic).suspend()
}
}
override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
@@ -143,7 +118,7 @@ internal class BPSManager @Inject constructor(
return bpmCharacteristic != null
}
override fun onServicesInvalidated() { }
override fun onServicesInvalidated() {}
override fun isOptionalServiceSupported(gatt: BluetoothGatt): Boolean {
super.isOptionalServiceSupported(gatt) // ignore the result of this
@@ -159,4 +134,8 @@ internal class BPSManager @Inject constructor(
override fun getGattCallback(): BleManagerGattCallback {
return BloodPressureManagerGattCallback()
}
fun release() {
scope.close()
}
}

View File

@@ -100,5 +100,6 @@ internal class BPSViewModel @Inject constructor(
override fun onCleared() {
super.onCleared()
repository.clear()
bpsManager.release()
}
}

View File

@@ -7,6 +7,7 @@ dependencies {
implementation project(":lib_utils")
implementation libs.nordic.ble.common
implementation libs.nordic.ble.ktx
implementation libs.nordic.log
implementation libs.nordic.theme

View File

@@ -21,42 +21,45 @@
*/
package no.nordicsemi.android.cgms.repository
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import android.util.SparseArray
import no.nordicsemi.android.ble.common.callback.RecordAccessControlPointDataCallback
import no.nordicsemi.android.ble.common.callback.cgm.CGMFeatureDataCallback
import no.nordicsemi.android.ble.common.callback.cgm.CGMSpecificOpsControlPointDataCallback
import no.nordicsemi.android.ble.common.callback.cgm.CGMStatusDataCallback
import no.nordicsemi.android.ble.common.callback.cgm.ContinuousGlucoseMeasurementDataCallback
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.common.callback.RecordAccessControlPointResponse
import no.nordicsemi.android.ble.common.callback.cgm.CGMFeatureResponse
import no.nordicsemi.android.ble.common.callback.cgm.CGMSpecificOpsControlPointResponse
import no.nordicsemi.android.ble.common.callback.cgm.CGMStatusResponse
import no.nordicsemi.android.ble.common.callback.cgm.ContinuousGlucoseMeasurementResponse
import no.nordicsemi.android.ble.common.data.RecordAccessControlPointData
import no.nordicsemi.android.ble.common.data.cgm.CGMSpecificOpsControlPointData
import no.nordicsemi.android.ble.common.profile.RecordAccessControlPointCallback
import no.nordicsemi.android.ble.common.profile.cgm.CGMSpecificOpsControlPointCallback
import no.nordicsemi.android.ble.common.profile.cgm.CGMTypes
import no.nordicsemi.android.ble.data.Data
import no.nordicsemi.android.cgms.data.*
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.ble.ktx.asValidResponseFlow
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.ble.ktx.suspendForValidResponse
import no.nordicsemi.android.cgms.data.CGMRecord
import no.nordicsemi.android.cgms.data.CGMRepository
import no.nordicsemi.android.cgms.data.RequestStatus
import no.nordicsemi.android.service.BatteryManager
import java.util.*
/** Cycling Speed and Cadence service UUID. */
val CGMS_SERVICE_UUID = UUID.fromString("0000181F-0000-1000-8000-00805f9b34fb")
private val CGM_STATUS_UUID = UUID.fromString("00002AA9-0000-1000-8000-00805f9b34fb")
private val CGM_FEATURE_UUID = UUID.fromString("00002AA8-0000-1000-8000-00805f9b34fb")
private val CGM_MEASUREMENT_UUID = UUID.fromString("00002AA7-0000-1000-8000-00805f9b34fb")
private val CGM_OPS_CONTROL_POINT_UUID =
UUID.fromString("00002AAC-0000-1000-8000-00805f9b34fb")
private val CGM_OPS_CONTROL_POINT_UUID = UUID.fromString("00002AAC-0000-1000-8000-00805f9b34fb")
/** Record Access Control Point characteristic UUID. */
private val RACP_UUID = UUID.fromString("00002A52-0000-1000-8000-00805f9b34fb")
internal class CGMManager(
context: Context,
private val scope: CoroutineScope,
private val repository: CGMRepository
) : BatteryManager(context) {
@@ -67,21 +70,16 @@ internal class CGMManager(
private var recordAccessControlPointCharacteristic: BluetoothGattCharacteristic? = null
private val records: SparseArray<CGMRecord> = SparseArray<CGMRecord>()
/** A flag set to true if the remote device supports E2E CRC. */
private var secured = false
/**
* A flag set when records has been requested using RACP. This is to distinguish CGM packets
* received as continuous measurements or requested.
*/
private var recordAccessRequestInProgress = false
/**
* The timestamp when the session has started. This is needed to display the user facing
* times of samples.
*/
private var sessionStartTime: Long = 0
private val exceptionHandler = CoroutineExceptionHandler { _, t->
Log.e("COROUTINE-EXCEPTION", "Uncaught exception", t)
}
override fun onBatteryLevelChanged(batteryLevel: Int) {
repository.emitNewBatteryLevel(batteryLevel)
}
@@ -90,247 +88,135 @@ internal class CGMManager(
return CGMManagerGattCallback()
}
/**
* BluetoothGatt mCallbacks for connection/disconnection, service discovery,
* receiving notification, etc.
*/
private inner class CGMManagerGattCallback : BatteryManagerGattCallback() {
override fun initialize() {
// Enable Battery service
super.initialize()
// Read CGM Feature characteristic, mainly to see if the device supports E2E CRC.
// This is not supported in the experimental CGMS from the SDK.
readCharacteristic(cgmFeatureCharacteristic)
.with(object : CGMFeatureDataCallback() {
override fun onContinuousGlucoseMonitorFeaturesReceived(
device: BluetoothDevice, features: CGMTypes.CGMFeatures,
type: Int, sampleLocation: Int, secured: Boolean
) {
this@CGMManager.secured = features.e2eCrcSupported
log(
LogContract.Log.Level.APPLICATION,
"E2E CRC feature " + if (this@CGMManager.secured) "supported" else "not supported"
)
}
})
.fail { _: BluetoothDevice?, _: Int ->
log(
Log.WARN,
"Could not read CGM Feature characteristic"
)
scope.launch(exceptionHandler) {
val response =
readCharacteristic(cgmFeatureCharacteristic).suspendForValidResponse<CGMFeatureResponse>()
this@CGMManager.secured = response.features.e2eCrcSupported
}
scope.launch(exceptionHandler) {
val response =
readCharacteristic(cgmStatusCharacteristic).suspendForValidResponse<CGMStatusResponse>()
if (response.status?.sessionStopped == false) {
sessionStartTime = System.currentTimeMillis() - response.timeOffset * 60000L
}
.enqueue()
}
// Check if the session is already started. This is not supported in the experimental CGMS from the SDK.
readCharacteristic(cgmStatusCharacteristic)
.with(object : CGMStatusDataCallback() {
override fun onContinuousGlucoseMonitorStatusChanged(
device: BluetoothDevice,
status: CGMTypes.CGMStatus,
timeOffset: Int,
secured: Boolean
) {
if (!status.sessionStopped) {
sessionStartTime = System.currentTimeMillis() - timeOffset * 60000L
log(LogContract.Log.Level.APPLICATION, "Session already started")
}
}
})
.fail { _: BluetoothDevice?, _: Int ->
log(
Log.WARN,
"Could not read CGM Status characteristic"
)
}
.enqueue()
// Set notification and indication mCallbacks
setNotificationCallback(cgmMeasurementCharacteristic)
.with(object : ContinuousGlucoseMeasurementDataCallback() {
override fun onContinuousGlucoseMeasurementReceived(
device: BluetoothDevice,
glucoseConcentration: Float,
cgmTrend: Float?,
cgmQuality: Float?,
status: CGMTypes.CGMStatus?,
timeOffset: Int,
secured: Boolean
) {
// If the CGM Status characteristic has not been read and the session was already started before,
// estimate the Session Start Time by subtracting timeOffset minutes from the current timestamp.
if (sessionStartTime == 0L && !recordAccessRequestInProgress) {
sessionStartTime = System.currentTimeMillis() - timeOffset * 60000L
}
// Calculate the sample timestamp based on the Session Start Time
val timestamp =
sessionStartTime + timeOffset * 60000L // Sequence number is in minutes since Start Session
val record = CGMRecord(timeOffset, glucoseConcentration, timestamp)
records.put(record.sequenceNumber, record)
repository.emitNewRecords(records.toList())
setNotificationCallback(cgmMeasurementCharacteristic).asValidResponseFlow<ContinuousGlucoseMeasurementResponse>()
.onEach {
if (sessionStartTime == 0L && !recordAccessRequestInProgress) {
sessionStartTime = System.currentTimeMillis() - it.timeOffset * 60000L
}
override fun onContinuousGlucoseMeasurementReceivedWithCrcError(
device: BluetoothDevice,
data: Data
) {
log(
Log.WARN,
"Continuous Glucose Measurement record received with CRC error"
)
}
})
setIndicationCallback(cgmSpecificOpsControlPointCharacteristic)
.with(object : CGMSpecificOpsControlPointDataCallback() {
@SuppressLint("SwitchIntDef")
override fun onCGMSpecificOpsOperationCompleted(
device: BluetoothDevice,
@CGMSpecificOpsControlPointCallback.CGMOpCode requestCode: Int,
secured: Boolean
) {
when (requestCode) {
val timestamp = sessionStartTime + it.timeOffset * 60000L
val record = CGMRecord(it.timeOffset, it.glucoseConcentration, timestamp)
records.put(record.sequenceNumber, record)
repository.emitNewRecords(records.toList())
}.launchIn(scope)
setIndicationCallback(cgmSpecificOpsControlPointCharacteristic).asValidResponseFlow<CGMSpecificOpsControlPointResponse>()
.onEach {
if (it.isOperationCompleted) {
when (it.requestCode) {
CGMSpecificOpsControlPointCallback.CGM_OP_CODE_START_SESSION -> sessionStartTime =
System.currentTimeMillis()
CGMSpecificOpsControlPointCallback.CGM_OP_CODE_STOP_SESSION -> sessionStartTime =
0
CGMSpecificOpsControlPointCallback.CGM_OP_CODE_STOP_SESSION -> sessionStartTime = 0
}
}
@SuppressLint("SwitchIntDef")
override fun onCGMSpecificOpsOperationError(
device: BluetoothDevice,
@CGMSpecificOpsControlPointCallback.CGMOpCode requestCode: Int,
@CGMSpecificOpsControlPointCallback.CGMErrorCode errorCode: Int,
secured: Boolean
) {
when (requestCode) {
CGMSpecificOpsControlPointCallback.CGM_OP_CODE_START_SESSION -> {
if (errorCode == CGMSpecificOpsControlPointCallback.CGM_ERROR_PROCEDURE_NOT_COMPLETED) {
// Session was already started before.
// Looks like the CGM Status characteristic has not been read,
// otherwise we would have got the Session Start Time before.
// The Session Start Time will be calculated when a next CGM
// packet is received based on it's Time Offset.
} else {
when (it.requestCode) {
CGMSpecificOpsControlPointCallback.CGM_OP_CODE_START_SESSION ->
if (it.errorCode == CGMSpecificOpsControlPointCallback.CGM_ERROR_PROCEDURE_NOT_COMPLETED) {
sessionStartTime = 0
}
sessionStartTime = 0
}
CGMSpecificOpsControlPointCallback.CGM_OP_CODE_STOP_SESSION -> sessionStartTime =
0
CGMSpecificOpsControlPointCallback.CGM_OP_CODE_STOP_SESSION -> sessionStartTime = 0
}
}
}.launchIn(scope)
override fun onCGMSpecificOpsResponseReceivedWithCrcError(
device: BluetoothDevice,
data: Data
) {
log(Log.ERROR, "Request failed: CRC error")
}
})
setIndicationCallback(recordAccessControlPointCharacteristic)
.with(object : RecordAccessControlPointDataCallback() {
@SuppressLint("SwitchIntDef")
override fun onRecordAccessOperationCompleted(
device: BluetoothDevice,
@RecordAccessControlPointCallback.RACPOpCode requestCode: Int
) {
when (requestCode) {
RecordAccessControlPointCallback.RACP_OP_CODE_ABORT_OPERATION -> repository.setRequestStatus(RequestStatus.ABORTED)
else -> {
recordAccessRequestInProgress = false
repository.setRequestStatus(RequestStatus.SUCCESS)
}
}
setIndicationCallback(recordAccessControlPointCharacteristic).asValidResponseFlow<RecordAccessControlPointResponse>()
.onEach {
if (it.isOperationCompleted && !it.wereRecordsFound() && it.numberOfRecords > 0) {
onRecordsReceived(it)
} else if (it.isOperationCompleted && !it.wereRecordsFound()) {
onNoRecordsFound()
} else if (it.isOperationCompleted && it.wereRecordsFound()) {
onOperationCompleted(it)
} else if (it.errorCode > 0) {
onError(it)
}
}.launchIn(scope)
override fun onRecordAccessOperationCompletedWithNoRecordsFound(
device: BluetoothDevice,
@RecordAccessControlPointCallback.RACPOpCode requestCode: Int
) {
recordAccessRequestInProgress = false
repository.setRequestStatus(RequestStatus.SUCCESS)
}
scope.launch(exceptionHandler) {
enableNotifications(cgmMeasurementCharacteristic).suspend()
}
scope.launch(exceptionHandler) {
enableIndications(cgmSpecificOpsControlPointCharacteristic).suspend()
}
scope.launch(exceptionHandler) {
enableIndications(recordAccessControlPointCharacteristic).suspend()
}
override fun onNumberOfRecordsReceived(
device: BluetoothDevice,
numberOfRecords: Int
) {
if (numberOfRecords > 0) {
if (records.size() > 0) {
val sequenceNumber = records.keyAt(records.size() - 1) + 1
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportStoredRecordsGreaterThenOrEqualTo(
sequenceNumber
)
)
.enqueue()
} else {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportAllStoredRecords()
)
.enqueue()
}
} else {
recordAccessRequestInProgress = false
repository.setRequestStatus(RequestStatus.SUCCESS)
}
}
override fun onRecordAccessOperationError(
device: BluetoothDevice,
@RecordAccessControlPointCallback.RACPOpCode requestCode: Int,
@RecordAccessControlPointCallback.RACPErrorCode errorCode: Int
) {
log(Log.WARN, "Record Access operation failed (error $errorCode)")
if (errorCode == RecordAccessControlPointCallback.RACP_ERROR_OP_CODE_NOT_SUPPORTED) {
repository.setRequestStatus(RequestStatus.NOT_SUPPORTED)
} else {
repository.setRequestStatus(RequestStatus.FAILED)
}
}
})
// Enable notifications and indications
enableNotifications(cgmMeasurementCharacteristic)
.fail { _: BluetoothDevice?, status: Int ->
log(
Log.WARN,
"Failed to enable Continuous Glucose Measurement notifications ($status)"
)
}
.enqueue()
enableIndications(cgmSpecificOpsControlPointCharacteristic)
.fail { _: BluetoothDevice?, status: Int ->
log(
Log.WARN,
"Failed to enable CGM Specific Ops Control Point indications notifications ($status)"
)
}
.enqueue()
enableIndications(recordAccessControlPointCharacteristic)
.fail { _: BluetoothDevice?, status: Int ->
log(
Log.WARN,
"Failed to enabled Record Access Control Point indications (error $status)"
)
}
.enqueue()
// Start Continuous Glucose session if hasn't been started before
if (sessionStartTime == 0L) {
writeCharacteristic(
cgmSpecificOpsControlPointCharacteristic,
CGMSpecificOpsControlPointData.startSession(secured)
scope.launch(exceptionHandler) {
writeCharacteristic(
cgmSpecificOpsControlPointCharacteristic,
CGMSpecificOpsControlPointData.startSession(secured),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
}
private suspend fun onRecordsReceived(response: RecordAccessControlPointResponse) {
if (response.numberOfRecords > 0) {
if (records.size() > 0) {
val sequenceNumber = records.keyAt(records.size() - 1) + 1
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportStoredRecordsGreaterThenOrEqualTo(
sequenceNumber
),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
} else {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportAllStoredRecords(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
} else {
recordAccessRequestInProgress = false
repository.setRequestStatus(RequestStatus.SUCCESS)
}
}
private fun onNoRecordsFound() {
recordAccessRequestInProgress = false
repository.setRequestStatus(RequestStatus.SUCCESS)
}
private fun onOperationCompleted(response: RecordAccessControlPointResponse) {
when (response.requestCode) {
RecordAccessControlPointCallback.RACP_OP_CODE_ABORT_OPERATION -> repository.setRequestStatus(
RequestStatus.ABORTED
)
.fail { _: BluetoothDevice?, status: Int ->
log(
LogContract.Log.Level.ERROR,
"Failed to start session (error $status)"
)
}
.enqueue()
else -> {
recordAccessRequestInProgress = false
repository.setRequestStatus(RequestStatus.SUCCESS)
}
}
}
private fun onError(response: RecordAccessControlPointResponse) {
if (response.errorCode == RecordAccessControlPointCallback.RACP_ERROR_OP_CODE_NOT_SUPPORTED) {
repository.setRequestStatus(RequestStatus.NOT_SUPPORTED)
} else {
repository.setRequestStatus(RequestStatus.FAILED)
}
}
@@ -360,118 +246,49 @@ internal class CGMManager(
}
}
/**
* Returns a list of CGM records obtained from this device. The key in the array is the
*/
fun getRecords(): SparseArray<CGMRecord> {
return records
}
/**
* Clears the records list locally
*/
fun clear() {
records.clear()
}
/**
* Sends the request to obtain the last (most recent) record from glucose device.
* The data will be returned to Glucose Measurement characteristic as a notification followed by
* Record Access Control Point indication with status code Success or other in case of error.
*/
fun requestLastRecord() {
if (recordAccessControlPointCharacteristic == null) return
clear()
repository.setRequestStatus(RequestStatus.PENDING)
recordAccessRequestInProgress = true
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportLastStoredRecord()
).enqueue()
scope.launch(exceptionHandler) {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportLastStoredRecord(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
/**
* Sends the request to obtain the first (oldest) record from glucose device.
* The data will be returned to Glucose Measurement characteristic as a notification followed by
* Record Access Control Point indication with status code Success or other in case of error.
*/
fun requestFirstRecord() {
if (recordAccessControlPointCharacteristic == null) return
clear()
repository.setRequestStatus(RequestStatus.PENDING)
recordAccessRequestInProgress = true
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportFirstStoredRecord()
).enqueue()
scope.launch(exceptionHandler) {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportFirstStoredRecord(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
/**
* Sends abort operation signal to the device.
*/
fun abort() {
if (recordAccessControlPointCharacteristic == null) return
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.abortOperation()
).enqueue()
}
/**
* Sends the request to obtain all records from glucose device. Initially we want to notify the
* user about the number of the records so the Report Number of Stored Records request is send.
* The data will be returned to Glucose Measurement characteristic as a notification followed by
* Record Access Control Point indication with status code Success or other in case of error.
*/
fun requestAllRecords() {
if (recordAccessControlPointCharacteristic == null) return
clear()
repository.setRequestStatus(RequestStatus.PENDING)
recordAccessRequestInProgress = true
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportNumberOfAllStoredRecords()
).enqueue()
}
/**
* Sends the request to obtain all records from glucose device. Initially we want to notify the
* user about the number of the records so the Report Number of Stored Records request is send.
* The data will be returned to Glucose Measurement characteristic as a notification followed by
* Record Access Control Point indication with status code Success or other in case of error.
*/
fun refreshRecords() {
if (recordAccessControlPointCharacteristic == null) return
if (records.size() == 0) {
requestAllRecords()
} else {
repository.setRequestStatus(RequestStatus.PENDING)
// Obtain the last sequence number
val sequenceNumber = records.keyAt(records.size() - 1) + 1
recordAccessRequestInProgress = true
scope.launch(exceptionHandler) {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportStoredRecordsGreaterThenOrEqualTo(sequenceNumber)
).enqueue()
// Info:
// Operators OPERATOR_GREATER_THEN_OR_EQUAL, OPERATOR_LESS_THEN_OR_EQUAL and OPERATOR_RANGE are not supported by the CGMS sample from SDK
// The "Operation not supported" response will be received
RecordAccessControlPointData.reportNumberOfAllStoredRecords(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
/**
* Sends the request to remove all stored records from the Continuous Glucose Monitor device.
* This feature is not supported by the CGMS sample from the SDK, so monitor will answer with
* the Op Code Not Supported error.
*/
fun deleteAllRecords() {
if (recordAccessControlPointCharacteristic == null) return
clear()
repository.setRequestStatus(RequestStatus.PENDING)
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.deleteAllStoredRecords()
).enqueue()
}
}

View File

@@ -16,7 +16,7 @@ internal class CGMService : ForegroundBleService() {
@Inject
lateinit var repository: CGMRepository
override val manager: CGMManager by lazy { CGMManager(this, repository) }
override val manager: CGMManager by lazy { CGMManager(this, scope, repository) }
override fun onCreate() {
super.onCreate()

View File

@@ -7,6 +7,7 @@ dependencies {
implementation project(":lib_utils")
implementation libs.nordic.ble.common
implementation libs.nordic.ble.ktx
implementation libs.nordic.log
implementation libs.nordic.theme

View File

@@ -49,9 +49,9 @@ internal data class CSCData(
fun displayTotalDistance(): String {
return when (selectedSpeedUnit) {
SpeedUnit.M_S -> String.format("%.2f km", distance)
SpeedUnit.KM_H -> String.format("%.2f km", distance)
SpeedUnit.MPH -> String.format("%.2f mile", distance)
SpeedUnit.M_S -> String.format("%.2f km", totalDistance)
SpeedUnit.KM_H -> String.format("%.2f km", totalDistance)
SpeedUnit.MPH -> String.format("%.2f mile", totalDistance)
}
}

View File

@@ -21,30 +21,41 @@
*/
package no.nordicsemi.android.csc.repository
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import androidx.annotation.FloatRange
import no.nordicsemi.android.ble.common.callback.csc.CyclingSpeedAndCadenceMeasurementDataCallback
import no.nordicsemi.android.ble.data.Data
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.common.callback.csc.CyclingSpeedAndCadenceMeasurementResponse
import no.nordicsemi.android.ble.ktx.asValidResponseFlow
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.csc.data.CSCRepository
import no.nordicsemi.android.csc.data.WheelSize
import no.nordicsemi.android.service.BatteryManager
import java.util.*
/** Cycling Speed and Cadence service UUID. */
val CSC_SERVICE_UUID: UUID = UUID.fromString("00001816-0000-1000-8000-00805f9b34fb")
/** Cycling Speed and Cadence Measurement characteristic UUID. */
private val CSC_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A5B-0000-1000-8000-00805f9b34fb")
internal class CSCManager(context: Context, private val repository: CSCRepository) : BatteryManager(context) {
internal class CSCManager(
context: Context,
private val scope: CoroutineScope,
private val repository: CSCRepository
) : BatteryManager(context) {
private var cscMeasurementCharacteristic: BluetoothGattCharacteristic? = null
private var wheelSize: WheelSize = WheelSize()
private var previousResponse: CyclingSpeedAndCadenceMeasurementResponse? = null
private val exceptionHandler = CoroutineExceptionHandler { context, t->
Log.e("COROUTINE-EXCEPTION", "Uncaught exception", t)
}
override fun onBatteryLevelChanged(batteryLevel: Int) {
repository.setBatteryLevel(batteryLevel)
}
@@ -57,47 +68,30 @@ internal class CSCManager(context: Context, private val repository: CSCRepositor
wheelSize = value
}
/**
* BluetoothGatt callbacks for connection/disconnection, service discovery,
* receiving indication, etc.
*/
private inner class CSCManagerGattCallback : BatteryManagerGattCallback() {
override fun initialize() {
super.initialize()
// CSC characteristic is required
setNotificationCallback(cscMeasurementCharacteristic)
.with(object : CyclingSpeedAndCadenceMeasurementDataCallback() {
override fun getWheelCircumference(): Float {
return wheelSize.value.toFloat()
}
override fun onDistanceChanged(
device: BluetoothDevice,
@FloatRange(from = 0.0) totalDistance: Float,
@FloatRange(from = 0.0) distance: Float,
@FloatRange(from = 0.0) speed: Float
) {
setNotificationCallback(cscMeasurementCharacteristic).asValidResponseFlow<CyclingSpeedAndCadenceMeasurementResponse>()
.onEach {
previousResponse?.let { previousResponse ->
val wheelCircumference = wheelSize.value.toFloat()
val totalDistance = it.getTotalDistance(wheelSize.value.toFloat())
val distance = it.getDistance(wheelCircumference, previousResponse)
val speed = it.getSpeed(wheelCircumference, previousResponse)
repository.setNewDistance(totalDistance, distance, speed, wheelSize)
}
override fun onCrankDataChanged(
device: BluetoothDevice,
@FloatRange(from = 0.0) crankCadence: Float,
gearRatio: Float
) {
val crankCadence = it.getCrankCadence(previousResponse)
val gearRatio = it.getGearRatio(previousResponse)
repository.setNewCrankCadence(crankCadence, gearRatio, wheelSize)
}
override fun onInvalidDataReceived(
device: BluetoothDevice,
data: Data
) {
log(Log.WARN, "Invalid CSC Measurement data received: $data")
}
})
enableNotifications(cscMeasurementCharacteristic).enqueue()
previousResponse = it
}.launchIn(scope)
scope.launch(exceptionHandler) {
enableNotifications(cscMeasurementCharacteristic).suspend()
}
}
public override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {

View File

@@ -17,7 +17,7 @@ internal class CSCService : ForegroundBleService() {
@Inject
lateinit var repository: CSCRepository
override val manager: CSCManager by lazy { CSCManager(this, repository) }
override val manager: CSCManager by lazy { CSCManager(this, scope, repository) }
override fun onCreate() {
super.onCreate()

View File

@@ -9,6 +9,7 @@ dependencies {
implementation libs.chart
implementation libs.nordic.ble.common
implementation libs.nordic.ble.ktx
implementation libs.nordic.theme
implementation libs.nordic.ui.scanner
implementation libs.nordic.navigation

View File

@@ -14,184 +14,196 @@ import androidx.compose.ui.unit.dp
import no.nordicsemi.android.gls.R
import no.nordicsemi.android.gls.data.GLSRecord
import no.nordicsemi.android.gls.main.view.toDisplayString
import no.nordicsemi.android.theme.view.ScreenSection
@Composable
internal fun GLSDetailsContentView(record: GLSRecord) {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Column(modifier = Modifier.padding(16.dp)) {
Field(
stringResource(id = R.string.gls_details_sequence_number),
record.sequenceNumber.toString()
)
record.time?.let {
ScreenSection() {
Field(
stringResource(id = R.string.gls_details_date_and_time),
stringResource(R.string.gls_timestamp, it)
stringResource(id = R.string.gls_details_sequence_number),
record.sequenceNumber.toString()
)
}
Divider(
color = MaterialTheme.colorScheme.secondary,
thickness = 1.dp,
modifier = Modifier.padding(vertical = 16.dp)
)
record.type?.let {
Field(stringResource(id = R.string.gls_details_type), it.toDisplayString())
Spacer(modifier = Modifier.size(4.dp))
}
record.sampleLocation?.let {
Field(stringResource(id = R.string.gls_details_location), it.toDisplayString())
Spacer(modifier = Modifier.size(4.dp))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(
text = stringResource(id = R.string.gls_details_glucose_condensation_title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
Text(
text = stringResource(
id = R.string.gls_details_glucose_condensation_field,
record.glucoseConcentration,
record.unit.toDisplayString()
),
style = MaterialTheme.typography.titleLarge
)
}
Divider(
color = MaterialTheme.colorScheme.secondary,
thickness = 1.dp,
modifier = Modifier.padding(vertical = 16.dp)
)
record.status?.let {
BooleanField(
stringResource(id = R.string.gls_details_battery_low),
it.deviceBatteryLow
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_sensor_malfunction),
it.sensorMalfunction
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_insufficient_sample),
it.sampleSizeInsufficient
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_strip_insertion_error),
it.stripInsertionError
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_strip_type_incorrect),
it.stripTypeIncorrect
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_sensor_result_too_high),
it.sensorResultHigherThenDeviceCanProcess
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_sensor_result_too_low),
it.sensorResultLowerThenDeviceCanProcess
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_temperature_too_high),
it.sensorTemperatureTooHigh
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_temperature_too_low),
it.sensorTemperatureTooLow
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_strip_pulled_too_soon),
it.sensorReadInterrupted
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_general_device_fault),
it.generalDeviceFault
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(stringResource(id = R.string.gls_details_time_fault), it.timeFault)
Spacer(modifier = Modifier.size(4.dp))
}
Divider(
color = MaterialTheme.colorScheme.secondary,
thickness = 1.dp,
modifier = Modifier.padding(vertical = 16.dp)
)
record.context?.let {
Field(
stringResource(id = R.string.gls_context_title),
stringResource(id = R.string.gls_available)
)
Spacer(modifier = Modifier.size(4.dp))
it.carbohydrate?.let {
record.time?.let {
Field(
stringResource(id = R.string.gls_context_carbohydrate),
it.toDisplayString()
stringResource(id = R.string.gls_details_date_and_time),
stringResource(R.string.gls_timestamp, it)
)
}
Divider(
color = MaterialTheme.colorScheme.secondary,
thickness = 1.dp,
modifier = Modifier.padding(vertical = 16.dp)
)
record.type?.let {
Field(stringResource(id = R.string.gls_details_type), it.toDisplayString())
Spacer(modifier = Modifier.size(4.dp))
}
record.sampleLocation?.let {
Field(stringResource(id = R.string.gls_details_location), it.toDisplayString())
Spacer(modifier = Modifier.size(4.dp))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(
text = stringResource(id = R.string.gls_details_glucose_condensation_title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
Text(
text = stringResource(
id = R.string.gls_details_glucose_condensation_field,
record.glucoseConcentration,
record.unit.toDisplayString()
),
style = MaterialTheme.typography.titleLarge
)
}
record.status?.let {
Divider(
color = MaterialTheme.colorScheme.secondary,
thickness = 1.dp,
modifier = Modifier.padding(vertical = 16.dp)
)
BooleanField(
stringResource(id = R.string.gls_details_battery_low),
it.deviceBatteryLow
)
Spacer(modifier = Modifier.size(4.dp))
}
it.meal?.let {
Field(stringResource(id = R.string.gls_context_meal), it.toDisplayString())
Spacer(modifier = Modifier.size(4.dp))
}
it.tester?.let {
Field(stringResource(id = R.string.gls_context_tester), it.toDisplayString())
Spacer(modifier = Modifier.size(4.dp))
}
it.health?.let {
Field(stringResource(id = R.string.gls_context_health), it.toDisplayString())
Spacer(modifier = Modifier.size(4.dp))
}
Field(
stringResource(id = R.string.gls_context_exercise_title),
stringResource(
id = R.string.gls_context_exercise_field,
it.exerciseDuration,
it.exerciseIntensity
BooleanField(
stringResource(id = R.string.gls_details_sensor_malfunction),
it.sensorMalfunction
)
)
Spacer(modifier = Modifier.size(4.dp))
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_insufficient_sample),
it.sampleSizeInsufficient
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_strip_insertion_error),
it.stripInsertionError
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_strip_type_incorrect),
it.stripTypeIncorrect
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_sensor_result_too_high),
it.sensorResultHigherThenDeviceCanProcess
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_sensor_result_too_low),
it.sensorResultLowerThenDeviceCanProcess
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_temperature_too_high),
it.sensorTemperatureTooHigh
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_temperature_too_low),
it.sensorTemperatureTooLow
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_strip_pulled_too_soon),
it.sensorReadInterrupted
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(
stringResource(id = R.string.gls_details_general_device_fault),
it.generalDeviceFault
)
Spacer(modifier = Modifier.size(4.dp))
BooleanField(stringResource(id = R.string.gls_details_time_fault), it.timeFault)
Spacer(modifier = Modifier.size(4.dp))
}
val medicationField = String.format(
stringResource(id = R.string.gls_context_medication_field),
it.medicationQuantity,
it.medicationUnit.toDisplayString(),
it.medication?.toDisplayString()
)
Field(stringResource(id = R.string.gls_context_medication_title), medicationField)
record.context?.let {
Divider(
color = MaterialTheme.colorScheme.secondary,
thickness = 1.dp,
modifier = Modifier.padding(vertical = 16.dp)
)
Spacer(modifier = Modifier.size(4.dp))
Field(
stringResource(id = R.string.gls_context_hba1c_title),
stringResource(id = R.string.gls_context_hba1c_field, it.HbA1c)
Field(
stringResource(id = R.string.gls_context_title),
stringResource(id = R.string.gls_available)
)
Spacer(modifier = Modifier.size(4.dp))
it.carbohydrate?.let {
Field(
stringResource(id = R.string.gls_context_carbohydrate),
it.toDisplayString()
)
Spacer(modifier = Modifier.size(4.dp))
}
it.meal?.let {
Field(stringResource(id = R.string.gls_context_meal), it.toDisplayString())
Spacer(modifier = Modifier.size(4.dp))
}
it.tester?.let {
Field(
stringResource(id = R.string.gls_context_tester),
it.toDisplayString()
)
Spacer(modifier = Modifier.size(4.dp))
}
it.health?.let {
Field(
stringResource(id = R.string.gls_context_health),
it.toDisplayString()
)
Spacer(modifier = Modifier.size(4.dp))
}
Field(
stringResource(id = R.string.gls_context_exercise_title),
stringResource(
id = R.string.gls_context_exercise_field,
it.exerciseDuration,
it.exerciseIntensity
)
)
Spacer(modifier = Modifier.size(4.dp))
val medicationField = String.format(
stringResource(id = R.string.gls_context_medication_field),
it.medicationQuantity,
it.medicationUnit.toDisplayString(),
it.medication?.toDisplayString()
)
Field(
stringResource(id = R.string.gls_context_medication_title),
medicationField
)
Spacer(modifier = Modifier.size(4.dp))
Field(
stringResource(id = R.string.gls_context_hba1c_title),
stringResource(id = R.string.gls_context_hba1c_field, it.HbA1c)
)
Spacer(modifier = Modifier.size(4.dp))
} ?: Field(
stringResource(id = R.string.gls_context_title),
stringResource(id = R.string.gls_unavailable)
)
Spacer(modifier = Modifier.size(4.dp))
} ?: Field(
stringResource(id = R.string.gls_context_title),
stringResource(id = R.string.gls_unavailable)
)
}
}
}
}

View File

@@ -109,5 +109,6 @@ internal class GLSViewModel @Inject constructor(
override fun onCleared() {
super.onCleared()
repository.clear()
glsManager.release()
}
}

View File

@@ -21,53 +21,52 @@
*/
package no.nordicsemi.android.gls.repository
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.common.callback.RecordAccessControlPointDataCallback
import no.nordicsemi.android.ble.common.callback.glucose.GlucoseMeasurementContextDataCallback
import no.nordicsemi.android.ble.common.callback.glucose.GlucoseMeasurementDataCallback
import no.nordicsemi.android.ble.common.callback.RecordAccessControlPointResponse
import no.nordicsemi.android.ble.common.callback.glucose.GlucoseMeasurementContextResponse
import no.nordicsemi.android.ble.common.callback.glucose.GlucoseMeasurementResponse
import no.nordicsemi.android.ble.common.data.RecordAccessControlPointData
import no.nordicsemi.android.ble.common.profile.RecordAccessControlPointCallback.RACPErrorCode
import no.nordicsemi.android.ble.common.profile.RecordAccessControlPointCallback.RACPOpCode
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementCallback.GlucoseStatus
import no.nordicsemi.android.ble.common.profile.glucose.GlucoseMeasurementContextCallback.*
import no.nordicsemi.android.ble.ktx.asValidResponseFlow
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.gls.data.*
import no.nordicsemi.android.service.BatteryManager
import no.nordicsemi.android.service.CloseableCoroutineScope
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
/** Glucose service UUID */
val GLS_SERVICE_UUID: UUID = UUID.fromString("00001808-0000-1000-8000-00805f9b34fb")
/** Glucose Measurement characteristic UUID */
private val GM_CHARACTERISTIC = UUID.fromString("00002A18-0000-1000-8000-00805f9b34fb")
/** Glucose Measurement Context characteristic UUID */
private val GM_CONTEXT_CHARACTERISTIC =
UUID.fromString("00002A34-0000-1000-8000-00805f9b34fb")
/** Glucose Feature characteristic UUID */
private val GM_CONTEXT_CHARACTERISTIC = UUID.fromString("00002A34-0000-1000-8000-00805f9b34fb")
private val GF_CHARACTERISTIC = UUID.fromString("00002A51-0000-1000-8000-00805f9b34fb")
/** Record Access Control Point characteristic UUID */
private val RACP_CHARACTERISTIC = UUID.fromString("00002A52-0000-1000-8000-00805f9b34fb")
@Singleton
internal class GLSManager @Inject constructor(
@ApplicationContext context: Context,
private val repository: GLSRepository
) : BatteryManager(context) {
private val scope = CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var glucoseMeasurementCharacteristic: BluetoothGattCharacteristic? = null
private var glucoseMeasurementContextCharacteristic: BluetoothGattCharacteristic? = null
private var recordAccessControlPointCharacteristic: BluetoothGattCharacteristic? = null
private val exceptionHandler = CoroutineExceptionHandler { _, t->
Log.e("COROUTINE-EXCEPTION", "Uncaught exception", t)
}
override fun onBatteryLevelChanged(batteryLevel: Int) {
repository.setNewBatteryLevel(batteryLevel)
}
@@ -76,183 +75,124 @@ internal class GLSManager @Inject constructor(
return GlucoseManagerGattCallback()
}
/**
* BluetoothGatt callbacks for connection/disconnection, service discovery,
* receiving notification, etc.
*/
private inner class GlucoseManagerGattCallback : BatteryManagerGattCallback() {
override fun initialize() {
super.initialize()
// The gatt.setCharacteristicNotification(...) method is called in BleManager during
// enabling notifications or indications
// (see BleManager#internalEnableNotifications/Indications).
// However, on Samsung S3 with Android 4.3 it looks like the 2 gatt calls
// (gatt.setCharacteristicNotification(...) and gatt.writeDescriptor(...)) are called
// too quickly, or from a wrong thread, and in result the notification listener is not
// set, causing onCharacteristicChanged(...) callback never being called when a
// notification comes. Enabling them here, like below, solves the problem.
// However... the original approach works for the Battery Level CCCD, which makes it
// even weirder.
/*
gatt.setCharacteristicNotification(glucoseMeasurementCharacteristic, true);
if (glucoseMeasurementContextCharacteristic != null) {
device.setCharacteristicNotification(glucoseMeasurementContextCharacteristic, true);
}
device.setCharacteristicNotification(recordAccessControlPointCharacteristic, true);
*/
setNotificationCallback(glucoseMeasurementCharacteristic)
.with(object : GlucoseMeasurementDataCallback() {
override fun onGlucoseMeasurementReceived(
device: BluetoothDevice,
sequenceNumber: Int,
time: Calendar,
glucoseConcentration: Float?,
unit: Int?,
type: Int?,
sampleLocation: Int?,
status: GlucoseStatus?,
contextInformationFollows: Boolean
) {
val record = GLSRecord(
sequenceNumber = sequenceNumber,
time = time,
glucoseConcentration = glucoseConcentration ?: 0f,
unit = unit?.let { ConcentrationUnit.create(it) }
?: ConcentrationUnit.UNIT_KGPL,
type = RecordType.createOrNull(type),
sampleLocation = SampleLocation.createOrNull(sampleLocation),
status = status
)
repository.addNewRecord(record)
}
})
setNotificationCallback(glucoseMeasurementContextCharacteristic)
.with(object : GlucoseMeasurementContextDataCallback() {
override fun onGlucoseMeasurementContextReceived(
device: BluetoothDevice,
sequenceNumber: Int,
carbohydrate: Carbohydrate?,
carbohydrateAmount: Float?,
meal: Meal?,
tester: Tester?,
health: Health?,
exerciseDuration: Int?,
exerciseIntensity: Int?,
medication: Medication?,
medicationAmount: Float?,
medicationUnit: Int?,
HbA1c: Float?
) {
val context = MeasurementContext(
sequenceNumber = sequenceNumber,
carbohydrate = carbohydrate,
carbohydrateAmount = carbohydrateAmount ?: 0f,
meal = meal,
tester = tester,
health = health,
exerciseDuration = exerciseDuration ?: 0,
exerciseIntensity = exerciseIntensity ?: 0,
medication = medication,
medicationQuantity = medicationAmount ?: 0f,
medicationUnit = medicationUnit?.let { MedicationUnit.create(it) }
?: MedicationUnit.UNIT_KG,
HbA1c = HbA1c ?: 0f
)
repository.addNewContext(context)
}
})
setIndicationCallback(recordAccessControlPointCharacteristic)
.with(object : RecordAccessControlPointDataCallback() {
@SuppressLint("SwitchIntDef")
override fun onRecordAccessOperationCompleted(
device: BluetoothDevice,
@RACPOpCode requestCode: Int
) {
val status = when (requestCode) {
RACP_OP_CODE_ABORT_OPERATION -> RequestStatus.ABORTED
else -> RequestStatus.SUCCESS
}
repository.setRequestStatus(status)
}
override fun onRecordAccessOperationCompletedWithNoRecordsFound(
device: BluetoothDevice,
@RACPOpCode requestCode: Int
) {
repository.setRequestStatus(RequestStatus.SUCCESS)
}
override fun onNumberOfRecordsReceived(
device: BluetoothDevice,
numberOfRecords: Int
) {
if (numberOfRecords > 0) {
if (repository.records().isNotEmpty()) {
val sequenceNumber = repository.records().last().sequenceNumber + 1 //TODO check if correct
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportStoredRecordsGreaterThenOrEqualTo(
sequenceNumber
)
)
.enqueue()
} else {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportAllStoredRecords()
)
.enqueue()
}
}
repository.setRequestStatus(RequestStatus.SUCCESS)
}
override fun onRecordAccessOperationError(
device: BluetoothDevice,
@RACPOpCode requestCode: Int,
@RACPErrorCode errorCode: Int
) {
log(Log.WARN, "Record Access operation failed (error $errorCode)")
if (errorCode == RACP_ERROR_OP_CODE_NOT_SUPPORTED) {
repository.setRequestStatus(RequestStatus.NOT_SUPPORTED)
} else {
repository.setRequestStatus(RequestStatus.FAILED)
}
}
})
enableNotifications(glucoseMeasurementCharacteristic).enqueue()
enableNotifications(glucoseMeasurementContextCharacteristic).enqueue()
enableIndications(recordAccessControlPointCharacteristic)
.fail { device: BluetoothDevice?, status: Int ->
log(
Log.WARN,
"Failed to enabled Record Access Control Point indications (error $status)"
setNotificationCallback(glucoseMeasurementCharacteristic).asValidResponseFlow<GlucoseMeasurementResponse>()
.onEach {
val record = GLSRecord(
sequenceNumber = it.sequenceNumber,
time = it.time,
glucoseConcentration = it.glucoseConcentration ?: 0f,
unit = it.unit?.let { ConcentrationUnit.create(it) }
?: ConcentrationUnit.UNIT_KGPL,
type = RecordType.createOrNull(it.type),
sampleLocation = SampleLocation.createOrNull(it.sampleLocation),
status = it.status
)
repository.addNewRecord(record)
}.launchIn(scope)
setNotificationCallback(glucoseMeasurementContextCharacteristic).asValidResponseFlow<GlucoseMeasurementContextResponse>()
.onEach {
val context = MeasurementContext(
sequenceNumber = it.sequenceNumber,
carbohydrate = it.carbohydrate,
carbohydrateAmount = it.carbohydrateAmount ?: 0f,
meal = it.meal,
tester = it.tester,
health = it.health,
exerciseDuration = it.exerciseDuration ?: 0,
exerciseIntensity = it.exerciseIntensity ?: 0,
medication = it.medication,
medicationQuantity = it.medicationAmount ?: 0f,
medicationUnit = it.medicationUnit?.let { MedicationUnit.create(it) }
?: MedicationUnit.UNIT_KG,
HbA1c = it.hbA1c ?: 0f
)
repository.addNewContext(context)
}.launchIn(scope)
setIndicationCallback(recordAccessControlPointCharacteristic).asValidResponseFlow<RecordAccessControlPointResponse>()
.onEach {
if (it.isOperationCompleted && it.wereRecordsFound() && it.numberOfRecords > 0) {
onNumberOfRecordsReceived(it)
} else if(it.isOperationCompleted && it.wereRecordsFound() && it.numberOfRecords == 0) {
onRecordAccessOperationCompletedWithNoRecordsFound(it)
} else if (it.isOperationCompleted && it.wereRecordsFound()) {
onRecordAccessOperationCompleted(it)
} else if (it.errorCode > 0) {
onRecordAccessOperationError(it)
}
}.launchIn(scope)
scope.launch(exceptionHandler) {
enableNotifications(glucoseMeasurementCharacteristic).suspend()
}
scope.launch(exceptionHandler) {
enableNotifications(glucoseMeasurementContextCharacteristic).suspend()
}
scope.launch(exceptionHandler) {
enableIndications(recordAccessControlPointCharacteristic).suspend()
}
}
private fun onRecordAccessOperationCompleted(response: RecordAccessControlPointResponse) {
val status = when (response.requestCode) {
RecordAccessControlPointDataCallback.RACP_OP_CODE_ABORT_OPERATION -> RequestStatus.ABORTED
else -> RequestStatus.SUCCESS
}
repository.setRequestStatus(status)
}
private fun onRecordAccessOperationCompletedWithNoRecordsFound(response: RecordAccessControlPointResponse) {
repository.setRequestStatus(RequestStatus.SUCCESS)
}
private suspend fun onNumberOfRecordsReceived(response: RecordAccessControlPointResponse) {
if (response.numberOfRecords > 0) {
if (repository.records().isNotEmpty()) {
val sequenceNumber = repository.records()
.last().sequenceNumber + 1 //TODO check if correct
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportStoredRecordsGreaterThenOrEqualTo(sequenceNumber),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
} else {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportAllStoredRecords(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
.enqueue()
}
repository.setRequestStatus(RequestStatus.SUCCESS)
}
private fun onRecordAccessOperationError(response: RecordAccessControlPointResponse) {
log(Log.WARN, "Record Access operation failed (error ${response.errorCode})")
if (response.errorCode == RecordAccessControlPointDataCallback.RACP_ERROR_OP_CODE_NOT_SUPPORTED) {
repository.setRequestStatus(RequestStatus.NOT_SUPPORTED)
} else {
repository.setRequestStatus(RequestStatus.FAILED)
}
}
public override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
val service = gatt.getService(GLS_SERVICE_UUID)
if (service != null) {
glucoseMeasurementCharacteristic = service.getCharacteristic(GM_CHARACTERISTIC)
glucoseMeasurementContextCharacteristic = service.getCharacteristic(
GM_CONTEXT_CHARACTERISTIC
)
recordAccessControlPointCharacteristic = service.getCharacteristic(
RACP_CHARACTERISTIC
)
glucoseMeasurementContextCharacteristic = service.getCharacteristic(GM_CONTEXT_CHARACTERISTIC)
recordAccessControlPointCharacteristic = service.getCharacteristic(RACP_CHARACTERISTIC)
}
return glucoseMeasurementCharacteristic != null && recordAccessControlPointCharacteristic != null
}
override fun onServicesInvalidated() { }
override fun onServicesInvalidated() {}
override fun isOptionalServiceSupported(gatt: BluetoothGatt): Boolean {
super.isOptionalServiceSupported(gatt)
@@ -266,9 +206,6 @@ internal class GLSManager @Inject constructor(
}
}
/**
* Clears the records list locally.
*/
private fun clear() {
repository.clearRecords()
val target = bluetoothDevice
@@ -277,112 +214,47 @@ internal class GLSManager @Inject constructor(
}
}
/**
* Sends the request to obtain the last (most recent) record from glucose device. The data will
* be returned to Glucose Measurement characteristic as a notification followed by Record Access
* Control Point indication with status code Success or other in case of error.
*/
fun requestLastRecord() {
if (recordAccessControlPointCharacteristic == null) return
val target = bluetoothDevice ?: return
clear()
repository.setRequestStatus(RequestStatus.PENDING)
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportLastStoredRecord()
).enqueue()
}
/**
* Sends the request to obtain the first (oldest) record from glucose device. The data will be
* returned to Glucose Measurement characteristic as a notification followed by Record Access
* Control Point indication with status code Success or other in case of error.
*/
fun requestFirstRecord() {
if (recordAccessControlPointCharacteristic == null) return
val target = bluetoothDevice ?: return
clear()
repository.setRequestStatus(RequestStatus.PENDING)
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportFirstStoredRecord()
).enqueue()
}
/**
* Sends the request to obtain all records from glucose device. Initially we want to notify user
* about the number of the records so the 'Report Number of Stored Records' is send. The data
* will be returned to Glucose Measurement characteristic as a notification followed by
* Record Access Control Point indication with status code Success or other in case of error.
*/
fun requestAllRecords() {
if (recordAccessControlPointCharacteristic == null) return
val target = bluetoothDevice ?: return
clear()
repository.setRequestStatus(RequestStatus.PENDING)
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportNumberOfAllStoredRecords()
).enqueue()
}
/**
* Sends the request to obtain from the glucose device all records newer than the newest one
* from local storage. The data will be returned to Glucose Measurement characteristic as
* a notification followed by Record Access Control Point indication with status code Success
* or other in case of error.
*
*
* Refresh button will not download records older than the oldest in the local memory.
* E.g. if you have pressed Last and then Refresh, than it will try to get only newer records.
* However if there are no records, it will download all existing (using [.getAllRecords]).
*/
fun refreshRecords() {
if (recordAccessControlPointCharacteristic == null) return
val target = bluetoothDevice ?: return
if (repository.records().isEmpty()) {
requestAllRecords()
} else {
repository.setRequestStatus(RequestStatus.PENDING)
// obtain the last sequence number
val sequenceNumber = repository.records().last().sequenceNumber + 1 //TODO check if correct
scope.launch(exceptionHandler) {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportStoredRecordsGreaterThenOrEqualTo(sequenceNumber)
).enqueue()
// Info:
// Operators OPERATOR_LESS_THEN_OR_EQUAL and OPERATOR_RANGE are not supported by Nordic Semiconductor Glucose Service in SDK 4.4.2.
RecordAccessControlPointData.reportLastStoredRecord(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
/**
* Sends abort operation signal to the device.
*/
fun abort() {
fun requestFirstRecord() {
if (recordAccessControlPointCharacteristic == null) return
val target = bluetoothDevice ?: return
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.abortOperation()
).enqueue()
}
/**
* Sends the request to delete all data from the device. A Record Access Control Point
* indication with status code Success (or other in case of error) will be send.
*/
fun deleteAllRecords() {
if (recordAccessControlPointCharacteristic == null) return
val target = bluetoothDevice ?: return
clear()
repository.setRequestStatus(RequestStatus.PENDING)
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.deleteAllStoredRecords()
).enqueue()
scope.launch(exceptionHandler) {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportFirstStoredRecord(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
val elements = listOf(1, 2, 3)
val result = elements.all { it > 3 }
fun requestAllRecords() {
if (recordAccessControlPointCharacteristic == null) return
clear()
repository.setRequestStatus(RequestStatus.PENDING)
scope.launch(exceptionHandler) {
writeCharacteristic(
recordAccessControlPointCharacteristic,
RecordAccessControlPointData.reportNumberOfAllStoredRecords(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
fun release() {
scope.close()
}
}

View File

@@ -4,7 +4,6 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.hrs.data.HRSRepository
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.service.ForegroundBleService
import javax.inject.Inject

View File

@@ -1,6 +1,5 @@
package no.nordicsemi.android.hrs.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -25,7 +24,6 @@ internal class HRSViewModel @Inject constructor(
) : ViewModel() {
val state = repository.data.combine(repository.status) { data, status ->
Log.d("AAATESTAAA", "data: $data, status: $status")
when (status) {
BleManagerStatus.CONNECTING -> LoadingState
BleManagerStatus.OK,

View File

@@ -7,6 +7,7 @@ dependencies {
implementation project(":lib_utils")
implementation libs.nordic.ble.common
implementation libs.nordic.ble.ktx
implementation libs.nordic.log
implementation libs.nordic.theme

View File

@@ -21,13 +21,18 @@
*/
package no.nordicsemi.android.hts.repository
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import no.nordicsemi.android.ble.common.callback.ht.TemperatureMeasurementDataCallback
import no.nordicsemi.android.ble.common.profile.ht.TemperatureType
import no.nordicsemi.android.ble.common.profile.ht.TemperatureUnit
import android.util.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.common.callback.ht.TemperatureMeasurementResponse
import no.nordicsemi.android.ble.ktx.asValidResponseFlow
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.hts.data.HTSRepository
import no.nordicsemi.android.service.BatteryManager
import java.util.*
@@ -42,22 +47,14 @@ private val HT_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A1C-0000-
*/
internal class HTSManager internal constructor(
context: Context,
private val scope: CoroutineScope,
private val dataHolder: HTSRepository
) : BatteryManager(context) {
private var htCharacteristic: BluetoothGattCharacteristic? = null
private val temperatureMeasurementDataCallback = object : TemperatureMeasurementDataCallback() {
override fun onTemperatureMeasurementReceived(
device: BluetoothDevice,
temperature: Float,
@TemperatureUnit unit: Int,
calendar: Calendar?,
@TemperatureType type: Int?
) {
dataHolder.setNewTemperature(temperature)
}
private val exceptionHandler = CoroutineExceptionHandler { _, t->
Log.e("COROUTINE-EXCEPTION", "Uncaught exception", t)
}
override fun onBatteryLevelChanged(batteryLevel: Int) {
@@ -75,9 +72,16 @@ internal class HTSManager internal constructor(
private inner class HTManagerGattCallback : BatteryManagerGattCallback() {
override fun initialize() {
super.initialize()
setIndicationCallback(htCharacteristic)
.with(temperatureMeasurementDataCallback)
enableIndications(htCharacteristic).enqueue()
.asValidResponseFlow<TemperatureMeasurementResponse>()
.onEach {
dataHolder.setNewTemperature(it.temperature)
}.launchIn(scope)
scope.launch(exceptionHandler) {
enableIndications(htCharacteristic).suspend()
}
}
override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {

View File

@@ -4,10 +4,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.hts.data.HTSRepository
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.service.BleServiceStatus
import no.nordicsemi.android.service.ForegroundBleService
import no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject
@AndroidEntryPoint
@@ -16,7 +13,7 @@ internal class HTSService : ForegroundBleService() {
@Inject
lateinit var repository: HTSRepository
override val manager: HTSManager by lazy { HTSManager(this, repository) }
override val manager: HTSManager by lazy { HTSManager(this, scope, repository) }
override fun onCreate() {
super.onCreate()

View File

@@ -7,6 +7,7 @@ dependencies {
implementation project(":lib_utils")
implementation libs.nordic.ble.common
implementation libs.nordic.ble.ktx
implementation libs.nordic.log
implementation libs.nordic.ui.scanner

View File

@@ -41,8 +41,14 @@ internal class PRXRepository @Inject constructor() {
}
fun invokeCommand(command: PRXCommand) {
_command.tryEmit(command)
_status.tryEmit(BleManagerStatus.DISCONNECTED)
if (command == Disconnect) {
_command.tryEmit(command)
_status.tryEmit(BleManagerStatus.DISCONNECTED)
} else if (_command.subscriptionCount.value > 0) {
_command.tryEmit(command)
} else {
_status.tryEmit(BleManagerStatus.DISCONNECTED)
}
}
fun setNewStatus(status: BleManagerStatus) {

View File

@@ -27,51 +27,43 @@ import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattServer
import android.content.Context
import android.util.Log
import no.nordicsemi.android.ble.callback.FailCallback
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.common.callback.alert.AlertLevelDataCallback
import no.nordicsemi.android.ble.common.data.alert.AlertLevelData
import no.nordicsemi.android.ble.error.GattError
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.prx.data.PRXRepository
import no.nordicsemi.android.service.BatteryManager
import java.util.*
/** Link Loss service UUID. */
val LINK_LOSS_SERVICE_UUID = UUID.fromString("00001803-0000-1000-8000-00805f9b34fb")
/** Immediate Alert service UUID. */
val PRX_SERVICE_UUID = UUID.fromString("00001802-0000-1000-8000-00805f9b34fb")
/** Alert Level characteristic UUID. */
val ALERT_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002A06-0000-1000-8000-00805f9b34fb")
internal class PRXManager(
context: Context,
private val scope: CoroutineScope,
private val dataHolder: PRXRepository
) : BatteryManager(context) {
// Client characteristics.
private var alertLevelCharacteristic: BluetoothGattCharacteristic? = null
private var linkLossCharacteristic: BluetoothGattCharacteristic? = null
// Server characteristics.
private var localAlertLevelCharacteristic: BluetoothGattCharacteristic? = null
private var linkLossServerCharacteristic: BluetoothGattCharacteristic? = null
/**
* Returns true if the alert has been enabled on the proximity tag, false otherwise.
*/
/** A flag indicating whether the alarm on the connected proximity tag has been activated. */
private val exceptionHandler = CoroutineExceptionHandler { _, t->
Log.e("COROUTINE-EXCEPTION", "Uncaught exception", t)
}
var isAlertEnabled = false
private set
/**
* BluetoothGatt callbacks for connection/disconnection, service discovery,
* receiving indication, etc.
*/
private inner class ProximityManagerGattCallback : BatteryManagerGattCallback() {
override fun initialize() {
super.initialize()
// This callback will be called whenever local Alert Level char is written
// by a connected proximity tag.
setWriteCallback(localAlertLevelCharacteristic)
.with(object : AlertLevelDataCallback() {
override fun onAlertLevelChanged(device: BluetoothDevice, level: Int) {
@@ -86,21 +78,13 @@ internal class PRXManager(
}
})
// After connection, set the Link Loss behaviour on the tag.
writeCharacteristic(linkLossCharacteristic, AlertLevelData.highAlert(), BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
.done { device: BluetoothDevice? ->
log(
Log.INFO,
"Link loss alert level set"
)
}
.fail { device: BluetoothDevice?, status: Int ->
log(
Log.WARN,
"Failed to set link loss level: $status"
)
}
.enqueue()
scope.launch(exceptionHandler) {
writeCharacteristic(
linkLossCharacteristic,
AlertLevelData.highAlert(),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
).suspend()
}
}
override fun onServerReady(server: BluetoothGattServer) {
@@ -141,48 +125,22 @@ internal class PRXManager(
linkLossCharacteristic = null
localAlertLevelCharacteristic = null
linkLossServerCharacteristic = null
// Reset the alert flag
isAlertEnabled = false
}
}
/**
* Toggles the immediate alert on the target device.
*/
fun toggleImmediateAlert() {
writeImmediateAlert(!isAlertEnabled)
}
/**
* Writes the HIGH ALERT or NO ALERT command to the target device.
*
* @param on true to enable the alarm on proximity tag, false to disable it.
*/
fun writeImmediateAlert(on: Boolean) {
if (!isConnected()) return
writeCharacteristic(
alertLevelCharacteristic,
if (on) AlertLevelData.highAlert() else AlertLevelData.noAlert()
)
.before { device: BluetoothDevice? ->
log(
Log.VERBOSE,
if (on) "Setting alarm to HIGH..." else "Disabling alarm..."
)
}
.done { device: BluetoothDevice? ->
isAlertEnabled = on
dataHolder.setRemoteAlarmLevel(on)
}
.fail { device: BluetoothDevice?, status: Int ->
log(
Log.WARN,
if (status == FailCallback.REASON_NULL_ATTRIBUTE) "Alert Level characteristic not found" else GattError.parse(
status
)
)
}
.enqueue()
if (!isConnected) return
scope.launch(exceptionHandler) {
writeCharacteristic(
alertLevelCharacteristic,
if (on) AlertLevelData.highAlert() else AlertLevelData.noAlert(),
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
).suspend()
isAlertEnabled = on
dataHolder.setRemoteAlarmLevel(on)
}
}
override fun onBatteryLevelChanged(batteryLevel: Int) {

View File

@@ -1,5 +1,6 @@
package no.nordicsemi.android.prx.repository
import android.util.Log
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -22,7 +23,7 @@ internal class PRXService : ForegroundBleService() {
private var serverManager: ProximityServerManager = ProximityServerManager(this)
override val manager: PRXManager by lazy {
PRXManager(this, repository).apply {
PRXManager(this, scope, repository).apply {
useServer(serverManager)
}
}

View File

@@ -7,6 +7,7 @@ dependencies {
implementation project(":lib_utils")
implementation libs.nordic.ble.common
implementation libs.nordic.ble.ktx
implementation libs.nordic.log
implementation libs.nordic.ui.scanner

View File

@@ -26,10 +26,9 @@ internal data class RSCSData(
return "$instantaneousCadence RPM"
}
fun displayNumberOfSteps(): String {
fun displayNumberOfSteps(): String? {
if (totalDistance == null || strideLength == null) {
return "NONE"
return null
}
val numberOfSteps = totalDistance/strideLength
return "Number of Steps $numberOfSteps"

View File

@@ -21,40 +21,35 @@
*/
package no.nordicsemi.android.rscs.repository
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import no.nordicsemi.android.ble.common.callback.rsc.RunningSpeedAndCadenceMeasurementDataCallback
import android.util.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.common.callback.rsc.RunningSpeedAndCadenceMeasurementResponse
import no.nordicsemi.android.ble.ktx.asValidResponseFlow
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.rscs.data.RSCSRepository
import no.nordicsemi.android.service.BatteryManager
import java.util.*
/** Running Speed and Cadence Measurement service UUID */
val RSCS_SERVICE_UUID: UUID = UUID.fromString("00001814-0000-1000-8000-00805F9B34FB")
/** Running Speed and Cadence Measurement characteristic UUID */
private val RSC_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A53-0000-1000-8000-00805F9B34FB")
internal class RSCSManager internal constructor(
context: Context,
private val scope: CoroutineScope,
private val dataHolder: RSCSRepository
) : BatteryManager(context) {
private var rscMeasurementCharacteristic: BluetoothGattCharacteristic? = null
private val callback = object : RunningSpeedAndCadenceMeasurementDataCallback() {
override fun onRSCMeasurementReceived(
device: BluetoothDevice,
running: Boolean,
instantaneousSpeed: Float,
instantaneousCadence: Int,
strideLength: Int?,
totalDistance: Long?
) {
dataHolder.setNewData(running, instantaneousSpeed, instantaneousCadence, strideLength, totalDistance)
}
private val exceptionHandler = CoroutineExceptionHandler { _, t->
Log.e("COROUTINE-EXCEPTION", "Uncaught exception", t)
}
override fun onBatteryLevelChanged(batteryLevel: Int) {
@@ -65,17 +60,18 @@ internal class RSCSManager internal constructor(
return RSCManagerGattCallback()
}
/**
* BluetoothGatt callbacks for connection/disconnection, service discovery,
* receiving indication, etc.
*/
private inner class RSCManagerGattCallback : BatteryManagerGattCallback() {
override fun initialize() {
super.initialize()
setNotificationCallback(rscMeasurementCharacteristic)
.with(callback)
enableNotifications(rscMeasurementCharacteristic).enqueue()
setNotificationCallback(rscMeasurementCharacteristic).asValidResponseFlow<RunningSpeedAndCadenceMeasurementResponse>()
.onEach {
dataHolder.setNewData(it.isRunning, it.instantaneousSpeed, it.instantaneousCadence, it.strideLength, it.totalDistance)
}.launchIn(scope)
scope.launch(exceptionHandler) {
enableNotifications(rscMeasurementCharacteristic).suspend()
}
}
public override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {

View File

@@ -4,10 +4,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.rscs.data.RSCSRepository
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.service.BleServiceStatus
import no.nordicsemi.android.service.ForegroundBleService
import no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject
@AndroidEntryPoint
@@ -16,7 +13,7 @@ internal class RSCSService : ForegroundBleService() {
@Inject
lateinit var repository: RSCSRepository
override val manager: RSCSManager by lazy { RSCSManager(this, repository) }
override val manager: RSCSManager by lazy { RSCSManager(this, scope, repository) }
override fun onCreate() {
super.onCreate()

View File

@@ -26,10 +26,9 @@ internal fun SensorsReadingView(state: RSCSData) {
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.rscs_cadence), state.displayCadence())
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(
stringResource(id = R.string.rscs_number_of_steps),
state.displayNumberOfSteps()
)
state.displayNumberOfSteps()?.let {
KeyValueField(stringResource(id = R.string.rscs_number_of_steps), it)
}
}
}

View File

@@ -7,6 +7,7 @@ dependencies {
implementation project(":lib_utils")
implementation libs.nordic.ble.common
implementation libs.nordic.ble.ktx
implementation libs.nordic.log
implementation libs.nordic.theme

View File

@@ -26,51 +26,53 @@ import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattService
import android.content.Context
import android.text.TextUtils
import android.util.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.ble.WriteRequest
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.ble.ktx.asFlow
import no.nordicsemi.android.ble.ktx.suspend
import no.nordicsemi.android.service.BatteryManager
import no.nordicsemi.android.uart.data.UARTRepository
import no.nordicsemi.android.utils.EMPTY
import java.util.*
/** Nordic UART Service UUID */
val UART_SERVICE_UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
/** RX characteristic UUID */
private val UART_RX_CHARACTERISTIC_UUID = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
/** TX characteristic UUID */
private val UART_TX_CHARACTERISTIC_UUID = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E")
internal class UARTManager(context: Context, private val dataHolder: UARTRepository) : BatteryManager(context) {
internal class UARTManager(
context: Context,
private val scope: CoroutineScope,
private val dataHolder: UARTRepository
) : BatteryManager(context) {
private var rxCharacteristic: BluetoothGattCharacteristic? = null
private var txCharacteristic: BluetoothGattCharacteristic? = null
/**
* A flag indicating whether Long Write can be used. It's set to false if the UART RX
* characteristic has only PROPERTY_WRITE_NO_RESPONSE property and no PROPERTY_WRITE.
* If you set it to false here, it will never use Long Write.
*
* TODO change this flag if you don't want to use Long Write even with Write Request.
*/
private val exceptionHandler = CoroutineExceptionHandler { _, t->
Log.e("COROUTINE-EXCEPTION", "Uncaught exception", t)
}
private var useLongWrite = true
/**
* BluetoothGatt callbacks for connection/disconnection, service discovery,
* receiving indication, etc.
*/
private inner class UARTManagerGattCallback : BatteryManagerGattCallback() {
override fun initialize() {
setNotificationCallback(txCharacteristic)
.with { device, data ->
val text: String = data.getStringValue(0) ?: String.EMPTY
log(LogContract.Log.Level.APPLICATION, "\"$text\" received")
dataHolder.emitNewMessage(text)
}
requestMtu(260).enqueue()
enableNotifications(txCharacteristic).enqueue()
setNotificationCallback(txCharacteristic).asFlow().onEach {
val text: String = it.getStringValue(0) ?: String.EMPTY
dataHolder.emitNewMessage(text)
}
scope.launch(exceptionHandler) {
requestMtu(260).suspend()
}
scope.launch(exceptionHandler) {
enableNotifications(txCharacteristic).suspend()
}
}
override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
@@ -113,21 +115,15 @@ internal class UARTManager(context: Context, private val dataHolder: UARTReposit
}
fun send(text: String) {
// Are we connected?
if (rxCharacteristic == null) return
if (!TextUtils.isEmpty(text)) {
val request: WriteRequest = writeCharacteristic(rxCharacteristic, text.toByteArray())
.with { device, data ->
log(
LogContract.Log.Level.APPLICATION,
"\"" + data.getStringValue(0).toString() + "\" sent"
)
scope.launch(exceptionHandler) {
val request: WriteRequest = writeCharacteristic(rxCharacteristic, text.toByteArray(), BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
if (!useLongWrite) {
request.split()
}
if (!useLongWrite) {
// This will automatically split the long data into MTU-3-byte long packets.
request.split()
request.enqueue()
}
request.enqueue()
}
}

View File

@@ -3,7 +3,6 @@ package no.nordicsemi.android.uart.repository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.service.BleManagerStatus
import no.nordicsemi.android.service.ForegroundBleService
import no.nordicsemi.android.uart.data.DisconnectCommand
import no.nordicsemi.android.uart.data.SendTextCommand
@@ -17,7 +16,7 @@ internal class UARTService : ForegroundBleService() {
@Inject
lateinit var repository: UARTRepository
override val manager: UARTManager by lazy { UARTManager(this, repository) }
override val manager: UARTManager by lazy { UARTManager(this, scope, repository) }
override fun onCreate() {
super.onCreate()

View File

@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -24,6 +25,7 @@ internal fun UARTAddMacroDialog(onDismiss: () -> Unit, onEvent: (UARTViewEvent)
AlertDialog(
onDismissRequest = onDismiss,
containerColor = MaterialTheme.colorScheme.background,
title = {
Text(text = stringResource(id = R.string.uart_macro_dialog_title))
},

View File

@@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow
@@ -15,6 +17,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.material.you.Card
@@ -26,13 +29,14 @@ internal fun MacroItem(macro: UARTMacro, onEvent: (UARTViewEvent) -> Unit) {
Card(backgroundColor = MaterialTheme.colorScheme.primaryContainer) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp)
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = stringResource(id = R.string.uart_run_macro_description),
modifier = Modifier
.size(40.dp)
.size(70.dp)
.padding(8.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { onEvent(OnRunMacro(macro)) }
)
@@ -56,7 +60,10 @@ internal fun MacroItem(macro: UARTMacro, onEvent: (UARTViewEvent) -> Unit) {
imageVector = Icons.Default.Delete,
contentDescription = stringResource(id = R.string.uart_delete_macro_description),
modifier = Modifier
.size(32.dp)
.padding(8.dp)
.padding(end = 8.dp)
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { onEvent(OnDeleteMacro(macro)) }
)
}

View File

@@ -14,6 +14,7 @@ dependencyResolutionManagement {
alias('nordic-navigation').to('no.nordicsemi.android.common:navigation:1.0.0')
alias('nordic-theme').to('no.nordicsemi.android.common:theme:1.0.0')
alias('nordic-ble-common').to('no.nordicsemi.android:ble-common:2.3.1')
alias('nordic-ble-ktx').to('no.nordicsemi.android:ble-ktx:2.3.1')
alias('nordic-log').to('no.nordicsemi.android:log:2.3.0')
alias('nordic-scanner').to('no.nordicsemi.android.support.v18:scanner:1.6.0')
alias('nordic-ui-scanner').to('no.nordicsemi.android.common:scanner:1.0.0')
@@ -91,3 +92,7 @@ include ':lib_utils'
if (file('../Android-Common-Libraries').exists()) {
includeBuild('../Android-Common-Libraries')
}
if (file('../Android-BLE-Library').exists()) {
includeBuild('../Android-BLE-Library')
}