diff --git a/app-wear-shared/shared/src/main/java/info/nightscout/rx/weardata/EventData.kt b/app-wear-shared/shared/src/main/java/info/nightscout/rx/weardata/EventData.kt
index a1c2fecced..bc176a8361 100644
--- a/app-wear-shared/shared/src/main/java/info/nightscout/rx/weardata/EventData.kt
+++ b/app-wear-shared/shared/src/main/java/info/nightscout/rx/weardata/EventData.kt
@@ -3,7 +3,8 @@ package info.nightscout.rx.weardata
import info.nightscout.rx.events.Event
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
-import java.util.Objects
+import java.util.*
+import kotlin.collections.ArrayList
@Serializable
sealed class EventData : Event() {
@@ -90,6 +91,15 @@ sealed class EventData : Event() {
@Serializable
data class ActionQuickWizardPreCheck(val guid: String) : EventData()
+ @Serializable
+ data class ActionHeartRate(
+ val samplingStartMillis: Long,
+ val samplingEndMillis: Long,
+ val beatsPerMinute: Int,
+ val device: String): EventData() {
+ override fun toString() = "HR $beatsPerMinute [${Date(samplingStartMillis)}..${Date(samplingEndMillis)}] $device"
+ }
+
@Serializable
data class ActionTempTargetPreCheck(
val command: TempTargetCommand,
diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml
index 1b5db5dc26..2a08a7f623 100644
--- a/wear/src/main/AndroidManifest.xml
+++ b/wear/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
+
@@ -268,6 +269,10 @@
+
+
Unit = { hr -> ctx.startService(IntentWearToMobile(ctx, hr)) }
+
+ fun onDestroy() {
+ aapsLogger.info(LTag.WEAR, "Destroy ${javaClass.simpleName}")
+ (ctx.getSystemService(SENSOR_SERVICE) as SensorManager?)?.unregisterListener(this)
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
+ }
+
+ private fun send(sampler: Sampler) {
+ sampler.heartRate.let { hr ->
+ aapsLogger.info(LTag.WEAR, "Send heart rate $hr")
+ sendHeartRate(hr)
+ }
+ }
+
+ override fun onSensorChanged(event: SensorEvent) {
+ onSensorChanged(event.sensor?.type, event.accuracy, System.currentTimeMillis(), event.values)
+ }
+
+ @VisibleForTesting
+ fun onSensorChanged(sensorType: Int?, accuracy: Int, timestamp: Long, values: FloatArray) {
+ if (sensorType == null || sensorType != Sensor.TYPE_HEART_RATE || values.isEmpty()) {
+ aapsLogger.error(LTag.WEAR, "Invalid SensorEvent $sensorType $accuracy $timestamp ${values.joinToString()}")
+ return
+ }
+ when (accuracy) {
+ SensorManager.SENSOR_STATUS_NO_CONTACT -> return
+ SensorManager.SENSOR_STATUS_UNRELIABLE -> return
+ }
+ val heartRate = values[0].toInt()
+ sampler = sampler.let { s ->
+ if (s == null || s.age(timestamp) !in 0..120_000 ) {
+ Sampler(timestamp, heartRate)
+ } else if (s.age(timestamp) >= samplingIntervalMillis) {
+ send(s)
+ Sampler(timestamp, heartRate)
+ } else {
+ s.addHeartRate(timestamp, heartRate)
+ s
+ }
+ }
+ }
+
+ private class Sampler(timestampMillis: Long, heartRate: Int) {
+ private var startMillis: Long = timestampMillis
+ private var endMillis: Long = timestampMillis
+ private var valueCount: Int = 1
+ private var valueSum: Int = heartRate
+ private val device = (Build.MANUFACTURER ?: "unknown") + " " + (Build.MODEL ?: "unknown")
+
+ val beatsPerMinute get() = valueSum / valueCount
+ val heartRate get() = EventData.ActionHeartRate(startMillis, endMillis, beatsPerMinute, device)
+
+ fun age(timestampMillis: Long) = timestampMillis - startMillis
+
+ fun addHeartRate(timestampMillis: Long, heartRate: Int) {
+ startMillis = min(startMillis, timestampMillis)
+ endMillis = max(endMillis, timestampMillis)
+ valueCount++
+ valueSum += heartRate
+ }
+ }
+}
diff --git a/wear/src/main/java/info/nightscout/androidaps/watchfaces/utils/BaseWatchFace.kt b/wear/src/main/java/info/nightscout/androidaps/watchfaces/utils/BaseWatchFace.kt
index c31325b3cb..3bface142b 100644
--- a/wear/src/main/java/info/nightscout/androidaps/watchfaces/utils/BaseWatchFace.kt
+++ b/wear/src/main/java/info/nightscout/androidaps/watchfaces/utils/BaseWatchFace.kt
@@ -20,6 +20,7 @@ import dagger.android.AndroidInjection
import info.nightscout.androidaps.R
import info.nightscout.androidaps.data.RawDisplayData
import info.nightscout.androidaps.events.EventWearPreferenceChange
+import info.nightscout.androidaps.heartrate.HeartRateListener
import info.nightscout.androidaps.interaction.menus.MainMenuActivity
import info.nightscout.androidaps.interaction.utils.Persistence
import info.nightscout.androidaps.interaction.utils.WearUtil
@@ -99,6 +100,7 @@ abstract class BaseWatchFace : WatchFace() {
private var mLastSvg = ""
private var mLastDirection = ""
+ private var heartRateListener: HeartRateListener? = null
override fun onCreate() {
// Not derived from DaggerService, do injection here
@@ -115,6 +117,7 @@ abstract class BaseWatchFace : WatchFace() {
.subscribe { event: EventWearPreferenceChange ->
simpleUi.updatePreferences()
if (event.changedKey != null && event.changedKey == "delta_granularity") rxBus.send(EventWearToMobile(ActionResendData("BaseWatchFace:onSharedPreferenceChanged")))
+ if (event.changedKey == getString(R.string.key_heart_rate_sampling)) updateHeartRateListener()
if (layoutSet) setDataFields()
invalidate()
}
@@ -139,6 +142,7 @@ abstract class BaseWatchFace : WatchFace() {
layoutView = binding.root
performViewSetup()
rxBus.send(EventWearToMobile(ActionResendData("BaseWatchFace::onCreate")))
+ updateHeartRateListener()
}
private fun forceUpdate() {
@@ -146,6 +150,15 @@ abstract class BaseWatchFace : WatchFace() {
invalidate()
}
+ private fun updateHeartRateListener() {
+ if (sp.getBoolean(R.string.key_heart_rate_sampling, false)) {
+ heartRateListener = heartRateListener ?: HeartRateListener(this, aapsLogger)
+ } else {
+ heartRateListener?.onDestroy()
+ heartRateListener = null
+ }
+ }
+
override fun onTapCommand(tapType: Int, x: Int, y: Int, eventTime: Long) {
binding.chart?.let { chart ->
if (tapType == TAP_TYPE_TAP && x >= chart.left && x <= chart.right && y >= chart.top && y <= chart.bottom) {
@@ -227,6 +240,7 @@ abstract class BaseWatchFace : WatchFace() {
override fun onDestroy() {
disposable.clear()
+ heartRateListener?.onDestroy()
simpleUi.onDestroy()
super.onDestroy()
}
diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml
index 08ecf60ce7..a9f53bd230 100644
--- a/wear/src/main/res/values/strings.xml
+++ b/wear/src/main/res/values/strings.xml
@@ -233,5 +233,6 @@
old
!old!
!err!
-
+ heart_rate_sampling
+ Heart Rate
diff --git a/wear/src/main/res/xml/preferences.xml b/wear/src/main/res/xml/preferences.xml
index 5719c3ea66..3033bc862d 100644
--- a/wear/src/main/res/xml/preferences.xml
+++ b/wear/src/main/res/xml/preferences.xml
@@ -180,4 +180,11 @@
android:summary="Input Design"
android:title="@string/pref_moreWatchfaceSettings" />
+
diff --git a/wear/src/test/java/info/nightscout/androidaps/heartrate/HeartRateListenerTest.kt b/wear/src/test/java/info/nightscout/androidaps/heartrate/HeartRateListenerTest.kt
new file mode 100644
index 0000000000..925a5af500
--- /dev/null
+++ b/wear/src/test/java/info/nightscout/androidaps/heartrate/HeartRateListenerTest.kt
@@ -0,0 +1,104 @@
+package info.nightscout.androidaps.heartrate
+
+import android.content.Context
+import android.hardware.Sensor
+import android.hardware.SensorManager
+import info.nightscout.rx.logging.AAPSLoggerTest
+import info.nightscout.rx.weardata.EventData.ActionHeartRate
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.mockito.Mockito.mock
+
+internal class HeartRateListenerTest {
+ private val aapsLogger = AAPSLoggerTest()
+ private val heartRates = mutableListOf()
+ private val device = "unknown unknown"
+
+ private fun create(): HeartRateListener {
+ val ctx = mock(Context::class.java)
+ val listener = HeartRateListener(ctx, aapsLogger)
+ listener.sendHeartRate = { hr -> heartRates.add(hr) }
+ return listener
+ }
+
+ private fun sendSensorEvent(
+ listener: HeartRateListener,
+ timestamp: Long,
+ heartRate: Int,
+ sensorType: Int? = Sensor.TYPE_HEART_RATE,
+ accuracy: Int = SensorManager.SENSOR_STATUS_ACCURACY_HIGH) {
+ listener.onSensorChanged(sensorType, accuracy, timestamp, floatArrayOf(heartRate.toFloat()))
+ }
+
+ @BeforeEach
+ fun init() {
+ heartRates.clear()
+ }
+
+ @Test
+ fun onSensorChanged() {
+ val listener = create()
+ val start = System.currentTimeMillis()
+ sendSensorEvent(listener, start, 80)
+ assertEquals(0, heartRates.size)
+ sendSensorEvent(listener, start + 60_001L,180)
+ assertEquals(1, heartRates.size)
+ assertEquals(ActionHeartRate(start, start, 80, device), heartRates.first())
+ }
+
+ @Test
+ fun onSensorChanged2() {
+ val listener = create()
+ val start = System.currentTimeMillis()
+ sendSensorEvent(listener, start, 80)
+ assertEquals(0, heartRates.size)
+ sendSensorEvent(listener, start + 1000L,100)
+ assertEquals(0, heartRates.size)
+ sendSensorEvent(listener, start + 60_001L,180)
+ assertEquals(1, heartRates.size)
+ assertEquals(ActionHeartRate(start, start + 1000L, 90, device), heartRates.first())
+ }
+
+ @Test
+ fun onSensorChangedOldValue() {
+ val listener = create()
+ val start = System.currentTimeMillis()
+ sendSensorEvent(listener, start, 80)
+ assertEquals(0, heartRates.size)
+ val start2 = start + 120_001L
+ sendSensorEvent(listener, start2, 100)
+ assertEquals(0, heartRates.size)
+ sendSensorEvent(listener, start + 180_001L, 180)
+ assertEquals(1, heartRates.size)
+ assertEquals(ActionHeartRate(start2, start2, 100, device), heartRates.first())
+ }
+
+ @Test
+ fun onSensorChangedMultiple() {
+ val listener = create()
+ val start = System.currentTimeMillis()
+ sendSensorEvent(listener, start, 80)
+ assertEquals(0, heartRates.size)
+ val start2 = start + 60_000L
+ sendSensorEvent(listener, start2,100)
+ assertEquals(1, heartRates.size)
+ sendSensorEvent(listener, start2 + 60_000L,180)
+ assertEquals(2, heartRates.size)
+ assertEquals(ActionHeartRate(start, start, 80, device), heartRates[0])
+ assertEquals(ActionHeartRate(start2, start2, 100, device), heartRates[1])
+ }
+
+ @Test
+ fun onSensorChangedNoContact() {
+ val listener = create()
+ val start = System.currentTimeMillis()
+ sendSensorEvent(listener, start, 80)
+ assertEquals(0, heartRates.size)
+ sendSensorEvent(listener, start + 1000L, 100, accuracy = SensorManager.SENSOR_STATUS_NO_CONTACT)
+ assertEquals(0, heartRates.size)
+ sendSensorEvent(listener, start + 60_001L, 180)
+ assertEquals(1, heartRates.size)
+ assertEquals(ActionHeartRate(start, start, 80, device), heartRates.first())
+ }
+}