From 411ec8bd685305a3eab0b5155d79c6cb79054a76 Mon Sep 17 00:00:00 2001 From: Robert Buessow Date: Mon, 15 May 2023 16:33:08 +0200 Subject: [PATCH] Get heart rate readings from Wear watch sensor and send to phone. --- .../info/nightscout/rx/weardata/EventData.kt | 12 +- wear/src/main/AndroidManifest.xml | 5 + .../androidaps/di/WearServicesModule.kt | 5 +- .../androidaps/heartrate/HeartRateListener.kt | 109 ++++++++++++++++++ .../watchfaces/utils/BaseWatchFace.kt | 14 +++ wear/src/main/res/values/strings.xml | 3 +- wear/src/main/res/xml/preferences.xml | 7 ++ .../heartrate/HeartRateListenerTest.kt | 104 +++++++++++++++++ 8 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 wear/src/main/java/info/nightscout/androidaps/heartrate/HeartRateListener.kt create mode 100644 wear/src/test/java/info/nightscout/androidaps/heartrate/HeartRateListenerTest.kt 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()) + } +}