From 2a28d7b25526b3f76fdc9e2a09d9cf5568137a9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sylwester=20Zieli=C5=84ski?= Date: Thu, 14 Oct 2021 17:32:39 +0200 Subject: [PATCH] Add BPS & RSCS profiles --- app/build.gradle | 2 + .../android/nrftoolbox/HomeScreen.kt | 8 + .../android/nrftoolbox/NavDestination.kt | 2 + .../android/nrftoolbox/NavigationViewModel.kt | 4 + app/src/main/res/drawable/ic_bps.xml | 15 ++ app/src/main/res/drawable/ic_rscs.xml | 7 + app/src/main/res/values/strings.xml | 2 + profile_bps/build.gradle | 26 +++ .../android/bps/ExampleInstrumentedTest.kt | 24 +++ profile_bps/src/main/AndroidManifest.xml | 5 + .../no/nordicsemi/android/bps/data/BPSData.kt | 38 ++++ .../android/bps/data/BPSDataHolder.kt | 63 ++++++ .../android/bps/repository/BPSManager.kt | 182 ++++++++++++++++++ .../BloodPressureMeasurementParser.kt | 82 ++++++++ .../android/bps/repository/DateTimeParser.kt | 53 +++++ .../IntermediateCuffPressureParser.kt | 78 ++++++++ .../android/bps/view/BPSContentView.kt | 36 ++++ .../nordicsemi/android/bps/view/BPSScreen.kt | 31 +++ .../android/bps/view/BPSScreenViewEvent.kt | 5 + .../android/bps/view/BPSSensorsReadingView.kt | 42 ++++ .../android/bps/viewmodel/BPSViewModel.kt | 28 +++ profile_bps/src/main/res/values/strings.xml | 10 + .../nordicsemi/android/bps/ExampleUnitTest.kt | 17 ++ profile_rscs/build.gradle | 26 +++ .../android/rscs/ExampleInstrumentedTest.kt | 24 +++ profile_rscs/src/main/AndroidManifest.xml | 8 + .../nordicsemi/android/rscs/data/RSCSData.kt | 37 ++++ .../android/rscs/data/RSCSDataHolder.kt | 37 ++++ .../rscs/service/RSCMeasurementParser.kt | 83 ++++++++ .../android/rscs/service/RSCSManager.kt | 109 +++++++++++ .../android/rscs/service/RSCSService.kt | 15 ++ .../android/rscs/view/RSCSContentView.kt | 43 +++++ .../android/rscs/view/RSCSScreen.kt | 54 ++++++ .../android/rscs/view/RSCScreenViewEvent.kt | 5 + .../android/rscs/view/SensorsReadingView.kt | 43 +++++ .../android/rscs/viewmodel/RSCSViewModel.kt | 28 +++ profile_rscs/src/main/res/values/strings.xml | 9 + .../android/rscs/ExampleUnitTest.kt | 17 ++ settings.gradle | 2 + 39 files changed, 1300 insertions(+) create mode 100644 app/src/main/res/drawable/ic_bps.xml create mode 100644 app/src/main/res/drawable/ic_rscs.xml create mode 100644 profile_bps/build.gradle create mode 100644 profile_bps/src/androidTest/java/no/nordicsemi/android/bps/ExampleInstrumentedTest.kt create mode 100644 profile_bps/src/main/AndroidManifest.xml create mode 100644 profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSData.kt create mode 100644 profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSDataHolder.kt create mode 100644 profile_bps/src/main/java/no/nordicsemi/android/bps/repository/BPSManager.kt create mode 100644 profile_bps/src/main/java/no/nordicsemi/android/bps/repository/BloodPressureMeasurementParser.kt create mode 100644 profile_bps/src/main/java/no/nordicsemi/android/bps/repository/DateTimeParser.kt create mode 100644 profile_bps/src/main/java/no/nordicsemi/android/bps/repository/IntermediateCuffPressureParser.kt create mode 100644 profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSContentView.kt create mode 100644 profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSScreen.kt create mode 100644 profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSScreenViewEvent.kt create mode 100644 profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSSensorsReadingView.kt create mode 100644 profile_bps/src/main/java/no/nordicsemi/android/bps/viewmodel/BPSViewModel.kt create mode 100644 profile_bps/src/main/res/values/strings.xml create mode 100644 profile_bps/src/test/java/no/nordicsemi/android/bps/ExampleUnitTest.kt create mode 100644 profile_rscs/build.gradle create mode 100644 profile_rscs/src/androidTest/java/no/nordicsemi/android/rscs/ExampleInstrumentedTest.kt create mode 100644 profile_rscs/src/main/AndroidManifest.xml create mode 100644 profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSData.kt create mode 100644 profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSDataHolder.kt create mode 100644 profile_rscs/src/main/java/no/nordicsemi/android/rscs/service/RSCMeasurementParser.kt create mode 100644 profile_rscs/src/main/java/no/nordicsemi/android/rscs/service/RSCSManager.kt create mode 100644 profile_rscs/src/main/java/no/nordicsemi/android/rscs/service/RSCSService.kt create mode 100644 profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSContentView.kt create mode 100644 profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSScreen.kt create mode 100644 profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCScreenViewEvent.kt create mode 100644 profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/SensorsReadingView.kt create mode 100644 profile_rscs/src/main/java/no/nordicsemi/android/rscs/viewmodel/RSCSViewModel.kt create mode 100644 profile_rscs/src/main/res/values/strings.xml create mode 100644 profile_rscs/src/test/java/no/nordicsemi/android/rscs/ExampleUnitTest.kt diff --git a/app/build.gradle b/app/build.gradle index 060252d2..bda0ea86 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -50,10 +50,12 @@ android { dependencies { //Hilt requires to implement every module in the main app module //https://github.com/google/dagger/issues/2123 + implementation project(':profile_bps') implementation project(':profile_csc') implementation project(':profile_hrs') implementation project(':profile_hts') implementation project(':profile_gls') + implementation project(':profile_rscs') implementation project(':profile_permission') implementation project(':profile_scanner') implementation project(":lib_theme") 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 723f3419..e8e10137 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/HomeScreen.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/HomeScreen.kt @@ -25,6 +25,7 @@ import androidx.navigation.compose.NavHost 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.csc.view.CSCScreen import no.nordicsemi.android.gls.view.GLSScreen import no.nordicsemi.android.hrs.view.HRSScreen @@ -32,6 +33,7 @@ import no.nordicsemi.android.hts.view.HTSScreen import no.nordicsemi.android.permission.view.BluetoothNotAvailableScreen import no.nordicsemi.android.permission.view.BluetoothNotEnabledScreen import no.nordicsemi.android.permission.view.RequestPermissionScreen +import no.nordicsemi.android.rscs.view.RSCSScreen import no.nordicsemi.android.scanner.view.ScanDeviceScreen import no.nordicsemi.android.scanner.view.ScanDeviceScreenResult import no.nordicsemi.android.theme.view.CloseIconAppBar @@ -53,6 +55,8 @@ internal fun HomeScreen() { composable(NavDestination.HRS.id) { HRSScreen { viewModel.navigateUp() } } composable(NavDestination.HTS.id) { HTSScreen { viewModel.navigateUp() } } composable(NavDestination.GLS.id) { GLSScreen { viewModel.navigateUp() } } + composable(NavDestination.BPS.id) { BPSScreen { viewModel.navigateUp() } } + composable(NavDestination.RSCS.id) { RSCSScreen { viewModel.navigateUp() } } composable(NavDestination.REQUEST_PERMISSION.id) { RequestPermissionScreen(continueAction) } composable(NavDestination.BLUETOOTH_NOT_AVAILABLE.id) { BluetoothNotAvailableScreen{ viewModel.finish() } } composable(NavDestination.BLUETOOTH_NOT_ENABLED.id) { @@ -91,6 +95,10 @@ fun HomeView(callback: (NavDestination) -> Unit) { 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) } } } 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 144ac172..9b28ebcf 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavDestination.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavDestination.kt @@ -8,6 +8,8 @@ enum class NavDestination(val id: String) { HRS("hrs-screen"), HTS("hts-screen"), GLS("gls-screen"), + BPS("bps-screen"), + RSCS("rscs-screen"), REQUEST_PERMISSION("request-permission"), BLUETOOTH_NOT_AVAILABLE("bluetooth-not-available"), BLUETOOTH_NOT_ENABLED("bluetooth-not-enabled"), 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 f6419e15..1cbac2ca 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavigationViewModel.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavigationViewModel.kt @@ -3,6 +3,7 @@ package no.nordicsemi.android.nrftoolbox 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.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 @@ -11,6 +12,7 @@ import no.nordicsemi.android.permission.tools.NordicBleScanner import no.nordicsemi.android.permission.tools.PermissionHelper import no.nordicsemi.android.permission.tools.ScannerStatus import no.nordicsemi.android.permission.viewmodel.BluetoothPermissionState +import no.nordicsemi.android.rscs.service.RSCS_SERVICE_UUID import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder import javax.inject.Inject @@ -73,6 +75,8 @@ class NavigationViewModel @Inject constructor( NavDestination.HRS -> HR_SERVICE_UUID.toString() NavDestination.HTS -> HT_SERVICE_UUID.toString() NavDestination.GLS -> GLS_SERVICE_UUID.toString() + NavDestination.BPS -> BPS_SERVICE_UUID.toString() + NavDestination.RSCS -> RSCS_SERVICE_UUID.toString() NavDestination.HOME, NavDestination.REQUEST_PERMISSION, NavDestination.BLUETOOTH_NOT_AVAILABLE, diff --git a/app/src/main/res/drawable/ic_bps.xml b/app/src/main/res/drawable/ic_bps.xml new file mode 100644 index 00000000..583afc04 --- /dev/null +++ b/app/src/main/res/drawable/ic_bps.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_rscs.xml b/app/src/main/res/drawable/ic_rscs.xml new file mode 100644 index 00000000..a79bb78b --- /dev/null +++ b/app/src/main/res/drawable/ic_rscs.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4188a864..4ed85901 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,4 +3,6 @@ HRS GLS HTS + BPS + RSCS \ No newline at end of file diff --git a/profile_bps/build.gradle b/profile_bps/build.gradle new file mode 100644 index 00000000..d397c91b --- /dev/null +++ b/profile_bps/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_bps/src/androidTest/java/no/nordicsemi/android/bps/ExampleInstrumentedTest.kt b/profile_bps/src/androidTest/java/no/nordicsemi/android/bps/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..5dd697c4 --- /dev/null +++ b/profile_bps/src/androidTest/java/no/nordicsemi/android/bps/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package no.nordicsemi.android.bps + +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.bps.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/profile_bps/src/main/AndroidManifest.xml b/profile_bps/src/main/AndroidManifest.xml new file mode 100644 index 00000000..457baeb5 --- /dev/null +++ b/profile_bps/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSData.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSData.kt new file mode 100644 index 00000000..22ce1791 --- /dev/null +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSData.kt @@ -0,0 +1,38 @@ +package no.nordicsemi.android.bps.data + +import no.nordicsemi.android.ble.common.profile.bp.BloodPressureTypes +import java.util.* + +data class BPSData( + val batteryLevel: Int = 0, + val cuffPressure: Float = 0f, + val unit: Int = 0, + val pulseRate: Float? = null, + val userID: Int? = null, + val status: BloodPressureTypes.BPMStatus? = null, + val calendar: Calendar? = null, + val systolic: Float = 0f, + val diastolic: Float = 0f, + val meanArterialPressure: Float = 0f, +) { + + fun displaySystolic(): String { + return "$systolic" + } + + fun displayDiastolic(): String { + return "$diastolic" + } + + fun displayMeanArterialPressure(): String { + return "$meanArterialPressure" + } + + fun displayPulse(): String { + return "$pulseRate" + } + + fun displayTimeData(): String { + return "" + } +} diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSDataHolder.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSDataHolder.kt new file mode 100644 index 00000000..04015ca1 --- /dev/null +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/data/BPSDataHolder.kt @@ -0,0 +1,63 @@ +package no.nordicsemi.android.bps.data + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import no.nordicsemi.android.ble.common.profile.bp.BloodPressureTypes +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class BPSDataHolder @Inject constructor() { + + private val _data = MutableStateFlow(BPSData()) + val data: StateFlow = _data + + fun setIntermediateCuffPressure( + cuffPressure: Float, + unit: Int, + pulseRate: Float?, + userID: Int?, + status: BloodPressureTypes.BPMStatus?, + calendar: Calendar? + ) { + _data.tryEmit(_data.value.copy( + cuffPressure = cuffPressure, + unit = unit, + pulseRate = pulseRate, + userID = userID, + status = status, + calendar = calendar + )) + } + + fun setBloodPressureMeasurement( + systolic: Float, + diastolic: Float, + meanArterialPressure: Float, + unit: Int, + pulseRate: Float?, + userID: Int?, + status: BloodPressureTypes.BPMStatus?, + calendar: Calendar? + ) { + _data.tryEmit(_data.value.copy( + systolic = systolic, + diastolic = diastolic, + meanArterialPressure = meanArterialPressure, + unit = unit, + pulseRate = pulseRate, + userID = userID, + status = status, + calendar = calendar + )) + } + + fun setBatteryLevel(batteryLevel: Int) { + _data.tryEmit(_data.value.copy(batteryLevel = batteryLevel)) + } + + fun clear() { + _data.tryEmit(BPSData()) + } +} diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/BPSManager.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/BPSManager.kt new file mode 100644 index 00000000..a932b0b4 --- /dev/null +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/BPSManager.kt @@ -0,0 +1,182 @@ +/* + * 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.bps.repository + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.content.Context +import android.util.Log +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 no.nordicsemi.android.bps.data.BPSDataHolder +import no.nordicsemi.android.log.LogContract +import no.nordicsemi.android.service.BatteryManager +import java.util.* + +/** 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") + +internal class BPSManager(context: Context, private val dataHolder: BPSDataHolder) : BatteryManager(context) { + + private var bpmCharacteristic: BluetoothGattCharacteristic? = null + private var icpCharacteristic: BluetoothGattCharacteristic? = null + + private val intermediateCuffPressureCallback = object : IntermediateCuffPressureDataCallback() { + + override fun onDataReceived(device: BluetoothDevice, data: Data) { + log( + LogContract.Log.Level.APPLICATION, + "\"" + IntermediateCuffPressureParser.parse(data) + .toString() + "\" received" + ) + + // Pass through received data + super.onDataReceived(device, data) + } + + 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 onDataReceived(device: BluetoothDevice, data: Data) { + log( + LogContract.Log.Level.APPLICATION, + "\"" + BloodPressureMeasurementParser.parse(data) + .toString() + "\" received" + ) + + // Pass through received data + super.onDataReceived(device, data) + } + + 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") + } + } + + override fun onBatteryLevelChanged(batteryLevel: Int) { + dataHolder.setBatteryLevel(batteryLevel) + } + + /** + * BluetoothGatt callbacks for connection/disconnection, service discovery, + * receiving notification, etc. + */ + private inner class BloodPressureManagerGattCallback : BatteryManagerGattCallback() { + + 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" + ) + } + .enqueue() + enableIndications(bpmCharacteristic).enqueue() + } + + override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean { + val service = gatt.getService(BPS_SERVICE_UUID) + if (service != null) { + bpmCharacteristic = service.getCharacteristic(BPM_CHARACTERISTIC_UUID) + icpCharacteristic = service.getCharacteristic(ICP_CHARACTERISTIC_UUID) + } + return bpmCharacteristic != null + } + + override fun onServicesInvalidated() { } + + override fun isOptionalServiceSupported(gatt: BluetoothGatt): Boolean { + super.isOptionalServiceSupported(gatt) // ignore the result of this + return icpCharacteristic != null + } + + override fun onDeviceDisconnected() { + icpCharacteristic = null + bpmCharacteristic = null + } + } + + override fun getGattCallback(): BleManagerGattCallback { + return BloodPressureManagerGattCallback() + } +} diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/BloodPressureMeasurementParser.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/BloodPressureMeasurementParser.kt new file mode 100644 index 00000000..ff92a7ae --- /dev/null +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/BloodPressureMeasurementParser.kt @@ -0,0 +1,82 @@ +/* + * 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.bps.repository + +import no.nordicsemi.android.ble.data.Data +import no.nordicsemi.android.bps.repository.DateTimeParser.parse +import java.lang.StringBuilder + +object BloodPressureMeasurementParser { + + fun parse(data: Data): String { + val builder = StringBuilder() + + // first byte - flags + var offset = 0 + val flags = data.getIntValue(Data.FORMAT_UINT8, offset++)!! + val unitType = flags and 0x01 + val timestampPresent = flags and 0x02 > 0 + val pulseRatePresent = flags and 0x04 > 0 + val userIdPresent = flags and 0x08 > 0 + val statusPresent = flags and 0x10 > 0 + + // following bytes - systolic, diastolic and mean arterial pressure + val systolic = data.getFloatValue(Data.FORMAT_SFLOAT, offset)!! + val diastolic = data.getFloatValue(Data.FORMAT_SFLOAT, offset + 2)!! + val meanArterialPressure = data.getFloatValue(Data.FORMAT_SFLOAT, offset + 4)!! + val unit = if (unitType == 0) " mmHg" else " kPa" + offset += 6 + builder.append("Systolic: ").append(systolic).append(unit) + builder.append("\nDiastolic: ").append(diastolic).append(unit) + builder.append("\nMean AP: ").append(meanArterialPressure).append(unit) + + // parse timestamp if present + if (timestampPresent) { + builder.append("\nTimestamp: ").append(parse(data, offset)) + offset += 7 + } + + // parse pulse rate if present + if (pulseRatePresent) { + val pulseRate = data.getFloatValue(Data.FORMAT_SFLOAT, offset)!! + offset += 2 + builder.append("\nPulse: ").append(pulseRate).append(" bpm") + } + if (userIdPresent) { + val userId = data.getIntValue(Data.FORMAT_UINT8, offset)!! + offset += 1 + builder.append("\nUser ID: ").append(userId) + } + if (statusPresent) { + val status = data.getIntValue(Data.FORMAT_UINT16, offset)!! + // offset += 2; + if (status and 0x0001 > 0) builder.append("\nBody movement detected") + if (status and 0x0002 > 0) builder.append("\nCuff too lose") + if (status and 0x0004 > 0) builder.append("\nIrregular pulse detected") + if (status and 0x0018 == 0x0008) builder.append("\nPulse rate exceeds upper limit") + if (status and 0x0018 == 0x0010) builder.append("\nPulse rate is less than lower limit") + if (status and 0x0018 == 0x0018) builder.append("\nPulse rate range: Reserved for future use ") + if (status and 0x0020 > 0) builder.append("\nImproper measurement position") + } + return builder.toString() + } +} diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/DateTimeParser.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/DateTimeParser.kt new file mode 100644 index 00000000..7e38af1f --- /dev/null +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/DateTimeParser.kt @@ -0,0 +1,53 @@ +/* + * 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.bps.repository + +import no.nordicsemi.android.ble.common.callback.DateTimeDataCallback +import no.nordicsemi.android.ble.data.Data +import java.util.* + +object DateTimeParser { + /** + * Parses the date and time info. + * + * @param data + * @return time in human readable format + */ + fun parse(data: Data): String { + return parse(data, 0) + } + + /** + * Parses the date and time info. This data has 7 bytes + * + * @param data + * @param offset + * offset to start reading the time + * @return time in human readable format + */ + /* package */ + @JvmStatic + fun parse(data: Data, offset: Int): String { + val calendar = DateTimeDataCallback.readDateTime(data, offset) + return String.format(Locale.US, "%1\$te %1\$tb %1\$tY, %1\$tH:%1\$tM:%1\$tS", calendar) + } +} diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/IntermediateCuffPressureParser.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/IntermediateCuffPressureParser.kt new file mode 100644 index 00000000..6b9bbb24 --- /dev/null +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/repository/IntermediateCuffPressureParser.kt @@ -0,0 +1,78 @@ +/* + * 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.bps.repository + +import no.nordicsemi.android.ble.data.Data +import no.nordicsemi.android.bps.repository.DateTimeParser.parse +import java.lang.StringBuilder + +object IntermediateCuffPressureParser { + + fun parse(data: Data): String { + val builder = StringBuilder() + + // first byte - flags + var offset = 0 + val flags = data.getIntValue(Data.FORMAT_UINT8, offset++)!! + val unitType = flags and 0x01 + val timestampPresent = flags and 0x02 > 0 + val pulseRatePresent = flags and 0x04 > 0 + val userIdPresent = flags and 0x08 > 0 + val statusPresent = flags and 0x10 > 0 + + // following bytes - pressure + val pressure = data.getFloatValue(Data.FORMAT_SFLOAT, offset)!! + val unit = if (unitType == 0) " mmHg" else " kPa" + offset += 6 + builder.append("Cuff pressure: ").append(pressure).append(unit) + + // parse timestamp if present + if (timestampPresent) { + builder.append("Timestamp: ").append(parse(data, offset)) + offset += 7 + } + + // parse pulse rate if present + if (pulseRatePresent) { + val pulseRate = data.getFloatValue(Data.FORMAT_SFLOAT, offset)!! + offset += 2 + builder.append("\nPulse: ").append(pulseRate).append(" bpm") + } + if (userIdPresent) { + val userId = data.getIntValue(Data.FORMAT_UINT8, offset)!! + offset += 1 + builder.append("\nUser ID: ").append(userId) + } + if (statusPresent) { + val status = data.getIntValue(Data.FORMAT_UINT16, offset)!! + // offset += 2; + if (status and 0x0001 > 0) builder.append("\nBody movement detected") + if (status and 0x0002 > 0) builder.append("\nCuff too lose") + if (status and 0x0004 > 0) builder.append("\nIrregular pulse detected") + if (status and 0x0018 == 0x0008) builder.append("\nPulse rate exceeds upper limit") + if (status and 0x0018 == 0x0010) builder.append("\nPulse rate is less than lower limit") + if (status and 0x0018 == 0x0018) builder.append("\nPulse rate range: Reserved for future use ") + if (status and 0x0020 > 0) builder.append("\nImproper measurement position") + } + return builder.toString() + } +} diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSContentView.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSContentView.kt new file mode 100644 index 00000000..1bb585a2 --- /dev/null +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSContentView.kt @@ -0,0 +1,36 @@ +package no.nordicsemi.android.bps.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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.bps.R +import no.nordicsemi.android.bps.data.BPSData + +@Composable +internal fun BPSContentView(state: BPSData, onEvent: (BPSScreenViewEvent) -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(16.dp)) + + BPSSensorsReadingView(state = state) + + 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_bps/src/main/java/no/nordicsemi/android/bps/view/BPSScreen.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSScreen.kt new file mode 100644 index 00000000..a0b50c54 --- /dev/null +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSScreen.kt @@ -0,0 +1,31 @@ +package no.nordicsemi.android.bps.view + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import no.nordicsemi.android.bps.R +import no.nordicsemi.android.bps.data.BPSData +import no.nordicsemi.android.bps.viewmodel.BPSViewModel +import no.nordicsemi.android.theme.view.BackIconAppBar + +@Composable +fun BPSScreen(finishAction: () -> Unit) { + val viewModel: BPSViewModel = hiltViewModel() + val state = viewModel.state.collectAsState().value + val isScreenActive = viewModel.isActive.collectAsState().value + + BPSView(state) { viewModel.onEvent(it) } +} + +@Composable +private fun BPSView(state: BPSData, onEvent: (BPSScreenViewEvent) -> Unit) { + Column { + BackIconAppBar(stringResource(id = R.string.bps_title)) { + onEvent(DisconnectEvent) + } + + BPSContentView(state) { onEvent(it) } + } +} diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSScreenViewEvent.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSScreenViewEvent.kt new file mode 100644 index 00000000..445a07e8 --- /dev/null +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSScreenViewEvent.kt @@ -0,0 +1,5 @@ +package no.nordicsemi.android.bps.view + +internal sealed class BPSScreenViewEvent + +internal object DisconnectEvent : BPSScreenViewEvent() diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSSensorsReadingView.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSSensorsReadingView.kt new file mode 100644 index 00000000..722a3921 --- /dev/null +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/view/BPSSensorsReadingView.kt @@ -0,0 +1,42 @@ +package no.nordicsemi.android.bps.view + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.bps.R +import no.nordicsemi.android.bps.data.BPSData +import no.nordicsemi.android.theme.view.BatteryLevelView +import no.nordicsemi.android.theme.view.KeyValueField +import no.nordicsemi.android.theme.view.ScreenSection + +@Composable +internal fun BPSSensorsReadingView(state: BPSData) { + ScreenSection { + Column { + KeyValueField(stringResource(id = R.string.bps_systolic), state.displaySystolic()) + Spacer(modifier = Modifier.height(4.dp)) + KeyValueField(stringResource(id = R.string.bps_diastolic), state.displayDiastolic()) + Spacer(modifier = Modifier.height(4.dp)) + KeyValueField(stringResource(id = R.string.bps_mean), state.displayMeanArterialPressure()) + Spacer(modifier = Modifier.height(4.dp)) + KeyValueField(stringResource(id = R.string.bps_pulse), state.displayPulse()) + Spacer(modifier = Modifier.height(4.dp)) + KeyValueField(stringResource(id = R.string.bps_time_data), state.displayTimeData()) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + BatteryLevelView(state.batteryLevel) +} + +@Preview +@Composable +private fun Preview() { + BPSSensorsReadingView(BPSData()) +} diff --git a/profile_bps/src/main/java/no/nordicsemi/android/bps/viewmodel/BPSViewModel.kt b/profile_bps/src/main/java/no/nordicsemi/android/bps/viewmodel/BPSViewModel.kt new file mode 100644 index 00000000..afbc2c0a --- /dev/null +++ b/profile_bps/src/main/java/no/nordicsemi/android/bps/viewmodel/BPSViewModel.kt @@ -0,0 +1,28 @@ +package no.nordicsemi.android.bps.viewmodel + +import dagger.hilt.android.lifecycle.HiltViewModel +import no.nordicsemi.android.bps.data.BPSDataHolder +import no.nordicsemi.android.bps.view.BPSScreenViewEvent +import no.nordicsemi.android.bps.view.DisconnectEvent +import no.nordicsemi.android.theme.viewmodel.CloseableViewModel +import no.nordicsemi.android.utils.exhaustive +import javax.inject.Inject + +@HiltViewModel +internal class BPSViewModel @Inject constructor( + private val dataHolder: BPSDataHolder +) : CloseableViewModel() { + + val state = dataHolder.data + + fun onEvent(event: BPSScreenViewEvent) { + when (event) { + DisconnectEvent -> onDisconnectButtonClick() + }.exhaustive + } + + private fun onDisconnectButtonClick() { + finish() + dataHolder.clear() + } +} diff --git a/profile_bps/src/main/res/values/strings.xml b/profile_bps/src/main/res/values/strings.xml new file mode 100644 index 00000000..e9b7392a --- /dev/null +++ b/profile_bps/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + Blood pressure + + Systolic + Diastolic + Mean ap + Pulse + Time and Date + diff --git a/profile_bps/src/test/java/no/nordicsemi/android/bps/ExampleUnitTest.kt b/profile_bps/src/test/java/no/nordicsemi/android/bps/ExampleUnitTest.kt new file mode 100644 index 00000000..6621f261 --- /dev/null +++ b/profile_bps/src/test/java/no/nordicsemi/android/bps/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package no.nordicsemi.android.bps + +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_rscs/build.gradle b/profile_rscs/build.gradle new file mode 100644 index 00000000..d397c91b --- /dev/null +++ b/profile_rscs/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_rscs/src/androidTest/java/no/nordicsemi/android/rscs/ExampleInstrumentedTest.kt b/profile_rscs/src/androidTest/java/no/nordicsemi/android/rscs/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..33bfc46c --- /dev/null +++ b/profile_rscs/src/androidTest/java/no/nordicsemi/android/rscs/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package no.nordicsemi.android.rscs + +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.rscs.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/profile_rscs/src/main/AndroidManifest.xml b/profile_rscs/src/main/AndroidManifest.xml new file mode 100644 index 00000000..896ad954 --- /dev/null +++ b/profile_rscs/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSData.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSData.kt new file mode 100644 index 00000000..6f2d8ce1 --- /dev/null +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSData.kt @@ -0,0 +1,37 @@ +package no.nordicsemi.android.rscs.data + +internal data class RSCSData( + val batteryLevel: Int = 0, + val running: Boolean = false, + val instantaneousSpeed: Float = 1.0f, + val instantaneousCadence: Int = 0, + val strideLength: Int? = null, + val totalDistance: Long? = null +) { + + fun displayActivity(): String { + return if (running) { + "Running" + } else { + "Walking" + } + } + + fun displayPace(): String { + return "$instantaneousCadence min/km" + } + + + fun displayCadence(): String { + return "$instantaneousCadence RPM" + } + + + fun displayNumberOfSteps(): String { + if (totalDistance == null || strideLength == null) { + return "NONE" + } + val numberOfSteps = totalDistance/strideLength + return "Number of Steps $numberOfSteps" + } +} diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSDataHolder.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSDataHolder.kt new file mode 100644 index 00000000..6ca05ddb --- /dev/null +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/data/RSCSDataHolder.kt @@ -0,0 +1,37 @@ +package no.nordicsemi.android.rscs.data + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class RSCSDataHolder @Inject constructor() { + + private val _data = MutableStateFlow(RSCSData()) + val data: StateFlow = _data + + fun setNewData( + running: Boolean, + instantaneousSpeed: Float, + instantaneousCadence: Int, + strideLength: Int?, + totalDistance: Long? + ) { + _data.tryEmit(_data.value.copy( + running = running, + instantaneousCadence = instantaneousCadence, + instantaneousSpeed = instantaneousSpeed, + strideLength = strideLength, + totalDistance = totalDistance + )) + } + + fun setBatteryLevel(batteryLevel: Int) { + _data.tryEmit(_data.value.copy(batteryLevel = batteryLevel)) + } + + fun clear() { + _data.tryEmit(RSCSData()) + } +} diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/service/RSCMeasurementParser.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/service/RSCMeasurementParser.kt new file mode 100644 index 00000000..d0246d2c --- /dev/null +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/service/RSCMeasurementParser.kt @@ -0,0 +1,83 @@ +/* + * 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.rscs.service + +import no.nordicsemi.android.ble.data.Data +import java.util.* + +internal object RSCMeasurementParser { + + private const val INSTANTANEOUS_STRIDE_LENGTH_PRESENT: Byte = 0x01 // 1 bit + private const val TOTAL_DISTANCE_PRESENT: Byte = 0x02 // 1 bit + private const val WALKING_OR_RUNNING_STATUS_BITS: Byte = 0x04 // 1 bit + + fun parse(data: Data): String { + var offset = 0 + val flags = data.value!![offset].toInt() // 1 byte + offset += 1 + val islmPresent = flags and INSTANTANEOUS_STRIDE_LENGTH_PRESENT.toInt() > 0 + val tdPreset = flags and TOTAL_DISTANCE_PRESENT.toInt() > 0 + val running = flags and WALKING_OR_RUNNING_STATUS_BITS.toInt() > 0 + val walking = !running + val instantaneousSpeed = + data.getIntValue(Data.FORMAT_UINT16, offset) as Float / 256.0f // 1/256 m/s + offset += 2 + val instantaneousCadence = data.getIntValue(Data.FORMAT_UINT8, offset)!! + offset += 1 + var instantaneousStrideLength = 0f + if (islmPresent) { + instantaneousStrideLength = + data.getIntValue(Data.FORMAT_UINT16, offset) as Float / 100.0f // 1/100 m + offset += 2 + } + var totalDistance = 0f + if (tdPreset) { + totalDistance = data.getIntValue(Data.FORMAT_UINT32, offset) as Float / 10.0f + // offset += 4; + } + val builder = StringBuilder() + builder.append( + String.format( + Locale.US, + "Speed: %.2f m/s, Cadence: %d RPM,\n", + instantaneousSpeed, + instantaneousCadence + ) + ) + if (islmPresent) builder.append( + String.format( + Locale.US, + "Instantaneous Stride Length: %.2f m,\n", + instantaneousStrideLength + ) + ) + if (tdPreset) builder.append( + String.format( + Locale.US, + "Total Distance: %.1f m,\n", + totalDistance + ) + ) + if (walking) builder.append("Status: WALKING") else builder.append("Status: RUNNING") + return builder.toString() + } +} diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/service/RSCSManager.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/service/RSCSManager.kt new file mode 100644 index 00000000..84c0a680 --- /dev/null +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/service/RSCSManager.kt @@ -0,0 +1,109 @@ +/* + * 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.rscs.service + +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 no.nordicsemi.android.ble.data.Data +import no.nordicsemi.android.log.LogContract +import no.nordicsemi.android.rscs.data.RSCSDataHolder +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 dataHolder: RSCSDataHolder +) : BatteryManager(context) { + + private var rscMeasurementCharacteristic: BluetoothGattCharacteristic? = null + + private val callback = object : RunningSpeedAndCadenceMeasurementDataCallback() { + override fun onDataReceived(device: BluetoothDevice, data: Data) { + log( + LogContract.Log.Level.APPLICATION, + "\"" + RSCMeasurementParser.parse(data).toString() + "\" received" + ) + super.onDataReceived(device, data) + } + + override fun onRSCMeasurementReceived( + device: BluetoothDevice, + running: Boolean, + instantaneousSpeed: Float, + instantaneousCadence: Int, + strideLength: Int?, + totalDistance: Long? + ) { + dataHolder.setNewData(running, instantaneousSpeed, instantaneousCadence, strideLength, totalDistance) + } + } + + override fun onBatteryLevelChanged(batteryLevel: Int) { + dataHolder.setBatteryLevel(batteryLevel) + } + + override fun getGattCallback(): BatteryManagerGattCallback { + 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() + } + + public override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean { + val service = gatt.getService(RSCS_SERVICE_UUID) + if (service != null) { + rscMeasurementCharacteristic = service.getCharacteristic( + RSC_MEASUREMENT_CHARACTERISTIC_UUID + ) + } + return rscMeasurementCharacteristic != null + } + + override fun onServicesInvalidated() { + + } + + override fun onDeviceDisconnected() { + super.onDeviceDisconnected() + rscMeasurementCharacteristic = null + } + } +} diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/service/RSCSService.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/service/RSCSService.kt new file mode 100644 index 00000000..70317bcf --- /dev/null +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/service/RSCSService.kt @@ -0,0 +1,15 @@ +package no.nordicsemi.android.rscs.service + +import dagger.hilt.android.AndroidEntryPoint +import no.nordicsemi.android.rscs.data.RSCSDataHolder +import no.nordicsemi.android.service.ForegroundBleService +import javax.inject.Inject + +@AndroidEntryPoint +internal class RSCSService : ForegroundBleService() { + + @Inject + lateinit var dataHolder: RSCSDataHolder + + override val manager: RSCSManager by lazy { RSCSManager(this, dataHolder) } +} diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSContentView.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSContentView.kt new file mode 100644 index 00000000..0238961a --- /dev/null +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSContentView.kt @@ -0,0 +1,43 @@ +package no.nordicsemi.android.rscs.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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.rscs.R +import no.nordicsemi.android.rscs.data.RSCSData + +@Composable +internal fun RSCSContentView(state: RSCSData, onEvent: (RSCScreenViewEvent) -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(16.dp)) + + SensorsReadingView(state = state) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary), + onClick = { onEvent(DisconnectEvent) } + ) { + Text(text = stringResource(id = R.string.disconnect)) + } + } +} + +@Preview +@Composable +private fun RSCSContentViewPreview() { + RSCSContentView(RSCSData()) { } +} diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSScreen.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSScreen.kt new file mode 100644 index 00000000..67ff8b11 --- /dev/null +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCSScreen.kt @@ -0,0 +1,54 @@ +package no.nordicsemi.android.rscs.view + +import android.content.Intent +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import no.nordicsemi.android.rscs.R +import no.nordicsemi.android.rscs.data.RSCSData +import no.nordicsemi.android.rscs.service.RSCSService +import no.nordicsemi.android.rscs.viewmodel.RSCSViewModel +import no.nordicsemi.android.theme.view.BackIconAppBar +import no.nordicsemi.android.utils.isServiceRunning + +@Composable +fun RSCSScreen(finishAction: () -> Unit) { + val viewModel: RSCSViewModel = hiltViewModel() + val state = viewModel.state.collectAsState().value + val isScreenActive = viewModel.isActive.collectAsState().value + + val context = LocalContext.current + LaunchedEffect(isScreenActive) { + if (!isScreenActive) { + finishAction() + } + if (context.isServiceRunning(RSCSService::class.java.name)) { + val intent = Intent(context, RSCSService::class.java) + context.stopService(intent) + } + } + + LaunchedEffect("start-service") { + if (!context.isServiceRunning(RSCSService::class.java.name)) { + val intent = Intent(context, RSCSService::class.java) + context.startService(intent) + } + } + + RSCSView(state) { viewModel.onEvent(it) } +} + +@Composable +private fun RSCSView(state: RSCSData, onEvent: (RSCScreenViewEvent) -> Unit) { + Column { + BackIconAppBar(stringResource(id = R.string.rscs_title)) { + onEvent(DisconnectEvent) + } + + RSCSContentView(state) { onEvent(it) } + } +} diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCScreenViewEvent.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCScreenViewEvent.kt new file mode 100644 index 00000000..f759ff10 --- /dev/null +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/RSCScreenViewEvent.kt @@ -0,0 +1,5 @@ +package no.nordicsemi.android.rscs.view + +internal sealed class RSCScreenViewEvent + +internal object DisconnectEvent : RSCScreenViewEvent() diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/SensorsReadingView.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/SensorsReadingView.kt new file mode 100644 index 00000000..7b8507e1 --- /dev/null +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/view/SensorsReadingView.kt @@ -0,0 +1,43 @@ +package no.nordicsemi.android.rscs.view + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.rscs.R +import no.nordicsemi.android.rscs.data.RSCSData +import no.nordicsemi.android.theme.view.BatteryLevelView +import no.nordicsemi.android.theme.view.KeyValueField +import no.nordicsemi.android.theme.view.ScreenSection + +@Composable +internal fun SensorsReadingView(state: RSCSData) { + ScreenSection { + Column { + KeyValueField(stringResource(id = R.string.rscs_activity), state.displayActivity()) + Spacer(modifier = Modifier.height(4.dp)) + KeyValueField(stringResource(id = R.string.rscs_pace), state.displayPace()) + 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() + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + BatteryLevelView(state.batteryLevel) +} + +@Preview +@Composable +private fun Preview() { + SensorsReadingView(RSCSData()) +} diff --git a/profile_rscs/src/main/java/no/nordicsemi/android/rscs/viewmodel/RSCSViewModel.kt b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/viewmodel/RSCSViewModel.kt new file mode 100644 index 00000000..f088831f --- /dev/null +++ b/profile_rscs/src/main/java/no/nordicsemi/android/rscs/viewmodel/RSCSViewModel.kt @@ -0,0 +1,28 @@ +package no.nordicsemi.android.rscs.viewmodel + +import dagger.hilt.android.lifecycle.HiltViewModel +import no.nordicsemi.android.rscs.data.RSCSDataHolder +import no.nordicsemi.android.rscs.view.DisconnectEvent +import no.nordicsemi.android.rscs.view.RSCScreenViewEvent +import no.nordicsemi.android.theme.viewmodel.CloseableViewModel +import no.nordicsemi.android.utils.exhaustive +import javax.inject.Inject + +@HiltViewModel +internal class RSCSViewModel @Inject constructor( + private val dataHolder: RSCSDataHolder +) : CloseableViewModel() { + + val state = dataHolder.data + + fun onEvent(event: RSCScreenViewEvent) { + when (event) { + DisconnectEvent -> onDisconnectButtonClick() + }.exhaustive + } + + private fun onDisconnectButtonClick() { + finish() + dataHolder.clear() + } +} diff --git a/profile_rscs/src/main/res/values/strings.xml b/profile_rscs/src/main/res/values/strings.xml new file mode 100644 index 00000000..e02bc07d --- /dev/null +++ b/profile_rscs/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + Running speed & cadence + + Activity + Pace + Cadence + Number of steps + diff --git a/profile_rscs/src/test/java/no/nordicsemi/android/rscs/ExampleUnitTest.kt b/profile_rscs/src/test/java/no/nordicsemi/android/rscs/ExampleUnitTest.kt new file mode 100644 index 00000000..a5629c50 --- /dev/null +++ b/profile_rscs/src/test/java/no/nordicsemi/android/rscs/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package no.nordicsemi.android.rscs + +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/settings.gradle b/settings.gradle index 91b53830..b361e1f2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -62,10 +62,12 @@ rootProject.name = "Android-nRF-Toolbox" include ':app' +include ':profile_bps' include ':profile_csc' include ':profile_gls' include ':profile_hrs' include ':profile_hts' +include ':profile_rscs' include ':profile_permission' include ':lib_service'