diff --git a/app/build.gradle b/app/build.gradle index 1bfdfe72..07ea47bb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -52,6 +52,7 @@ dependencies { //https://github.com/google/dagger/issues/2123 implementation project(':profile_bps') implementation project(':profile_csc') + implementation project(':profile_cgms') implementation project(':profile_gls') implementation project(':profile_hrs') implementation project(':profile_hts') @@ -62,6 +63,7 @@ dependencies { implementation project(':lib_permission') implementation project(":lib_theme") implementation project(":lib_utils") + implementation project(":lib_service") implementation libs.nordic.ble.common diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/HomeScreen.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/HomeScreen.kt index a67c99ca..d9b16ad7 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/HomeScreen.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/HomeScreen.kt @@ -6,6 +6,8 @@ import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -26,10 +28,12 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import no.nordicsemi.android.bps.view.BPSScreen +import no.nordicsemi.android.cgms.view.CGMScreen import no.nordicsemi.android.csc.view.CSCScreen import no.nordicsemi.android.gls.view.GLSScreen import no.nordicsemi.android.hrs.view.HRSScreen import no.nordicsemi.android.hts.view.HTSScreen +import no.nordicsemi.android.permission.bonding.view.BondingScreen import no.nordicsemi.android.permission.view.BluetoothNotAvailableScreen import no.nordicsemi.android.permission.view.BluetoothNotEnabledScreen import no.nordicsemi.android.permission.view.RequestPermissionScreen @@ -59,6 +63,7 @@ internal fun HomeScreen() { composable(NavDestination.BPS.id) { BPSScreen { viewModel.navigateUp() } } composable(NavDestination.PRX.id) { PRXScreen { viewModel.navigateUp() } } composable(NavDestination.RSCS.id) { RSCSScreen { viewModel.navigateUp() } } + composable(NavDestination.CGMS.id) { CGMScreen { viewModel.navigateUp() } } composable(NavDestination.REQUEST_PERMISSION.id) { RequestPermissionScreen(continueAction) } composable(NavDestination.BLUETOOTH_NOT_AVAILABLE.id) { BluetoothNotAvailableScreen{ viewModel.finish() } } composable(NavDestination.BLUETOOTH_NOT_ENABLED.id) { @@ -75,6 +80,7 @@ internal fun HomeScreen() { }.exhaustive } } + composable(NavDestination.BONDING.id) { BondingScreen(continueAction) } } LaunchedEffect(state) { @@ -90,19 +96,23 @@ fun HomeView(callback: (NavDestination) -> Unit) { (context as? Activity)?.finish() } - FeatureButton(R.drawable.ic_csc, R.string.csc_module) { callback(NavDestination.CSC) } - Spacer(modifier = Modifier.height(1.dp)) - FeatureButton(R.drawable.ic_hrs, R.string.hrs_module) { callback(NavDestination.HRS) } - Spacer(modifier = Modifier.height(1.dp)) - FeatureButton(R.drawable.ic_gls, R.string.gls_module) { callback(NavDestination.GLS) } - Spacer(modifier = Modifier.height(1.dp)) - FeatureButton(R.drawable.ic_hts, R.string.hts_module) { callback(NavDestination.HTS) } - Spacer(modifier = Modifier.height(1.dp)) - FeatureButton(R.drawable.ic_bps, R.string.bps_module) { callback(NavDestination.BPS) } - Spacer(modifier = Modifier.height(1.dp)) - FeatureButton(R.drawable.ic_rscs, R.string.rscs_module) { callback(NavDestination.RSCS) } - Spacer(modifier = Modifier.height(1.dp)) - FeatureButton(R.drawable.ic_proximity, R.string.prx_module) { callback(NavDestination.PRX) } + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + FeatureButton(R.drawable.ic_csc, R.string.csc_module) { callback(NavDestination.CSC) } + Spacer(modifier = Modifier.height(1.dp)) + FeatureButton(R.drawable.ic_hrs, R.string.hrs_module) { callback(NavDestination.HRS) } + Spacer(modifier = Modifier.height(1.dp)) + FeatureButton(R.drawable.ic_gls, R.string.gls_module) { callback(NavDestination.GLS) } + Spacer(modifier = Modifier.height(1.dp)) + FeatureButton(R.drawable.ic_hts, R.string.hts_module) { callback(NavDestination.HTS) } + Spacer(modifier = Modifier.height(1.dp)) + FeatureButton(R.drawable.ic_bps, R.string.bps_module) { callback(NavDestination.BPS) } + Spacer(modifier = Modifier.height(1.dp)) + FeatureButton(R.drawable.ic_rscs, R.string.rscs_module) { callback(NavDestination.RSCS) } + Spacer(modifier = Modifier.height(1.dp)) + FeatureButton(R.drawable.ic_prx, R.string.prx_module) { callback(NavDestination.PRX) } + Spacer(modifier = Modifier.height(1.dp)) + FeatureButton(R.drawable.ic_cgm, R.string.cgm_module) { callback(NavDestination.CGMS) } + } } } diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavDestination.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavDestination.kt index fdcfecd1..dd510234 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavDestination.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavDestination.kt @@ -2,17 +2,19 @@ package no.nordicsemi.android.nrftoolbox const val ARGS_KEY = "args" -enum class NavDestination(val id: String) { - HOME("home-screen"), - CSC("csc-screen"), - HRS("hrs-screen"), - HTS("hts-screen"), - GLS("gls-screen"), - BPS("bps-screen"), - PRX("prx-screen"), - RSCS("rscs-screen"), - REQUEST_PERMISSION("request-permission"), - BLUETOOTH_NOT_AVAILABLE("bluetooth-not-available"), - BLUETOOTH_NOT_ENABLED("bluetooth-not-enabled"), - DEVICE_NOT_CONNECTED("device-not-connected/{$ARGS_KEY}"); +enum class NavDestination(val id: String, val pairingRequired: Boolean) { + HOME("home-screen", false), + CSC("csc-screen", false), + HRS("hrs-screen", false), + HTS("hts-screen", false), + GLS("gls-screen", true), + BPS("bps-screen", false), + PRX("prx-screen", true), + RSCS("rscs-screen", false), + CGMS("cgms-screen", false), + REQUEST_PERMISSION("request-permission", false), + BLUETOOTH_NOT_AVAILABLE("bluetooth-not-available", false), + BLUETOOTH_NOT_ENABLED("bluetooth-not-enabled", false), + DEVICE_NOT_CONNECTED("device-not-connected/{$ARGS_KEY}", false), + BONDING("bonding", false); } diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavigationViewModel.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavigationViewModel.kt index a929352d..80f81f4e 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavigationViewModel.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavigationViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import no.nordicsemi.android.bps.repository.BPS_SERVICE_UUID +import no.nordicsemi.android.cgms.repository.CGMS_UUID import no.nordicsemi.android.csc.service.CYCLING_SPEED_AND_CADENCE_SERVICE_UUID import no.nordicsemi.android.gls.repository.GLS_SERVICE_UUID import no.nordicsemi.android.hrs.service.HR_SERVICE_UUID @@ -49,7 +50,13 @@ class NavigationViewModel @Inject constructor( } else when (bleScanner.getBluetoothStatus()) { ScannerStatus.NOT_AVAILABLE -> BluetoothPermissionState.BLUETOOTH_NOT_AVAILABLE ScannerStatus.DISABLED -> BluetoothPermissionState.BLUETOOTH_NOT_ENABLED - ScannerStatus.ENABLED -> selectedDevice.device?.let { BluetoothPermissionState.READY } ?: BluetoothPermissionState.DEVICE_NOT_CONNECTED + ScannerStatus.ENABLED -> selectedDevice.device?.let { + if (targetDestination.pairingRequired && selectedDevice.isBondingRequired()) { + BluetoothPermissionState.BONDING_REQUIRED + } else { + BluetoothPermissionState.READY + } + } ?: BluetoothPermissionState.DEVICE_NOT_CONNECTED } } @@ -59,6 +66,7 @@ class NavigationViewModel @Inject constructor( BluetoothPermissionState.BLUETOOTH_NOT_AVAILABLE -> NavDestination.BLUETOOTH_NOT_AVAILABLE BluetoothPermissionState.BLUETOOTH_NOT_ENABLED -> NavDestination.BLUETOOTH_NOT_ENABLED BluetoothPermissionState.DEVICE_NOT_CONNECTED -> NavDestination.DEVICE_NOT_CONNECTED + BluetoothPermissionState.BONDING_REQUIRED -> NavDestination.BONDING BluetoothPermissionState.READY -> targetDestination } @@ -79,10 +87,12 @@ class NavigationViewModel @Inject constructor( NavDestination.BPS -> BPS_SERVICE_UUID.toString() NavDestination.RSCS -> RSCS_SERVICE_UUID.toString() NavDestination.PRX -> IMMEDIATE_ALERT_SERVICE_UUID.toString() + NavDestination.CGMS -> CGMS_UUID.toString() NavDestination.HOME, NavDestination.REQUEST_PERMISSION, NavDestination.BLUETOOTH_NOT_AVAILABLE, NavDestination.BLUETOOTH_NOT_ENABLED, + NavDestination.BONDING, NavDestination.DEVICE_NOT_CONNECTED -> throw IllegalArgumentException("There is no serivce related to the destination: $destination") } } diff --git a/app/src/main/res/drawable/ic_cgm.xml b/app/src/main/res/drawable/ic_cgm.xml new file mode 100644 index 00000000..028f8b82 --- /dev/null +++ b/app/src/main/res/drawable/ic_cgm.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_proximity.xml b/app/src/main/res/drawable/ic_prx.xml similarity index 100% rename from app/src/main/res/drawable/ic_proximity.xml rename to app/src/main/res/drawable/ic_prx.xml diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fa104f0b..f959b1c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,4 +6,5 @@ BPS RSCS PRX + CGMS \ No newline at end of file diff --git a/lib_permission/src/main/java/no/nordicsemi/android/permission/HiltModule.kt b/lib_permission/src/main/java/no/nordicsemi/android/permission/HiltModule.kt index f7c54249..d850d0cb 100644 --- a/lib_permission/src/main/java/no/nordicsemi/android/permission/HiltModule.kt +++ b/lib_permission/src/main/java/no/nordicsemi/android/permission/HiltModule.kt @@ -7,6 +7,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import no.nordicsemi.android.permission.bonding.repository.BondingStateObserver import no.nordicsemi.android.permission.tools.PermissionHelper import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder import javax.inject.Singleton @@ -31,4 +32,10 @@ internal object HiltModule { fun createPermissionHelper(@ApplicationContext context: Context): PermissionHelper { return PermissionHelper(context) } + + @Singleton + @Provides + fun createBondingStateObserver(@ApplicationContext context: Context): BondingStateObserver { + return BondingStateObserver(context) + } } diff --git a/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/repository/BondingStateObserver.kt b/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/repository/BondingStateObserver.kt new file mode 100644 index 00000000..22a00dd9 --- /dev/null +++ b/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/repository/BondingStateObserver.kt @@ -0,0 +1,57 @@ +package no.nordicsemi.android.permission.bonding.repository + +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.util.Log +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import no.nordicsemi.android.service.BondingState + +class BondingStateObserver(private val context: Context) { + + val events: MutableSharedFlow = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + fun startObserving() { + context.applicationContext.registerReceiver( + broadcastReceiver, + IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED) + ) + } + + fun stopObserving() { + context.applicationContext.unregisterReceiver(broadcastReceiver) + } + + private val broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + with(intent) { + if (action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) { + val device = getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + val previousBondState = getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, -1) + val bondState = getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1) + val bondTransition = "${previousBondState.toBondStateDescription()} to " + bondState.toBondStateDescription() + Log.w("Bond state change", "${device?.address} bond state changed | $bondTransition") + events.tryEmit(BondingStateChangeEvent(device, bondState)) + } + } + } + + private fun Int.toBondStateDescription() = when(this) { + BluetoothDevice.BOND_BONDED -> "BONDED" + BluetoothDevice.BOND_BONDING -> "BONDING" + BluetoothDevice.BOND_NONE -> "NOT BONDED" + else -> "ERROR: $this" + } + } +} + +data class BondingStateChangeEvent(val device: BluetoothDevice?, private val bondStateValue: Int) { + + val bondState = BondingState.create(bondStateValue) +} diff --git a/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/view/BondingErrorView.kt b/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/view/BondingErrorView.kt new file mode 100644 index 00000000..a3f72000 --- /dev/null +++ b/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/view/BondingErrorView.kt @@ -0,0 +1,32 @@ +package no.nordicsemi.android.permission.bonding.view + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.permission.R +import no.nordicsemi.android.theme.view.ScreenSection + +@Composable +internal fun BondingErrorView() { + ScreenSection { + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Text( + text = stringResource(id = R.string.bonding_error), + textAlign = TextAlign.Center + ) + } + } +} + +@Preview +@Composable +private fun BondingErrorViewPreview() { + BondingErrorView() +} diff --git a/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/view/BondingInProgressView.kt b/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/view/BondingInProgressView.kt new file mode 100644 index 00000000..e4c96e2f --- /dev/null +++ b/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/view/BondingInProgressView.kt @@ -0,0 +1,32 @@ +package no.nordicsemi.android.permission.bonding.view + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.permission.R +import no.nordicsemi.android.theme.view.ScreenSection + +@Composable +internal fun BondingInProgressView() { + ScreenSection { + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Text( + text = stringResource(id = R.string.bonding_in_progress), + textAlign = TextAlign.Center + ) + } + } +} + +@Preview +@Composable +private fun BondingInProgressViewPreview() { + BondingInProgressView() +} diff --git a/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/view/BondingScreen.kt b/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/view/BondingScreen.kt new file mode 100644 index 00000000..949e1a39 --- /dev/null +++ b/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/view/BondingScreen.kt @@ -0,0 +1,25 @@ +package no.nordicsemi.android.permission.bonding.view + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.hilt.navigation.compose.hiltViewModel +import no.nordicsemi.android.permission.bonding.viewmodel.BondingViewModel +import no.nordicsemi.android.service.BondingState +import no.nordicsemi.android.utils.exhaustive + +@Composable +fun BondingScreen(finishAction: () -> Unit) { + val viewModel: BondingViewModel = hiltViewModel() + val state = viewModel.state.collectAsState().value + + LaunchedEffect("start") { + viewModel.bondDevice() + } + + when (state) { + BondingState.BONDING -> BondingInProgressView() + BondingState.BONDED -> finishAction() + BondingState.NONE -> BondingErrorView() + }.exhaustive +} diff --git a/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/view/BondingSuccessView.kt b/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/view/BondingSuccessView.kt new file mode 100644 index 00000000..fe5d3f7b --- /dev/null +++ b/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/view/BondingSuccessView.kt @@ -0,0 +1,32 @@ +package no.nordicsemi.android.permission.bonding + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.permission.R +import no.nordicsemi.android.theme.view.ScreenSection + +@Composable +internal fun BondingSuccessView() { + ScreenSection { + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Text( + text = stringResource(id = R.string.bonding_success), + textAlign = TextAlign.Center + ) + } + } +} + +@Preview +@Composable +private fun BondingSuccessViewPreview() { + BondingSuccessView() +} diff --git a/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/viewmodel/BondingViewModel.kt b/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/viewmodel/BondingViewModel.kt new file mode 100644 index 00000000..cf022372 --- /dev/null +++ b/lib_permission/src/main/java/no/nordicsemi/android/permission/bonding/viewmodel/BondingViewModel.kt @@ -0,0 +1,43 @@ +package no.nordicsemi.android.permission.bonding.viewmodel + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import no.nordicsemi.android.permission.bonding.repository.BondingStateObserver +import no.nordicsemi.android.service.BondingState +import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder +import no.nordicsemi.android.theme.viewmodel.CloseableViewModel +import javax.inject.Inject + +@HiltViewModel +class BondingViewModel @Inject constructor( + private val deviceHolder: SelectedBluetoothDeviceHolder, + private val bondingStateObserver: BondingStateObserver +) : CloseableViewModel() { + + val state = MutableStateFlow(deviceHolder.getBondingState()) + + init { + bondingStateObserver.events.onEach { event -> + event.device?.let { + if (it == deviceHolder.device) { + state.tryEmit(event.bondState) + } else { + state.tryEmit(BondingState.NONE) + } + } ?: state.tryEmit(event.bondState) + }.launchIn(viewModelScope) + bondingStateObserver.startObserving() + } + + fun bondDevice() { + deviceHolder.bondDevice() + } + + override fun onCleared() { + super.onCleared() + bondingStateObserver.stopObserving() + } +} diff --git a/lib_permission/src/main/java/no/nordicsemi/android/permission/viewmodel/BluetoothPermissionState.kt b/lib_permission/src/main/java/no/nordicsemi/android/permission/viewmodel/BluetoothPermissionState.kt index 9444f576..a0f6b205 100644 --- a/lib_permission/src/main/java/no/nordicsemi/android/permission/viewmodel/BluetoothPermissionState.kt +++ b/lib_permission/src/main/java/no/nordicsemi/android/permission/viewmodel/BluetoothPermissionState.kt @@ -5,5 +5,6 @@ enum class BluetoothPermissionState { BLUETOOTH_NOT_AVAILABLE, BLUETOOTH_NOT_ENABLED, DEVICE_NOT_CONNECTED, + BONDING_REQUIRED, READY } diff --git a/lib_permission/src/main/res/values/strings.xml b/lib_permission/src/main/res/values/strings.xml index a12e9a62..8f942818 100644 --- a/lib_permission/src/main/res/values/strings.xml +++ b/lib_permission/src/main/res/values/strings.xml @@ -23,4 +23,8 @@ Bluetooth not enabled. To enable Bluetooth please open settings. Open settings + + Bonding in progress. Please follow instruction on the screen. + Bonding success. Please wait for the redirect to chosen profile screen. + We cannot get data from the peripheral without bonding. Please bond the device. diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/SelectedBluetoothDeviceHolder.kt b/lib_service/src/main/java/no/nordicsemi/android/service/SelectedBluetoothDeviceHolder.kt index fb97f2b1..d05078fe 100644 --- a/lib_service/src/main/java/no/nordicsemi/android/service/SelectedBluetoothDeviceHolder.kt +++ b/lib_service/src/main/java/no/nordicsemi/android/service/SelectedBluetoothDeviceHolder.kt @@ -10,6 +10,15 @@ class SelectedBluetoothDeviceHolder { fun isBondingRequired(): Boolean { return device?.bondState == BluetoothDevice.BOND_NONE } + + fun getBondingState(): BondingState { + return when (device?.bondState) { + BluetoothDevice.BOND_BONDED -> BondingState.BONDED + BluetoothDevice.BOND_BONDING -> BondingState.BONDING + else -> BondingState.NONE + } + } + fun bondDevice() { device?.createBond() } @@ -22,3 +31,18 @@ class SelectedBluetoothDeviceHolder { device = null } } + +enum class BondingState { + NONE, BONDING, BONDED; + + companion object { + fun create(value: Int): BondingState { + return when (value) { + BluetoothDevice.BOND_BONDED -> BONDED + BluetoothDevice.BOND_BONDING -> BONDING + BluetoothDevice.BOND_NONE -> NONE + else -> throw IllegalArgumentException("Cannot create BondingState for the value: $value") + } + } + } +} diff --git a/profile_cgms/build.gradle b/profile_cgms/build.gradle new file mode 100644 index 00000000..d397c91b --- /dev/null +++ b/profile_cgms/build.gradle @@ -0,0 +1,26 @@ +apply from: rootProject.file("library.gradle") +apply plugin: 'kotlin-parcelize' + +dependencies { + implementation project(":lib_service") + implementation project(":lib_theme") + implementation project(":lib_utils") + + implementation libs.nordic.ble.common + + implementation libs.nordic.log + + implementation libs.bundles.compose + implementation libs.androidx.core + implementation libs.material + implementation libs.lifecycle.activity + implementation libs.lifecycle.service + implementation libs.compose.lifecycle + implementation libs.compose.activity + + testImplementation libs.test.junit + androidTestImplementation libs.android.test.junit + androidTestImplementation libs.android.test.espresso + androidTestImplementation libs.android.test.compose.ui + debugImplementation libs.android.test.compose.tooling +} diff --git a/profile_cgms/src/androidTest/java/no/nordicsemi/android/cgms/ExampleInstrumentedTest.kt b/profile_cgms/src/androidTest/java/no/nordicsemi/android/cgms/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..5febd881 --- /dev/null +++ b/profile_cgms/src/androidTest/java/no/nordicsemi/android/cgms/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package no.nordicsemi.android.cgms + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("no.nordicsemi.android.cgms.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/profile_cgms/src/main/AndroidManifest.xml b/profile_cgms/src/main/AndroidManifest.xml new file mode 100644 index 00000000..6a415b28 --- /dev/null +++ b/profile_cgms/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMDataHolder.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMDataHolder.kt new file mode 100644 index 00000000..cfcf4e3c --- /dev/null +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMDataHolder.kt @@ -0,0 +1,21 @@ +package no.nordicsemi.android.cgms.data + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class CGMDataHolder @Inject constructor() { + + private val _data = MutableStateFlow(Idle) + val data: StateFlow = _data + + fun emitNewEvent(event: CGMEvent) { + _data.tryEmit(event) + } + + fun clear() { + _data.tryEmit(Idle) + } +} diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMEvent.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMEvent.kt new file mode 100644 index 00000000..80c199ac --- /dev/null +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMEvent.kt @@ -0,0 +1,21 @@ +package no.nordicsemi.android.cgms.data + +internal sealed class CGMEvent + +internal object Idle : CGMEvent() + +internal data class OnCGMValueReceived(val record: CGMRecord) : CGMEvent() + +internal object OnOperationStarted : CGMEvent() + +internal object OnOperationCompleted : CGMEvent() + +internal object OnOperationFailed : CGMEvent() + +internal object OnOperationAborted : CGMEvent() + +internal object OnOperationNotSupported : CGMEvent() + +internal object OnDataSetCleared : CGMEvent() + +internal data class OnNumberOfRecordsRequested(val value: Int) : CGMEvent() diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMRecord.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMRecord.kt new file mode 100644 index 00000000..ecf97197 --- /dev/null +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/data/CGMRecord.kt @@ -0,0 +1,11 @@ +package no.nordicsemi.android.cgms.data + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +internal data class CGMRecord( + var sequenceNumber: Int, + var glucoseConcentration: Float, + var timestamp: Long +) : Parcelable diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMManager.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMManager.kt new file mode 100644 index 00000000..a915ab82 --- /dev/null +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMManager.kt @@ -0,0 +1,566 @@ +/* + * Copyright (c) 2016, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +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 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.CGMDataHolder +import no.nordicsemi.android.cgms.data.CGMRecord +import no.nordicsemi.android.cgms.data.OnCGMValueReceived +import no.nordicsemi.android.cgms.data.OnDataSetCleared +import no.nordicsemi.android.cgms.data.OnNumberOfRecordsRequested +import no.nordicsemi.android.cgms.data.OnOperationAborted +import no.nordicsemi.android.cgms.data.OnOperationCompleted +import no.nordicsemi.android.cgms.data.OnOperationFailed +import no.nordicsemi.android.cgms.data.OnOperationNotSupported +import no.nordicsemi.android.cgms.data.OnOperationStarted +import no.nordicsemi.android.log.LogContract +import no.nordicsemi.android.service.BatteryManager +import java.util.* + +/** Cycling Speed and Cadence service UUID. */ +val CGMS_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") + +/** Record Access Control Point characteristic UUID. */ +private val RACP_UUID = UUID.fromString("00002A52-0000-1000-8000-00805f9b34fb") + +internal class CGMManager( + context: Context, + private val dataHolder: CGMDataHolder +) : BatteryManager(context) { + + private var cgmStatusCharacteristic: BluetoothGattCharacteristic? = null + private var cgmFeatureCharacteristic: BluetoothGattCharacteristic? = null + private var cgmMeasurementCharacteristic: BluetoothGattCharacteristic? = null + private var cgmSpecificOpsControlPointCharacteristic: BluetoothGattCharacteristic? = null + private var recordAccessControlPointCharacteristic: BluetoothGattCharacteristic? = null + private val records: SparseArray = SparseArray() + + /** 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 + override fun onBatteryLevelChanged(batteryLevel: Int) { + TODO("Not yet implemented") + } + + override fun getGattCallback(): BatteryManagerGattCallback { + 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" + ) + } + .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 onDataReceived(device: BluetoothDevice, data: Data) { + log( + LogContract.Log.Level.APPLICATION, + "\"" + CGMMeasurementParser.parse(data).toString() + "\" received" + ) + super.onDataReceived(device, data) + } + + 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) + dataHolder.emitNewEvent(OnCGMValueReceived(record)) + } + + override fun onContinuousGlucoseMeasurementReceivedWithCrcError( + device: BluetoothDevice, + data: Data + ) { + log( + Log.WARN, + "Continuous Glucose Measurement record received with CRC error" + ) + } + }) + setIndicationCallback(cgmSpecificOpsControlPointCharacteristic) + .with(object : CGMSpecificOpsControlPointDataCallback() { + override fun onDataReceived(device: BluetoothDevice, data: Data) { + log( + LogContract.Log.Level.APPLICATION, + "\"" + CGMSpecificOpsControlPointParser.parse(data) + .toString() + "\" received" + ) + super.onDataReceived(device, data) + } + + @SuppressLint("SwitchIntDef") + override fun onCGMSpecificOpsOperationCompleted( + device: BluetoothDevice, + @CGMSpecificOpsControlPointCallback.CGMOpCode requestCode: Int, + secured: Boolean + ) { + when (requestCode) { + CGMSpecificOpsControlPointCallback.CGM_OP_CODE_START_SESSION -> sessionStartTime = + System.currentTimeMillis() + 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. + } + sessionStartTime = 0 + } + CGMSpecificOpsControlPointCallback.CGM_OP_CODE_STOP_SESSION -> sessionStartTime = + 0 + } + } + + override fun onCGMSpecificOpsResponseReceivedWithCrcError( + device: BluetoothDevice, + data: Data + ) { + log(Log.ERROR, "Request failed: CRC error") + } + }) + setIndicationCallback(recordAccessControlPointCharacteristic) + .with(object : RecordAccessControlPointDataCallback() { + override fun onDataReceived(device: BluetoothDevice, data: Data) { + log( + LogContract.Log.Level.APPLICATION, + "\"" + RecordAccessControlPointParser.parse(data) + .toString() + "\" received" + ) + super.onDataReceived(device, data) + } + + @SuppressLint("SwitchIntDef") + override fun onRecordAccessOperationCompleted( + device: BluetoothDevice, + @RecordAccessControlPointCallback.RACPOpCode requestCode: Int + ) { + when (requestCode) { + RecordAccessControlPointCallback.RACP_OP_CODE_ABORT_OPERATION -> dataHolder.emitNewEvent( + OnOperationAborted + ) + else -> { + recordAccessRequestInProgress = false + dataHolder.emitNewEvent(OnOperationCompleted) + } + } + } + + override fun onRecordAccessOperationCompletedWithNoRecordsFound( + device: BluetoothDevice, + @RecordAccessControlPointCallback.RACPOpCode requestCode: Int + ) { + recordAccessRequestInProgress = false + dataHolder.emitNewEvent(OnOperationCompleted) + } + + override fun onNumberOfRecordsReceived( + device: BluetoothDevice, + numberOfRecords: Int + ) { + dataHolder.emitNewEvent(OnNumberOfRecordsRequested(numberOfRecords)) + 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 + dataHolder.emitNewEvent(OnOperationCompleted) + } + } + + 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) { + dataHolder.emitNewEvent(OnOperationNotSupported) + } else { + dataHolder.emitNewEvent(OnOperationFailed) + } + } + }) + + // 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) + ) + .with { _: BluetoothDevice, data: Data -> + log( + LogContract.Log.Level.APPLICATION, + "\"" + CGMSpecificOpsControlPointParser.parse(data) + "\" sent" + ) + } + .fail { _: BluetoothDevice?, status: Int -> + log( + LogContract.Log.Level.ERROR, + "Failed to start session (error $status)" + ) + } + .enqueue() + } + } + + override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean { + val service = gatt.getService(CGMS_UUID) + if (service != null) { + cgmStatusCharacteristic = service.getCharacteristic(CGM_STATUS_UUID) + cgmFeatureCharacteristic = service.getCharacteristic(CGM_FEATURE_UUID) + cgmMeasurementCharacteristic = service.getCharacteristic(CGM_MEASUREMENT_UUID) + cgmSpecificOpsControlPointCharacteristic = service.getCharacteristic( + CGM_OPS_CONTROL_POINT_UUID + ) + recordAccessControlPointCharacteristic = service.getCharacteristic(RACP_UUID) + } + return cgmMeasurementCharacteristic != null && cgmSpecificOpsControlPointCharacteristic != null && recordAccessControlPointCharacteristic != null + } + + override fun onServicesInvalidated() { } + + override fun onDeviceDisconnected() { + super.onDeviceDisconnected() + cgmStatusCharacteristic = null + cgmFeatureCharacteristic = null + cgmMeasurementCharacteristic = null + cgmSpecificOpsControlPointCharacteristic = null + recordAccessControlPointCharacteristic = null + } + } + + /** + * Returns a list of CGM records obtained from this device. The key in the array is the + */ + fun getRecords(): SparseArray { + return records + } + + /** + * Clears the records list locally + */ + fun clear() { + records.clear() + dataHolder.emitNewEvent(OnDataSetCleared) + } + + /** + * 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. + */ + val lastRecord: Unit + get() { + if (recordAccessControlPointCharacteristic == null) return + clear() + dataHolder.emitNewEvent(OnOperationStarted) + recordAccessRequestInProgress = true + writeCharacteristic( + recordAccessControlPointCharacteristic, + RecordAccessControlPointData.reportLastStoredRecord() + ) + .with { device: BluetoothDevice, data: Data -> + log( + LogContract.Log.Level.APPLICATION, + "\"" + RecordAccessControlPointParser.parse(data) + "\" sent" + ) + } + .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. + */ + val firstRecord: Unit + get() { + if (recordAccessControlPointCharacteristic == null) return + clear() + dataHolder.emitNewEvent(OnOperationStarted) + recordAccessRequestInProgress = true + writeCharacteristic( + recordAccessControlPointCharacteristic, + RecordAccessControlPointData.reportFirstStoredRecord() + ) + .with { _: BluetoothDevice, data: Data -> + log( + LogContract.Log.Level.APPLICATION, + "\"" + RecordAccessControlPointParser.parse(data) + "\" sent" + ) + } + .enqueue() + } + + /** + * Sends abort operation signal to the device. + */ + fun abort() { + if (recordAccessControlPointCharacteristic == null) return + writeCharacteristic( + recordAccessControlPointCharacteristic, + RecordAccessControlPointData.abortOperation() + ) + .with { _: BluetoothDevice, data: Data -> + log( + LogContract.Log.Level.APPLICATION, + "\"" + RecordAccessControlPointParser.parse(data) + "\" sent" + ) + } + .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. + */ + val allRecords: Unit + get() { + if (recordAccessControlPointCharacteristic == null) return + clear() + dataHolder.emitNewEvent(OnOperationStarted) + recordAccessRequestInProgress = true + writeCharacteristic( + recordAccessControlPointCharacteristic, + RecordAccessControlPointData.reportNumberOfAllStoredRecords() + ) + .with { _: BluetoothDevice, data: Data -> + log( + LogContract.Log.Level.APPLICATION, + "\"" + RecordAccessControlPointParser.parse(data) + "\" sent" + ) + } + .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) { + allRecords + } else { + dataHolder.emitNewEvent(OnOperationStarted) + + // Obtain the last sequence number + val sequenceNumber = records.keyAt(records.size() - 1) + 1 + recordAccessRequestInProgress = true + writeCharacteristic( + recordAccessControlPointCharacteristic, + RecordAccessControlPointData.reportStoredRecordsGreaterThenOrEqualTo(sequenceNumber) + ) + .with { _: BluetoothDevice, data: Data -> + log( + LogContract.Log.Level.APPLICATION, + "\"" + RecordAccessControlPointParser.parse(data) + "\" sent" + ) + } + .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 + } + } + + /** + * 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() + dataHolder.emitNewEvent(OnOperationStarted) + writeCharacteristic( + recordAccessControlPointCharacteristic, + RecordAccessControlPointData.deleteAllStoredRecords() + ) + .with { _: BluetoothDevice, data: Data -> + log( + LogContract.Log.Level.APPLICATION, + "\"" + RecordAccessControlPointParser.parse(data) + "\" sent" + ) + } + .enqueue() + } +} diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMMeasurementParser.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMMeasurementParser.kt new file mode 100644 index 00000000..10c22ed5 --- /dev/null +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMMeasurementParser.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2015, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package no.nordicsemi.android.cgms.repository + +import no.nordicsemi.android.ble.data.Data +import java.util.* + +object CGMMeasurementParser { + + private const val FLAGS_CGM_TREND_INFO_PRESENT = 1 + private const val FLAGS_CGM_QUALITY_PRESENT = 1 shl 1 + private const val FLAGS_SENSOR_STATUS_ANNUNCIATION_WARNING_OCTET_PRESENT = 1 shl 2 + private const val FLAGS_SENSOR_STATUS_ANNUNCIATION_CAL_TEMP_OCTET_PRESENT = 1 shl 3 + private const val FLAGS_SENSOR_STATUS_ANNUNCIATION_STATUS_OCTET_PRESENT = 1 shl 4 + private const val SSA_SESSION_STOPPED = 1 + private const val SSA_DEVICE_BATTERY_LOW = 1 shl 1 + private const val SSA_SENSOR_TYPE_INCORRECT = 1 shl 2 + private const val SSA_SENSOR_MALFUNCTION = 1 shl 3 + private const val SSA_DEVICE_SPEC_ALERT = 1 shl 4 + private const val SSA_GENERAL_DEVICE_FAULT = 1 shl 5 + private const val SSA_TIME_SYNC_REQUIRED = 1 shl 8 + private const val SSA_CALIBRATION_NOT_ALLOWED = 1 shl 9 + private const val SSA_CALIBRATION_RECOMMENDED = 1 shl 10 + private const val SSA_CALIBRATION_REQUIRED = 1 shl 11 + private const val SSA_SENSOR_TEMP_TOO_HIGH = 1 shl 12 + private const val SSA_SENSOR_TEMP_TOO_LOW = 1 shl 13 + private const val SSA_RESULT_LOWER_THAN_PATIENT_LOW_LEVEL = 1 shl 16 + private const val SSA_RESULT_HIGHER_THAN_PATIENT_HIGH_LEVEL = 1 shl 17 + private const val SSA_RESULT_LOWER_THAN_HYPO_LEVEL = 1 shl 18 + private const val SSA_RESULT_HIGHER_THAN_HYPER_LEVEL = 1 shl 19 + private const val SSA_SENSOR_RATE_OF_DECREASE_EXCEEDED = 1 shl 20 + private const val SSA_SENSOR_RATE_OF_INCREASE_EXCEEDED = 1 shl 21 + private const val SSA_RESULT_LOWER_THAN_DEVICE_CAN_PROCESS = 1 shl 22 + private const val SSA_RESULT_HIGHER_THAN_DEVICE_CAN_PROCESS = 1 shl 23 + + fun parse(data: Data): String { + // The CGM Measurement characteristic is a variable length structure containing one or more CGM Measurement records + val totalSize = data.value!!.size + val builder = StringBuilder() + var offset = 0 + while (offset < totalSize) { + offset += parseRecord(builder, data, offset) + if (offset < totalSize) builder.append("\n\n") + } + return builder.toString() + } + + private fun parseRecord(builder: StringBuilder, data: Data, offset: Int): Int { + // Read size and flags bytes + var offset = offset + val size = data.getIntValue(Data.FORMAT_UINT8, offset++)!! + val flags = data.getIntValue(Data.FORMAT_UINT8, offset++)!! + + /* + * false CGM Trend Information is not preset + * true CGM Trend Information is preset + */ + val cgmTrendInformationPresent = flags and FLAGS_CGM_TREND_INFO_PRESENT > 0 + + /* + * false CGM Quality is not preset + * true CGM Quality is preset + */ + val cgmQualityPresent = flags and FLAGS_CGM_QUALITY_PRESENT > 0 + + /* + * false Sensor Status Annunciation - Warning-Octet is not preset + * true Sensor Status Annunciation - Warning-Octet is preset + */ + val ssaWarningOctetPresent = + flags and FLAGS_SENSOR_STATUS_ANNUNCIATION_WARNING_OCTET_PRESENT > 0 + + /* + * false Sensor Status Annunciation - Calibration/Temp-Octet is not preset + * true Sensor Status Annunciation - Calibration/Temp-Octet is preset + */ + val ssaCalTempOctetPresent = + flags and FLAGS_SENSOR_STATUS_ANNUNCIATION_CAL_TEMP_OCTET_PRESENT > 0 + + /* + * false Sensor Status Annunciation - Status-Octet is not preset + * true Sensor Status Annunciation - Status-Octet is preset + */ + val ssaStatusOctetPresent = + flags and FLAGS_SENSOR_STATUS_ANNUNCIATION_STATUS_OCTET_PRESENT > 0 + + // Read CGM Glucose Concentration + val glucoseConcentration = data.getFloatValue(Data.FORMAT_SFLOAT, offset)!! + offset += 2 + + // Read time offset + val timeOffset = data.getIntValue(Data.FORMAT_UINT16, offset)!! + offset += 2 + builder.append("Glucose concentration: ").append(glucoseConcentration).append(" mg/dL\n") + builder.append("Sequence number: ").append(timeOffset).append(" (Time Offset in min)\n") + if (ssaWarningOctetPresent) { + val ssaWarningOctet = data.getIntValue(Data.FORMAT_UINT8, offset++)!! + builder.append("Warnings:\n") + if (ssaWarningOctet and SSA_SESSION_STOPPED > 0) builder.append("- Session Stopped\n") + if (ssaWarningOctet and SSA_DEVICE_BATTERY_LOW > 0) builder.append("- Device Battery Low\n") + if (ssaWarningOctet and SSA_SENSOR_TYPE_INCORRECT > 0) builder.append("- Sensor Type Incorrect\n") + if (ssaWarningOctet and SSA_SENSOR_MALFUNCTION > 0) builder.append("- Sensor Malfunction\n") + if (ssaWarningOctet and SSA_DEVICE_SPEC_ALERT > 0) builder.append("- Device Specific Alert\n") + if (ssaWarningOctet and SSA_GENERAL_DEVICE_FAULT > 0) builder.append("- General Device Fault\n") + } + if (ssaCalTempOctetPresent) { + val ssaCalTempOctet = data.getIntValue(Data.FORMAT_UINT8, offset++)!! + builder.append("Cal/Temp Info:\n") + if (ssaCalTempOctet and SSA_TIME_SYNC_REQUIRED > 0) builder.append("- Time Synchronization Required\n") + if (ssaCalTempOctet and SSA_CALIBRATION_NOT_ALLOWED > 0) builder.append("- Calibration Not Allowed\n") + if (ssaCalTempOctet and SSA_CALIBRATION_RECOMMENDED > 0) builder.append("- Calibration Recommended\n") + if (ssaCalTempOctet and SSA_CALIBRATION_REQUIRED > 0) builder.append("- Calibration Required\n") + if (ssaCalTempOctet and SSA_SENSOR_TEMP_TOO_HIGH > 0) builder.append("- Sensor Temp Too High\n") + if (ssaCalTempOctet and SSA_SENSOR_TEMP_TOO_LOW > 0) builder.append("- Sensor Temp Too Low\n") + } + if (ssaStatusOctetPresent) { + val ssaStatusOctet = data.getIntValue(Data.FORMAT_UINT8, offset++)!! + builder.append("Status:\n") + if (ssaStatusOctet and SSA_RESULT_LOWER_THAN_PATIENT_LOW_LEVEL > 0) builder.append("- Result Lower then Patient Low Level\n") + if (ssaStatusOctet and SSA_RESULT_HIGHER_THAN_PATIENT_HIGH_LEVEL > 0) builder.append("- Result Higher then Patient High Level\n") + if (ssaStatusOctet and SSA_RESULT_LOWER_THAN_HYPO_LEVEL > 0) builder.append("- Result Lower then Hypo Level\n") + if (ssaStatusOctet and SSA_RESULT_HIGHER_THAN_HYPER_LEVEL > 0) builder.append("- Result Higher then Hyper Level\n") + if (ssaStatusOctet and SSA_SENSOR_RATE_OF_DECREASE_EXCEEDED > 0) builder.append("- Sensor Rate of Decrease Exceeded\n") + if (ssaStatusOctet and SSA_SENSOR_RATE_OF_INCREASE_EXCEEDED > 0) builder.append("- Sensor Rate of Increase Exceeded\n") + if (ssaStatusOctet and SSA_RESULT_LOWER_THAN_DEVICE_CAN_PROCESS > 0) builder.append("- Result Lower then Device Can Process\n") + if (ssaStatusOctet and SSA_RESULT_HIGHER_THAN_DEVICE_CAN_PROCESS > 0) builder.append("- Result Higher then Device Can Process\n") + } + if (cgmTrendInformationPresent) { + val trend = data.getFloatValue(Data.FORMAT_SFLOAT, offset)!! + offset += 2 + builder.append("Trend: ").append(trend).append(" mg/dL/min\n") + } + if (cgmQualityPresent) { + val quality = data.getFloatValue(Data.FORMAT_SFLOAT, offset)!! + offset += 2 + builder.append("Quality: ").append(quality).append("%\n") + } + if (size > offset + 1) { + val crc = data.getIntValue(Data.FORMAT_UINT16, offset)!! + // offset += 2; + builder.append(String.format(Locale.US, "E2E-CRC: 0x%04X\n", crc)) + } + builder.setLength(builder.length - 1) // Remove last \n + return size + } +} diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMService.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMService.kt new file mode 100644 index 00000000..fefc381d --- /dev/null +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMService.kt @@ -0,0 +1,15 @@ +package no.nordicsemi.android.cgms.repository + +import dagger.hilt.android.AndroidEntryPoint +import no.nordicsemi.android.cgms.data.CGMDataHolder +import no.nordicsemi.android.service.ForegroundBleService +import javax.inject.Inject + +@AndroidEntryPoint +internal class CGMService : ForegroundBleService() { + + @Inject + lateinit var dataHolder: CGMDataHolder + + override val manager: CGMManager by lazy { CGMManager(this, dataHolder) } +} diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMSpecificOpsControlPointParser.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMSpecificOpsControlPointParser.kt new file mode 100644 index 00000000..9f579328 --- /dev/null +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/CGMSpecificOpsControlPointParser.kt @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2015, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package no.nordicsemi.android.cgms.repository + +import no.nordicsemi.android.ble.data.Data + +object CGMSpecificOpsControlPointParser { + + private const val OP_SET_CGM_COMMUNICATION_INTERVAL = 1 + private const val OP_GET_CGM_COMMUNICATION_INTERVAL = 2 + private const val OP_CGM_COMMUNICATION_INTERVAL_RESPONSE = 3 + private const val OP_SET_GLUCOSE_CALIBRATION_VALUE = 4 + private const val OP_GET_GLUCOSE_CALIBRATION_VALUE = 5 + private const val OP_GLUCOSE_CALIBRATION_VALUE_RESPONSE = 6 + private const val OP_SET_PATIENT_HIGH_ALERT_LEVEL = 7 + private const val OP_GET_PATIENT_HIGH_ALERT_LEVEL = 8 + private const val OP_PATIENT_HIGH_ALERT_LEVEL_RESPONSE = 9 + private const val OP_SET_PATIENT_LOW_ALERT_LEVEL = 10 + private const val OP_GET_PATIENT_LOW_ALERT_LEVEL = 11 + private const val OP_PATIENT_LOW_ALERT_LEVEL_RESPONSE = 12 + private const val OP_SET_HYPO_ALERT_LEVEL = 13 + private const val OP_GET_HYPO_ALERT_LEVEL = 14 + private const val OP_HYPO_ALERT_LEVEL_RESPONSE = 15 + private const val OP_SET_HYPER_ALERT_LEVEL = 16 + private const val OP_GET_HYPER_ALERT_LEVEL = 17 + private const val OP_HYPER_ALERT_LEVEL_RESPONSE = 18 + private const val OP_SET_RATE_OF_DECREASE_ALERT_LEVEL = 19 + private const val OP_GET_RATE_OF_DECREASE_ALERT_LEVEL = 20 + private const val OP_RATE_OF_DECREASE_ALERT_LEVEL_RESPONSE = 21 + private const val OP_SET_RATE_OF_INCREASE_ALERT_LEVEL = 22 + private const val OP_GET_RATE_OF_INCREASE_ALERT_LEVEL = 23 + private const val OP_RATE_OF_INCREASE_ALERT_LEVEL_RESPONSE = 24 + private const val OP_RESET_DEVICE_SPECIFIC_ALERT = 25 + private const val OP_CODE_START_SESSION = 26 + private const val OP_CODE_STOP_SESSION = 27 + private const val OP_CODE_RESPONSE_CODE = 28 + + // TODO this parser does not support E2E-CRC! + fun parse(data: Data): String { + var offset = 0 + val opCode = data.getIntValue(Data.FORMAT_UINT8, offset++)!! + val builder = StringBuilder() + builder.append(parseOpCode(opCode)) + when (opCode) { + OP_SET_CGM_COMMUNICATION_INTERVAL, OP_CGM_COMMUNICATION_INTERVAL_RESPONSE -> { + val interval = data.getIntValue(Data.FORMAT_UINT8, offset)!! + builder.append(" to ").append(interval).append(" min") + } + OP_SET_GLUCOSE_CALIBRATION_VALUE -> { + val calConcentration = data.getFloatValue(Data.FORMAT_SFLOAT, offset)!! + offset += 2 + val calTime = data.getIntValue(Data.FORMAT_UINT16, offset)!! + offset += 2 + val calTypeSampleLocation = data.getIntValue(Data.FORMAT_UINT8, offset++)!! + val calType = calTypeSampleLocation and 0x0F + val calSampleLocation = calTypeSampleLocation and 0xF0 shr 4 + val calNextCalibrationTime = data.getIntValue(Data.FORMAT_UINT16, offset)!! + // offset += 2; + // final int calCalibrationDataRecordNumber = data.getIntValue(Data.FORMAT_UINT16, offset); + // offset += 2; + // final int calStatus = data.getIntValue(Data.FORMAT_UINT8, offset++); + builder.append(" to:\n") + builder.append("Glucose Concentration of Calibration: ").append(calConcentration) + .append(" mg/dL\n") + builder.append("Time: ").append(calTime).append(" min\n") + builder.append("Type: ").append(parseType(calType)).append("\n") + builder.append("Sample Location: ").append(parseSampleLocation(calSampleLocation)) + .append("\n") + builder.append("Next Calibration Time: ") + .append(parseNextCalibrationTime(calNextCalibrationTime)) + .append(" min\n") // field ignored on Set + } + OP_GET_GLUCOSE_CALIBRATION_VALUE -> { + val calibrationRecordNumber = data.getIntValue(Data.FORMAT_UINT16, offset)!! + builder.append(": ").append(parseRecordNumber(calibrationRecordNumber)) + } + OP_GLUCOSE_CALIBRATION_VALUE_RESPONSE -> { + val calConcentration = data.getFloatValue(Data.FORMAT_SFLOAT, offset)!! + offset += 2 + val calTime = data.getIntValue(Data.FORMAT_UINT16, offset)!! + offset += 2 + val calTypeSampleLocation = data.getIntValue(Data.FORMAT_UINT8, offset++)!! + val calType = calTypeSampleLocation and 0x0F + val calSampleLocation = calTypeSampleLocation and 0xF0 shr 4 + val calNextCalibrationTime = data.getIntValue(Data.FORMAT_UINT16, offset)!! + offset += 2 + val calCalibrationDataRecordNumber = data.getIntValue(Data.FORMAT_UINT16, offset)!! + offset += 2 + val calStatus = data.getIntValue(Data.FORMAT_UINT8, offset)!! + builder.append(":\n") + if (calCalibrationDataRecordNumber > 0) { + builder.append("Glucose Concentration of Calibration: ") + .append(calConcentration).append(" mg/dL\n") + builder.append("Time: ").append(calTime).append(" min\n") + builder.append("Type: ").append(parseType(calType)).append("\n") + builder.append("Sample Location: ") + .append(parseSampleLocation(calSampleLocation)).append("\n") + builder.append("Next Calibration Time: ") + .append(parseNextCalibrationTime(calNextCalibrationTime)).append("\n") + builder.append("Data Record Number: ").append(calCalibrationDataRecordNumber) + parseStatus(builder, calStatus) + } else { + builder.append("No Calibration Data Stored") + } + } + OP_SET_PATIENT_HIGH_ALERT_LEVEL, OP_SET_PATIENT_LOW_ALERT_LEVEL, OP_SET_HYPO_ALERT_LEVEL, OP_SET_HYPER_ALERT_LEVEL -> { + val level = data.getFloatValue(Data.FORMAT_SFLOAT, offset)!! + builder.append(" to: ").append(level).append(" mg/dL") + } + OP_PATIENT_HIGH_ALERT_LEVEL_RESPONSE, OP_PATIENT_LOW_ALERT_LEVEL_RESPONSE, OP_HYPO_ALERT_LEVEL_RESPONSE, OP_HYPER_ALERT_LEVEL_RESPONSE -> { + val level = data.getFloatValue(Data.FORMAT_SFLOAT, offset)!! + builder.append(": ").append(level).append(" mg/dL") + } + OP_SET_RATE_OF_DECREASE_ALERT_LEVEL, OP_SET_RATE_OF_INCREASE_ALERT_LEVEL -> { + val level = data.getFloatValue(Data.FORMAT_SFLOAT, offset)!! + builder.append(" to: ").append(level).append(" mg/dL/min") + } + OP_RATE_OF_DECREASE_ALERT_LEVEL_RESPONSE, OP_RATE_OF_INCREASE_ALERT_LEVEL_RESPONSE -> { + val level = data.getFloatValue(Data.FORMAT_SFLOAT, offset)!! + builder.append(": ").append(level).append(" mg/dL/min") + } + OP_CODE_RESPONSE_CODE -> { + val requestOpCode = data.getIntValue(Data.FORMAT_UINT8, offset++)!! + val responseCode = data.getIntValue(Data.FORMAT_UINT8, offset++)!! + builder.append(" to ").append(parseOpCode(requestOpCode)).append(": ").append( + parseResponseCode(responseCode) + ) + } + } + return builder.toString() + } + + private fun parseOpCode(code: Int): String { + return when (code) { + OP_SET_CGM_COMMUNICATION_INTERVAL -> "Set CGM Communication Interval" + OP_GET_CGM_COMMUNICATION_INTERVAL -> "Get CGM Communication Interval" + OP_CGM_COMMUNICATION_INTERVAL_RESPONSE -> "CGM Communication Interval" + OP_SET_GLUCOSE_CALIBRATION_VALUE -> "Set CGM Calibration Value" + OP_GET_GLUCOSE_CALIBRATION_VALUE -> "Get CGM Calibration Value" + OP_GLUCOSE_CALIBRATION_VALUE_RESPONSE -> "CGM Calibration Value" + OP_SET_PATIENT_HIGH_ALERT_LEVEL -> "Set Patient High Alert Level" + OP_GET_PATIENT_HIGH_ALERT_LEVEL -> "Get Patient High Alert Level" + OP_PATIENT_HIGH_ALERT_LEVEL_RESPONSE -> "Patient High Alert Level" + OP_SET_PATIENT_LOW_ALERT_LEVEL -> "Set Patient Low Alert Level" + OP_GET_PATIENT_LOW_ALERT_LEVEL -> "Get Patient Low Alert Level" + OP_PATIENT_LOW_ALERT_LEVEL_RESPONSE -> "Patient Low Alert Level" + OP_SET_HYPO_ALERT_LEVEL -> "Set Hypo Alert Level" + OP_GET_HYPO_ALERT_LEVEL -> "Get Hypo Alert Level" + OP_HYPO_ALERT_LEVEL_RESPONSE -> "Hypo Alert Level" + OP_SET_HYPER_ALERT_LEVEL -> "Set Hyper Alert Level" + OP_GET_HYPER_ALERT_LEVEL -> "Get Hyper Alert Level" + OP_HYPER_ALERT_LEVEL_RESPONSE -> "Hyper Alert Level" + OP_SET_RATE_OF_DECREASE_ALERT_LEVEL -> "Set Rate of Decrease Alert Level" + OP_GET_RATE_OF_DECREASE_ALERT_LEVEL -> "Get Rate of Decrease Alert Level" + OP_RATE_OF_DECREASE_ALERT_LEVEL_RESPONSE -> "Rate of Decrease Alert Level" + OP_SET_RATE_OF_INCREASE_ALERT_LEVEL -> "Set Rate of Increase Alert Level" + OP_GET_RATE_OF_INCREASE_ALERT_LEVEL -> "Get Rate of Increase Alert Level" + OP_RATE_OF_INCREASE_ALERT_LEVEL_RESPONSE -> "Rate of Increase Alert Level" + OP_RESET_DEVICE_SPECIFIC_ALERT -> "Reset Device Specific Alert" + OP_CODE_START_SESSION -> "Start Session" + OP_CODE_STOP_SESSION -> "Stop Session" + OP_CODE_RESPONSE_CODE -> "Response" + else -> "Reserved for future use ($code)" + } + } + + private fun parseResponseCode(code: Int): String { + return when (code) { + 1 -> "Success" + 2 -> "Op Code not supported" + 3 -> "Invalid Operand" + 4 -> "Procedure not completed" + 5 -> "Parameter out of range" + else -> "Reserved for future use ($code)" + } + } + + private fun parseType(type: Int): String { + return when (type) { + 1 -> "Capillary Whole blood" + 2 -> "Capillary Plasma" + 3 -> "Capillary Whole blood" + 4 -> "Venous Plasma" + 5 -> "Arterial Whole blood" + 6 -> "Arterial Plasma" + 7 -> "Undetermined Whole blood" + 8 -> "Undetermined Plasma" + 9 -> "Interstitial Fluid (ISF)" + 10 -> "Control Solution" + else -> "Reserved for future use ($type)" + } + } + + private fun parseSampleLocation(location: Int): String { + return when (location) { + 1 -> "Finger" + 2 -> "Alternate Site Test (AST)" + 3 -> "Earlobe" + 4 -> "Control solution" + 5 -> "Subcutaneous tissue" + 15 -> "Sample Location value not available" + else -> "Reserved for future use ($location)" + } + } + + private fun parseNextCalibrationTime(time: Int): String { + return if (time == 0) "Calibration Required Instantly" else "$time min" + } + + private fun parseRecordNumber(time: Int): String { + return if (time == 0xFFFF) "Last Calibration Data" else time.toString() + } + + private fun parseStatus(builder: StringBuilder, status: Int) { + if (status == 0) return + builder.append("\nStatus:\n") + if (status and 1 > 0) builder.append("- Calibration Data rejected") + if (status and 2 > 0) builder.append("- Calibration Data out of range") + if (status and 4 > 0) builder.append("- Calibration Process pending") + if (status and 0xF8 > 0) builder.append("- Reserved for future use (").append(status) + .append(")") + } +} \ No newline at end of file diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/RecordAccessControlPointParser.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/RecordAccessControlPointParser.kt new file mode 100644 index 00000000..8237115f --- /dev/null +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/repository/RecordAccessControlPointParser.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2015, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package no.nordicsemi.android.cgms.repository + +import no.nordicsemi.android.ble.data.Data + +object RecordAccessControlPointParser { + + private const val OP_CODE_REPORT_STORED_RECORDS = 1 + private const val OP_CODE_DELETE_STORED_RECORDS = 2 + private const val OP_CODE_ABORT_OPERATION = 3 + private const val OP_CODE_REPORT_NUMBER_OF_RECORDS = 4 + private const val OP_CODE_NUMBER_OF_STORED_RECORDS_RESPONSE = 5 + private const val OP_CODE_RESPONSE_CODE = 6 + private const val OPERATOR_NULL = 0 + private const val OPERATOR_ALL_RECORDS = 1 + private const val OPERATOR_LESS_THEN_OR_EQUAL = 2 + private const val OPERATOR_GREATER_THEN_OR_EQUAL = 3 + private const val OPERATOR_WITHING_RANGE = 4 + private const val OPERATOR_FIRST_RECORD = 5 + private const val OPERATOR_LAST_RECORD = 6 + private const val RESPONSE_SUCCESS = 1 + private const val RESPONSE_OP_CODE_NOT_SUPPORTED = 2 + private const val RESPONSE_INVALID_OPERATOR = 3 + private const val RESPONSE_OPERATOR_NOT_SUPPORTED = 4 + private const val RESPONSE_INVALID_OPERAND = 5 + private const val RESPONSE_NO_RECORDS_FOUND = 6 + private const val RESPONSE_ABORT_UNSUCCESSFUL = 7 + private const val RESPONSE_PROCEDURE_NOT_COMPLETED = 8 + private const val RESPONSE_OPERAND_NOT_SUPPORTED = 9 + + fun parse(data: Data): String { + val builder = StringBuilder() + val opCode = data.getIntValue(Data.FORMAT_UINT8, 0)!! + val operator = data.getIntValue(Data.FORMAT_UINT8, 1)!! + when (opCode) { + OP_CODE_REPORT_STORED_RECORDS, OP_CODE_DELETE_STORED_RECORDS, OP_CODE_ABORT_OPERATION, OP_CODE_REPORT_NUMBER_OF_RECORDS -> builder.append( + getOpCode(opCode) + ).append("\n") + OP_CODE_NUMBER_OF_STORED_RECORDS_RESPONSE -> { + builder.append(getOpCode(opCode)).append(": ") + val value = data.getIntValue(Data.FORMAT_UINT16, 2)!! + builder.append(value).append("\n") + } + OP_CODE_RESPONSE_CODE -> { + builder.append(getOpCode(opCode)).append(" for ") + val targetOpCode = data.getIntValue(Data.FORMAT_UINT8, 2)!! + builder.append(getOpCode(targetOpCode)).append(": ") + val status = data.getIntValue(Data.FORMAT_UINT8, 3)!! + builder.append(getStatus(status)).append("\n") + } + } + when (operator) { + OPERATOR_ALL_RECORDS, OPERATOR_FIRST_RECORD, OPERATOR_LAST_RECORD -> builder.append("Operator: ") + .append( + getOperator(operator) + ).append("\n") + OPERATOR_GREATER_THEN_OR_EQUAL, OPERATOR_LESS_THEN_OR_EQUAL -> { + val filter = data.getIntValue(Data.FORMAT_UINT8, 2)!! + val value = data.getIntValue(Data.FORMAT_UINT16, 3)!! + builder.append("Operator: ").append(getOperator(operator)).append(" ").append(value) + .append(" (filter: ").append(filter).append(")\n") + } + OPERATOR_WITHING_RANGE -> { + val filter = data.getIntValue(Data.FORMAT_UINT8, 2)!! + val value1 = data.getIntValue(Data.FORMAT_UINT16, 3)!! + val value2 = data.getIntValue(Data.FORMAT_UINT16, 5)!! + builder.append("Operator: ").append(getOperator(operator)).append(" ") + .append(value1).append("-").append(value2).append(" (filter: ").append(filter) + .append(")\n") + } + } + if (builder.length > 0) builder.setLength(builder.length - 1) + return builder.toString() + } + + private fun getOpCode(opCode: Int): String { + return when (opCode) { + OP_CODE_REPORT_STORED_RECORDS -> "Report stored records" + OP_CODE_DELETE_STORED_RECORDS -> "Delete stored records" + OP_CODE_ABORT_OPERATION -> "Abort operation" + OP_CODE_REPORT_NUMBER_OF_RECORDS -> "Report number of stored records" + OP_CODE_NUMBER_OF_STORED_RECORDS_RESPONSE -> "Number of stored records response" + OP_CODE_RESPONSE_CODE -> "Response Code" + else -> "Reserved for future use" + } + } + + private fun getOperator(operator: Int): String { + return when (operator) { + OPERATOR_NULL -> "Null" + OPERATOR_ALL_RECORDS -> "All records" + OPERATOR_LESS_THEN_OR_EQUAL -> "Less than or equal to" + OPERATOR_GREATER_THEN_OR_EQUAL -> "Greater than or equal to" + OPERATOR_WITHING_RANGE -> "Within range of" + OPERATOR_FIRST_RECORD -> "First record(i.e. oldest record)" + OPERATOR_LAST_RECORD -> "Last record (i.e. most recent record)" + else -> "Reserved for future use" + } + } + + private fun getStatus(status: Int): String { + return when (status) { + RESPONSE_SUCCESS -> "Success" + RESPONSE_OP_CODE_NOT_SUPPORTED -> "Operation not supported" + RESPONSE_INVALID_OPERATOR -> "Invalid operator" + RESPONSE_OPERATOR_NOT_SUPPORTED -> "Operator not supported" + RESPONSE_INVALID_OPERAND -> "Invalid operand" + RESPONSE_NO_RECORDS_FOUND -> "No records found" + RESPONSE_ABORT_UNSUCCESSFUL -> "Abort unsuccessful" + RESPONSE_PROCEDURE_NOT_COMPLETED -> "Procedure not completed" + RESPONSE_OPERAND_NOT_SUPPORTED -> "Operand not supported" + else -> "Reserved for future use" + } + } +} diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMScreen.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMScreen.kt new file mode 100644 index 00000000..3e5763b6 --- /dev/null +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/view/CGMScreen.kt @@ -0,0 +1,7 @@ +package no.nordicsemi.android.cgms.view + +import androidx.compose.runtime.Composable + +@Composable +fun CGMScreen(finishAction: () -> Unit) { +} diff --git a/profile_cgms/src/main/java/no/nordicsemi/android/cgms/viewmodel/CGMScreenViewModel.kt b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/viewmodel/CGMScreenViewModel.kt new file mode 100644 index 00000000..040d3ee1 --- /dev/null +++ b/profile_cgms/src/main/java/no/nordicsemi/android/cgms/viewmodel/CGMScreenViewModel.kt @@ -0,0 +1,14 @@ +package no.nordicsemi.android.cgms.viewmodel + +import dagger.hilt.android.lifecycle.HiltViewModel +import no.nordicsemi.android.cgms.data.CGMDataHolder +import no.nordicsemi.android.theme.viewmodel.CloseableViewModel +import javax.inject.Inject + +@HiltViewModel +internal class CGMScreenViewModel @Inject constructor( + private val dataHolder: CGMDataHolder +) : CloseableViewModel() { + + val state = dataHolder.data +} diff --git a/profile_cgms/src/test/java/no/nordicsemi/android/cgms/ExampleUnitTest.kt b/profile_cgms/src/test/java/no/nordicsemi/android/cgms/ExampleUnitTest.kt new file mode 100644 index 00000000..307c6590 --- /dev/null +++ b/profile_cgms/src/test/java/no/nordicsemi/android/cgms/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package no.nordicsemi.android.cgms + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSData.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSData.kt index 14dbbbc6..34702b68 100644 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSData.kt +++ b/profile_gls/src/main/java/no/nordicsemi/android/gls/data/GLSData.kt @@ -6,7 +6,6 @@ internal data class GLSData( val records: List = emptyList(), val batteryLevel: Int = 0, val requestStatus: RequestStatus = RequestStatus.IDLE, - val isDeviceBonded: Boolean = false, val selectedMode: WorkingMode = WorkingMode.ALL ) { fun modeItems(): List> { diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/view/GLSScreen.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/view/GLSScreen.kt index c047ddb3..d633aeb5 100644 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/view/GLSScreen.kt +++ b/profile_gls/src/main/java/no/nordicsemi/android/gls/view/GLSScreen.kt @@ -19,8 +19,8 @@ fun GLSScreen(finishAction: () -> Unit) { val state = viewModel.state.collectAsState().value val isScreenActive = viewModel.isActive.collectAsState().value - LaunchedEffect(state.isDeviceBonded) { - viewModel.bondDevice() + LaunchedEffect("connect") { + viewModel.connectDevice() } LaunchedEffect(isScreenActive) { diff --git a/profile_gls/src/main/java/no/nordicsemi/android/gls/viewmodel/GLSViewModel.kt b/profile_gls/src/main/java/no/nordicsemi/android/gls/viewmodel/GLSViewModel.kt index e075f08c..f8a5364c 100644 --- a/profile_gls/src/main/java/no/nordicsemi/android/gls/viewmodel/GLSViewModel.kt +++ b/profile_gls/src/main/java/no/nordicsemi/android/gls/viewmodel/GLSViewModel.kt @@ -43,15 +43,7 @@ internal class GLSViewModel @Inject constructor( }.exhaustive } - fun bondDevice() { - if (deviceHolder.isBondingRequired()) { - deviceHolder.bondDevice() - } else { - connectDevice() - } - } - - private fun connectDevice() { + fun connectDevice() { deviceHolder.device?.let { glsManager.connect(it) .useAutoConnect(false) diff --git a/profile_prx/src/main/AndroidManifest.xml b/profile_prx/src/main/AndroidManifest.xml index f322bb43..f7d522ad 100644 --- a/profile_prx/src/main/AndroidManifest.xml +++ b/profile_prx/src/main/AndroidManifest.xml @@ -2,4 +2,7 @@ + + + diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXData.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXData.kt index 926e6608..e3697e9b 100644 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXData.kt +++ b/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXData.kt @@ -1,10 +1,19 @@ package no.nordicsemi.android.prx.data internal data class PRXData( - private val batteryLevel: Int = 0, - private val localAlarmLevel: AlarmLevel = AlarmLevel.NONE, - private val remoteAlarmLevel: Boolean = false -) + val batteryLevel: Int = 0, + val localAlarmLevel: AlarmLevel = AlarmLevel.NONE, + val isRemoteAlarm: Boolean = false +) { + + fun displayLocalAlarm(): String { + return when (localAlarmLevel) { + AlarmLevel.NONE -> "none" + AlarmLevel.MEDIUM -> "medium" + AlarmLevel.HIGH -> "height" + } + } +} internal enum class AlarmLevel(val value: Int) { NONE(0x00), diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXDataHolder.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXDataHolder.kt index 4f2fab54..f9f6fb3d 100644 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXDataHolder.kt +++ b/profile_prx/src/main/java/no/nordicsemi/android/prx/data/PRXDataHolder.kt @@ -21,7 +21,7 @@ internal class PRXDataHolder @Inject constructor() { } fun setRemoteAlarmLevel(isOn: Boolean) { - _data.tryEmit(_data.value.copy(remoteAlarmLevel = isOn)) + _data.tryEmit(_data.value.copy(isRemoteAlarm = isOn)) } fun clear(){ diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXContentView.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXContentView.kt index 0cc33481..120d9655 100644 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXContentView.kt +++ b/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXContentView.kt @@ -1,11 +1,48 @@ package no.nordicsemi.android.prx.view +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.prx.R import no.nordicsemi.android.prx.data.PRXData +import no.nordicsemi.android.theme.view.BatteryLevelView +import no.nordicsemi.android.theme.view.KeyValueField +import no.nordicsemi.android.theme.view.ScreenSection @Composable internal fun ContentView(state: PRXData, onEvent: (PRXScreenViewEvent) -> Unit) { + ScreenSection { + Column { + KeyValueField( + stringResource(id = R.string.prx_is_remote_alarm), + state.isRemoteAlarm.toString() + ) + Spacer(modifier = Modifier.height(4.dp)) + KeyValueField( + stringResource(id = R.string.prx_local_alarm_level), + state.displayLocalAlarm() + ) + } + } - Text(text = "aa") + Spacer(modifier = Modifier.height(16.dp)) + + BatteryLevelView(state.batteryLevel) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary), + onClick = { onEvent(DisconnectEvent) } + ) { + Text(text = stringResource(id = R.string.disconnect)) + } } diff --git a/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXScreen.kt b/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXScreen.kt index dbf2981f..b9081b61 100644 --- a/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXScreen.kt +++ b/profile_prx/src/main/java/no/nordicsemi/android/prx/view/PRXScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -45,7 +46,7 @@ fun PRXScreen(finishAction: () -> Unit) { @Composable private fun PRXView(state: PRXData, onEvent: (PRXScreenViewEvent) -> Unit) { - Column { + Column(horizontalAlignment = Alignment.CenterHorizontally) { BackIconAppBar(stringResource(id = R.string.prx_title)) { onEvent(DisconnectEvent) } @@ -56,6 +57,6 @@ private fun PRXView(state: PRXData, onEvent: (PRXScreenViewEvent) -> Unit) { @Preview @Composable -private fun PRXViewPreview(state: PRXData, onEvent: (PRXScreenViewEvent) -> Unit) { - PRXView(state) { } +private fun PRXViewPreview() { + PRXView(PRXData()) { } } diff --git a/profile_prx/src/main/res/values/strings.xml b/profile_prx/src/main/res/values/strings.xml index 6d3258a2..290b4856 100644 --- a/profile_prx/src/main/res/values/strings.xml +++ b/profile_prx/src/main/res/values/strings.xml @@ -1,4 +1,7 @@ Proximity + + Remote alarm + Local alarm level diff --git a/settings.gradle b/settings.gradle index a75b3116..ddf6ba2d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -63,6 +63,7 @@ rootProject.name = "Android-nRF-Toolbox" include ':app' include ':profile_bps' +include ':profile_cgms' include ':profile_csc' include ':profile_gls' include ':profile_hrs'