mirror of
https://github.com/aljazceru/Android-nRF-Toolbox.git
synced 2026-01-06 08:14:24 +01:00
Migrate to ble ktx
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,5 +100,6 @@ internal class BPSViewModel @Inject constructor(
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
repository.clear()
|
||||
bpsManager.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,5 +109,6 @@ internal class GLSViewModel @Inject constructor(
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
repository.clear()
|
||||
glsManager.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
|
||||
@@ -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)) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user