mirror of
https://github.com/aljazceru/Android-nRF-Toolbox.git
synced 2025-12-23 09:24:23 +01:00
Add HRS service
This commit is contained in:
28
feature_hrs/build.gradle
Normal file
28
feature_hrs/build.gradle
Normal file
@@ -0,0 +1,28 @@
|
||||
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.chart
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package no.nordicsemi.android.hrs
|
||||
|
||||
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.hrs.test", appContext.packageName)
|
||||
}
|
||||
}
|
||||
9
feature_hrs/src/main/AndroidManifest.xml
Normal file
9
feature_hrs/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="no.nordicsemi.android.hrs">
|
||||
|
||||
<application>
|
||||
<service android:name=".service.HRSService" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,7 @@
|
||||
package no.nordicsemi.android.hrs.events
|
||||
|
||||
internal data class HRSAggregatedData(
|
||||
val heartRates: List<Int> = emptyList(),
|
||||
val batteryLevel: Int = 0,
|
||||
val sensorLocation: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.hrs.service
|
||||
|
||||
import no.nordicsemi.android.ble.data.Data
|
||||
|
||||
object BodySensorLocationParser {
|
||||
fun parse(data: Data): String {
|
||||
val value = data.getIntValue(Data.FORMAT_UINT8, 0)!!
|
||||
return when (value) {
|
||||
6 -> "Foot"
|
||||
5 -> "Ear Lobe"
|
||||
4 -> "Hand"
|
||||
3 -> "Finger"
|
||||
2 -> "Wrist"
|
||||
1 -> "Chest"
|
||||
0 -> "Other"
|
||||
else -> "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package no.nordicsemi.android.hrs.service
|
||||
|
||||
import no.nordicsemi.android.hrs.events.HRSAggregatedData
|
||||
import no.nordicsemi.android.service.BluetoothDataReadBroadcast
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
internal class HRSDataBroadcast @Inject constructor() : BluetoothDataReadBroadcast<HRSAggregatedData>()
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* 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.hrs.service
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.IntRange
|
||||
import no.nordicsemi.android.ble.common.callback.hr.BodySensorLocationDataCallback
|
||||
import no.nordicsemi.android.ble.common.callback.hr.HeartRateMeasurementDataCallback
|
||||
import no.nordicsemi.android.ble.common.profile.hr.BodySensorLocation
|
||||
import no.nordicsemi.android.ble.data.Data
|
||||
import no.nordicsemi.android.log.LogContract
|
||||
import no.nordicsemi.android.service.BatteryManager
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* HRSManager class performs BluetoothGatt operations for connection, service discovery,
|
||||
* enabling notification and reading characteristics.
|
||||
* All operations required to connect to device with BLE Heart Rate Service and reading
|
||||
* heart rate values are performed here.
|
||||
*/
|
||||
class HRSManager(context: Context) : BatteryManager<HRSManagerCallbacks>(context) {
|
||||
|
||||
private var heartRateCharacteristic: BluetoothGattCharacteristic? = null
|
||||
private var bodySensorLocationCharacteristic: BluetoothGattCharacteristic? = null
|
||||
|
||||
override fun getGattCallback(): BatteryManagerGattCallback {
|
||||
return HeartRateManagerCallback()
|
||||
}
|
||||
|
||||
/**
|
||||
* BluetoothGatt callbacks for connection/disconnection, service discovery,
|
||||
* receiving notification, etc.
|
||||
*/
|
||||
private inner class HeartRateManagerCallback : BatteryManagerGattCallback() {
|
||||
override fun initialize() {
|
||||
super.initialize()
|
||||
readCharacteristic(bodySensorLocationCharacteristic)
|
||||
.with(object : BodySensorLocationDataCallback() {
|
||||
|
||||
override fun onDataReceived(device: BluetoothDevice, data: Data) {
|
||||
log(
|
||||
LogContract.Log.Level.APPLICATION,
|
||||
"\"" + BodySensorLocationParser.parse(data) + "\" received"
|
||||
)
|
||||
super.onDataReceived(device, data)
|
||||
}
|
||||
|
||||
override fun onBodySensorLocationReceived(
|
||||
device: BluetoothDevice,
|
||||
@BodySensorLocation sensorLocation: Int
|
||||
) {
|
||||
mCallbacks?.onBodySensorLocationReceived(device, sensorLocation)
|
||||
}
|
||||
|
||||
})
|
||||
.fail { device: BluetoothDevice?, status: Int ->
|
||||
log(Log.WARN, "Body Sensor Location characteristic not found")
|
||||
}
|
||||
.enqueue()
|
||||
|
||||
setNotificationCallback(heartRateCharacteristic)
|
||||
.with(object : HeartRateMeasurementDataCallback() {
|
||||
|
||||
override fun onDataReceived(device: BluetoothDevice, data: Data) {
|
||||
log(
|
||||
LogContract.Log.Level.APPLICATION,
|
||||
"\"" + HeartRateMeasurementParser.parse(data) + "\" received"
|
||||
)
|
||||
super.onDataReceived(device, data)
|
||||
}
|
||||
|
||||
override fun onHeartRateMeasurementReceived(
|
||||
device: BluetoothDevice,
|
||||
@IntRange(from = 0) heartRate: Int,
|
||||
contactDetected: Boolean?,
|
||||
@IntRange(from = 0) energyExpanded: Int?,
|
||||
rrIntervals: List<Int>?
|
||||
) {
|
||||
mCallbacks?.onHeartRateMeasurementReceived(
|
||||
device,
|
||||
heartRate,
|
||||
contactDetected,
|
||||
energyExpanded,
|
||||
rrIntervals
|
||||
)
|
||||
}
|
||||
})
|
||||
enableNotifications(heartRateCharacteristic).enqueue()
|
||||
}
|
||||
|
||||
override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
|
||||
val service = gatt.getService(HR_SERVICE_UUID)
|
||||
if (service != null) {
|
||||
heartRateCharacteristic = service.getCharacteristic(
|
||||
HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID
|
||||
)
|
||||
}
|
||||
return heartRateCharacteristic != null
|
||||
}
|
||||
|
||||
override fun isOptionalServiceSupported(gatt: BluetoothGatt): Boolean {
|
||||
super.isOptionalServiceSupported(gatt)
|
||||
val service = gatt.getService(HR_SERVICE_UUID)
|
||||
if (service != null) {
|
||||
bodySensorLocationCharacteristic = service.getCharacteristic(
|
||||
BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID
|
||||
)
|
||||
}
|
||||
return bodySensorLocationCharacteristic != null
|
||||
}
|
||||
|
||||
override fun onDeviceDisconnected() {
|
||||
super.onDeviceDisconnected()
|
||||
bodySensorLocationCharacteristic = null
|
||||
heartRateCharacteristic = null
|
||||
}
|
||||
|
||||
override fun onServicesInvalidated() {}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val HR_SERVICE_UUID = UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb")
|
||||
private val BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID = UUID.fromString("00002A38-0000-1000-8000-00805f9b34fb")
|
||||
private val HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A37-0000-1000-8000-00805f9b34fb")
|
||||
private var managerInstance: HRSManager? = null
|
||||
|
||||
/**
|
||||
* Singleton implementation of HRSManager class.
|
||||
*/
|
||||
@Synchronized
|
||||
fun getInstance(context: Context): HRSManager? {
|
||||
if (managerInstance == null) {
|
||||
managerInstance = HRSManager(context)
|
||||
}
|
||||
return managerInstance
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.hrs.service
|
||||
|
||||
import no.nordicsemi.android.ble.common.profile.hr.BodySensorLocationCallback
|
||||
import no.nordicsemi.android.ble.common.profile.hr.HeartRateMeasurementCallback
|
||||
import no.nordicsemi.android.service.BatteryManagerCallbacks
|
||||
|
||||
interface HRSManagerCallbacks
|
||||
: BatteryManagerCallbacks, BodySensorLocationCallback, HeartRateMeasurementCallback
|
||||
@@ -0,0 +1,53 @@
|
||||
package no.nordicsemi.android.hrs.service
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import no.nordicsemi.android.ble.BleManagerCallbacks
|
||||
import no.nordicsemi.android.hrs.events.HRSAggregatedData
|
||||
import no.nordicsemi.android.service.ForegroundBleService
|
||||
import no.nordicsemi.android.service.LoggableBleManager
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
internal class HRSService : ForegroundBleService<HRSManager>(), HRSManagerCallbacks {
|
||||
|
||||
private var data = HRSAggregatedData()
|
||||
private val points = mutableListOf<Int>()
|
||||
|
||||
@Inject
|
||||
lateinit var localBroadcast: HRSDataBroadcast
|
||||
|
||||
override val manager: HRSManager by lazy {
|
||||
HRSManager(this).apply {
|
||||
setGattCallbacks(this@HRSService)
|
||||
}
|
||||
}
|
||||
|
||||
override fun initializeManager(): LoggableBleManager<out BleManagerCallbacks> {
|
||||
return manager
|
||||
}
|
||||
|
||||
override fun onBatteryLevelChanged(device: BluetoothDevice, batteryLevel: Int) {
|
||||
sendNewData(data.copy(batteryLevel = batteryLevel))
|
||||
}
|
||||
|
||||
override fun onBodySensorLocationReceived(device: BluetoothDevice, sensorLocation: Int) {
|
||||
sendNewData(data.copy(sensorLocation = sensorLocation))
|
||||
}
|
||||
|
||||
override fun onHeartRateMeasurementReceived(
|
||||
device: BluetoothDevice,
|
||||
heartRate: Int,
|
||||
contactDetected: Boolean?,
|
||||
energyExpanded: Int?,
|
||||
rrIntervals: MutableList<Int>?
|
||||
) {
|
||||
points.add(heartRate)
|
||||
sendNewData(data.copy(heartRates = points))
|
||||
}
|
||||
|
||||
private fun sendNewData(newData: HRSAggregatedData) {
|
||||
data = newData
|
||||
localBroadcast.offer(newData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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.hrs.service
|
||||
|
||||
import no.nordicsemi.android.ble.data.Data
|
||||
import java.util.*
|
||||
|
||||
object HeartRateMeasurementParser {
|
||||
|
||||
private const val HEART_RATE_VALUE_FORMAT: Byte = 0x01 // 1 bit
|
||||
private const val SENSOR_CONTACT_STATUS: Byte = 0x06 // 2 bits
|
||||
private const val ENERGY_EXPANDED_STATUS: Byte = 0x08 // 1 bit
|
||||
private const val RR_INTERVAL: Byte = 0x10 // 1 bit
|
||||
|
||||
fun parse(data: Data): String {
|
||||
var offset = 0
|
||||
val flags = data.getIntValue(Data.FORMAT_UINT8, offset++)!!
|
||||
|
||||
/*
|
||||
* false Heart Rate Value Format is set to UINT8. Units: beats per minute (bpm)
|
||||
* true Heart Rate Value Format is set to UINT16. Units: beats per minute (bpm)
|
||||
*/
|
||||
val value16bit = flags and HEART_RATE_VALUE_FORMAT.toInt() > 0
|
||||
|
||||
/*
|
||||
* 0 Sensor Contact feature is not supported in the current connection
|
||||
* 1 Sensor Contact feature is not supported in the current connection
|
||||
* 2 Sensor Contact feature is supported, but contact is not detected
|
||||
* 3 Sensor Contact feature is supported and contact is detected
|
||||
*/
|
||||
val sensorContactStatus = flags and SENSOR_CONTACT_STATUS.toInt() shr 1
|
||||
|
||||
/*
|
||||
* false Energy Expended field is not present
|
||||
* true Energy Expended field is present. Units: kilo Joules
|
||||
*/
|
||||
val energyExpandedStatus = flags and ENERGY_EXPANDED_STATUS.toInt() > 0
|
||||
|
||||
/*
|
||||
* false RR-Interval values are not present.
|
||||
* true One or more RR-Interval values are present. Units: 1/1024 seconds
|
||||
*/
|
||||
val rrIntervalStatus = flags and RR_INTERVAL.toInt() > 0
|
||||
|
||||
// heart rate value is 8 or 16 bit long
|
||||
val heartRateValue = data.getIntValue(
|
||||
if (value16bit) {
|
||||
Data.FORMAT_UINT16
|
||||
} else {
|
||||
Data.FORMAT_UINT8
|
||||
},
|
||||
offset++
|
||||
) // bits per minute
|
||||
if (value16bit) offset++
|
||||
|
||||
// energy expanded value is present if a flag was set
|
||||
var energyExpanded = -1
|
||||
if (energyExpandedStatus) energyExpanded = data.getIntValue(Data.FORMAT_UINT16, offset)!!
|
||||
offset += 2
|
||||
|
||||
// RR-interval is set when a flag is set
|
||||
val rrIntervals: MutableList<Float> = ArrayList()
|
||||
if (rrIntervalStatus) {
|
||||
var o = offset
|
||||
while (o < data.value!!.size) {
|
||||
val units = data.getIntValue(Data.FORMAT_UINT16, o)!!
|
||||
rrIntervals.add(units * 1000.0f / 1024.0f) // RR interval is in [1/1024s]
|
||||
o += 2
|
||||
}
|
||||
}
|
||||
val builder = StringBuilder()
|
||||
builder.append("Heart Rate Measurement: ").append(heartRateValue).append(" bpm")
|
||||
when (sensorContactStatus) {
|
||||
0, 1 -> builder.append(",\nSensor Contact Not Supported")
|
||||
2 -> builder.append(",\nContact is NOT Detected")
|
||||
3 -> builder.append(",\nContact is Detected")
|
||||
}
|
||||
if (energyExpandedStatus) {
|
||||
builder.append(",\nEnergy Expanded: ")
|
||||
.append(energyExpanded)
|
||||
.append(" kJ")
|
||||
}
|
||||
if (rrIntervalStatus) {
|
||||
builder.append(",\nRR Interval: ")
|
||||
for (interval in rrIntervals) builder.append(
|
||||
String.format(
|
||||
Locale.US,
|
||||
"%.02f ms, ",
|
||||
interval
|
||||
)
|
||||
)
|
||||
builder.setLength(builder.length - 2) // remove the ", " at the end
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package no.nordicsemi.android.hrs.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.DashPathEffect
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Card
|
||||
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 androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.github.mikephil.charting.charts.LineChart
|
||||
import com.github.mikephil.charting.data.Entry
|
||||
import com.github.mikephil.charting.data.LineData
|
||||
import com.github.mikephil.charting.data.LineDataSet
|
||||
import com.github.mikephil.charting.formatter.IFillFormatter
|
||||
import com.github.mikephil.charting.interfaces.datasets.ILineDataSet
|
||||
import com.github.mikephil.charting.utils.Utils
|
||||
import no.nordicsemi.android.hrs.R
|
||||
import no.nordicsemi.android.hrs.viewmodel.HRSViewState
|
||||
import no.nordicsemi.android.theme.NordicColors
|
||||
import no.nordicsemi.android.theme.view.BatteryLevelView
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
internal fun ContentView(state: HRSViewState, onEvent: (HRSScreenViewEvent) -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Card(
|
||||
backgroundColor = NordicColors.NordicGray4.value(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
elevation = 0.dp
|
||||
) {
|
||||
Box(modifier = Modifier.padding(16.dp)) {
|
||||
LineChartView(state)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LineChartView(state: HRSViewState) {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
factory = { createLineChartView(it, state) },
|
||||
update = { updateData(state.points, it) }
|
||||
)
|
||||
}
|
||||
|
||||
fun createLineChartView(context: Context, state: HRSViewState): LineChart {
|
||||
return LineChart(context).apply {
|
||||
setBackgroundColor(Color.WHITE)
|
||||
|
||||
description.isEnabled = false
|
||||
|
||||
setTouchEnabled(true)
|
||||
|
||||
// setOnChartValueSelectedListener(this)
|
||||
setDrawGridBackground(false)
|
||||
|
||||
isDragEnabled = true
|
||||
setScaleEnabled(true)
|
||||
setPinchZoom(true)
|
||||
|
||||
xAxis.apply {
|
||||
xAxis.enableGridDashedLine(10f, 10f, 0f)
|
||||
}
|
||||
axisLeft.apply {
|
||||
enableGridDashedLine(10f, 10f, 0f)
|
||||
|
||||
axisMaximum = 300f
|
||||
axisMinimum = 100f
|
||||
}
|
||||
axisRight.isEnabled = false
|
||||
|
||||
//---
|
||||
|
||||
val entries = state.points.mapIndexed { i, v ->
|
||||
Entry(i.toFloat(), v.toFloat())
|
||||
}
|
||||
// create a dataset and give it a type
|
||||
|
||||
if (data != null && data.dataSetCount > 0) {
|
||||
val set1 = data!!.getDataSetByIndex(0) as LineDataSet
|
||||
set1.values = entries
|
||||
set1.notifyDataSetChanged()
|
||||
data!!.notifyDataChanged()
|
||||
notifyDataSetChanged()
|
||||
} else {
|
||||
val set1 = LineDataSet(entries, "DataSet 1")
|
||||
|
||||
set1.setDrawIcons(false)
|
||||
|
||||
// draw dashed line
|
||||
|
||||
// draw dashed line
|
||||
set1.enableDashedLine(10f, 5f, 0f)
|
||||
|
||||
// black lines and points
|
||||
|
||||
// black lines and points
|
||||
set1.color = Color.BLACK
|
||||
set1.setCircleColor(Color.BLACK)
|
||||
|
||||
// line thickness and point size
|
||||
|
||||
// line thickness and point size
|
||||
set1.lineWidth = 1f
|
||||
set1.circleRadius = 3f
|
||||
|
||||
// draw points as solid circles
|
||||
|
||||
// draw points as solid circles
|
||||
set1.setDrawCircleHole(false)
|
||||
|
||||
// customize legend entry
|
||||
|
||||
// customize legend entry
|
||||
set1.formLineWidth = 1f
|
||||
set1.formLineDashEffect = DashPathEffect(floatArrayOf(10f, 5f), 0f)
|
||||
set1.formSize = 15f
|
||||
|
||||
// text size of values
|
||||
|
||||
// text size of values
|
||||
set1.valueTextSize = 9f
|
||||
|
||||
// draw selection line as dashed
|
||||
|
||||
// draw selection line as dashed
|
||||
set1.enableDashedHighlightLine(10f, 5f, 0f)
|
||||
|
||||
// set the filled area
|
||||
|
||||
// set the filled area
|
||||
set1.setDrawFilled(true)
|
||||
set1.fillFormatter = IFillFormatter { _, _ ->
|
||||
axisLeft.axisMinimum
|
||||
}
|
||||
|
||||
// set color of filled area
|
||||
|
||||
// set color of filled area
|
||||
if (Utils.getSDKInt() >= 18) {
|
||||
// drawables only supported on api level 18 and above
|
||||
val drawable = ContextCompat.getDrawable(context, R.drawable.fade_red)
|
||||
set1.fillDrawable = drawable
|
||||
} else {
|
||||
set1.fillColor = Color.BLACK
|
||||
}
|
||||
|
||||
val dataSets = ArrayList<ILineDataSet>()
|
||||
dataSets.add(set1) // add the data sets
|
||||
|
||||
|
||||
// create a data object with the data sets
|
||||
|
||||
// create a data object with the data sets
|
||||
val data = LineData(dataSets)
|
||||
|
||||
// set data
|
||||
|
||||
// set data
|
||||
setData(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateData(points: List<Int>, chart: LineChart) {
|
||||
val entries = points.mapIndexed { i, v ->
|
||||
Entry(i.toFloat(), v.toFloat())
|
||||
}
|
||||
|
||||
with(chart) {
|
||||
if (data != null && data.dataSetCount > 0) {
|
||||
val set1 = data!!.getDataSetByIndex(0) as LineDataSet
|
||||
set1.values = entries
|
||||
set1.notifyDataSetChanged()
|
||||
data!!.notifyDataChanged()
|
||||
notifyDataSetChanged()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
ContentView(state = HRSViewState()) { }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package no.nordicsemi.android.hrs.view
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TopAppBar
|
||||
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.hrs.R
|
||||
import no.nordicsemi.android.hrs.service.HRSService
|
||||
import no.nordicsemi.android.hrs.viewmodel.HRSViewModel
|
||||
import no.nordicsemi.android.hrs.viewmodel.HRSViewState
|
||||
import no.nordicsemi.android.utils.isServiceRunning
|
||||
|
||||
@Composable
|
||||
fun HRSScreen(finishAction: () -> Unit) {
|
||||
val viewModel: HRSViewModel = hiltViewModel()
|
||||
val state = viewModel.state.collectAsState().value
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(state.isScreenActive) {
|
||||
if (!state.isScreenActive) {
|
||||
finishAction()
|
||||
}
|
||||
if (context.isServiceRunning(HRSService::class.java.name)) {
|
||||
val intent = Intent(context, HRSService::class.java)
|
||||
context.stopService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect("start-service") {
|
||||
if (!context.isServiceRunning(HRSService::class.java.name)) {
|
||||
val intent = Intent(context, HRSService::class.java)
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
HRSView(state) { viewModel.onEvent(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HRSView(state: HRSViewState, onEvent: (HRSScreenViewEvent) -> Unit) {
|
||||
Column {
|
||||
TopAppBar(title = { Text(text = stringResource(id = R.string.hrs_title)) })
|
||||
|
||||
ContentView(state) { onEvent(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package no.nordicsemi.android.hrs.view
|
||||
|
||||
sealed class HRSScreenViewEvent
|
||||
|
||||
object DisconnectEvent : HRSScreenViewEvent()
|
||||
@@ -0,0 +1,47 @@
|
||||
package no.nordicsemi.android.hrs.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
import no.nordicsemi.android.hrs.events.HRSAggregatedData
|
||||
import no.nordicsemi.android.hrs.service.HRSDataBroadcast
|
||||
import no.nordicsemi.android.hrs.view.DisconnectEvent
|
||||
import no.nordicsemi.android.hrs.view.HRSScreenViewEvent
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
internal class HRSViewModel @Inject constructor(
|
||||
private val localBroadcast: HRSDataBroadcast
|
||||
) : ViewModel() {
|
||||
|
||||
val state = MutableStateFlow(HRSViewState())
|
||||
|
||||
init {
|
||||
localBroadcast.events.onEach {
|
||||
withContext(Dispatchers.Main) { consumeEvent(it) }
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun consumeEvent(event: HRSAggregatedData) {
|
||||
state.value = state.value.copy(
|
||||
points = event.heartRates,
|
||||
batteryLevel = event.batteryLevel,
|
||||
sensorLocation = event.sensorLocation
|
||||
)
|
||||
}
|
||||
|
||||
fun onEvent(event: HRSScreenViewEvent) {
|
||||
(event as? DisconnectEvent)?.let {
|
||||
onDisconnectButtonClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDisconnectButtonClick() {
|
||||
state.tryEmit(state.value.copy(isScreenActive = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package no.nordicsemi.android.hrs.viewmodel
|
||||
|
||||
data class HRSViewState(
|
||||
val points: List<Int> = listOf(1, 2, 3),
|
||||
val batteryLevel: Int = 0,
|
||||
val sensorLocation: Int = 0,
|
||||
val isScreenActive: Boolean = true
|
||||
)
|
||||
7
feature_hrs/src/main/res/drawable/fade_red.xml
Normal file
7
feature_hrs/src/main/res/drawable/fade_red.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="90"
|
||||
android:startColor="#00ff0000"
|
||||
android:endColor="#ffff0000" />
|
||||
</shape>
|
||||
4
feature_hrs/src/main/res/values/strings.xml
Normal file
4
feature_hrs/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="hrs_title">HRS</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,17 @@
|
||||
package no.nordicsemi.android.hrs
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user