Rewrite HeartRateListener to better handle watches that don't regularely send heart rate events.

Make ActionHeartRate and HeartRate consistent.
This commit is contained in:
robertbuessow 2023-05-22 17:35:23 +02:00
parent 7f14535852
commit b82aeb9379
6 changed files with 240 additions and 99 deletions

View file

@ -3,8 +3,8 @@ package info.nightscout.rx.weardata
import info.nightscout.rx.events.Event import info.nightscout.rx.events.Event
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.util.* import org.joda.time.DateTime
import kotlin.collections.ArrayList import java.util.Objects
@Serializable @Serializable
sealed class EventData : Event() { sealed class EventData : Event() {
@ -93,11 +93,12 @@ sealed class EventData : Event() {
@Serializable @Serializable
data class ActionHeartRate( data class ActionHeartRate(
val samplingStartMillis: Long, val duration: Long,
val samplingEndMillis: Long, val timestamp: Long,
val beatsPerMinute: Int, val beatsPerMinute: Double,
val device: String): EventData() { val device: String): EventData() {
override fun toString() = "HR $beatsPerMinute [${Date(samplingStartMillis)}..${Date(samplingEndMillis)}] $device" override fun toString() =
"HR ${beatsPerMinute.toInt()} at ${DateTime(timestamp)} for ${duration / 1000.0}sec $device"
} }
@Serializable @Serializable

View file

@ -1241,10 +1241,10 @@ class DataHandlerMobile @Inject constructor(
private fun handleHeartRate(actionHeartRate: EventData.ActionHeartRate) { private fun handleHeartRate(actionHeartRate: EventData.ActionHeartRate) {
aapsLogger.debug(LTag.WEAR, "Heart rate received $actionHeartRate from ${actionHeartRate.sourceNodeId}") aapsLogger.debug(LTag.WEAR, "Heart rate received $actionHeartRate from ${actionHeartRate.sourceNodeId}")
val hr = HeartRate( val hr = HeartRate(
duration = actionHeartRate.samplingEndMillis - actionHeartRate.samplingStartMillis, duration = actionHeartRate.duration,
timestamp = actionHeartRate.samplingEndMillis, timestamp = actionHeartRate.timestamp,
beatsPerMinute = actionHeartRate.beatsPerMinute.toDouble(), beatsPerMinute = actionHeartRate.beatsPerMinute,
device = actionHeartRate.device) device = actionHeartRate.device)
repository.runTransaction(InsertOrUpdateHeartRateTransaction(hr)) repository.runTransaction(InsertOrUpdateHeartRateTransaction(hr)).blockingAwait()
} }
} }

View file

@ -9,22 +9,54 @@ import android.hardware.SensorManager
import android.os.Build import android.os.Build
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import info.nightscout.androidaps.comm.IntentWearToMobile import info.nightscout.androidaps.comm.IntentWearToMobile
import info.nightscout.rx.AapsSchedulers
import info.nightscout.rx.logging.AAPSLogger import info.nightscout.rx.logging.AAPSLogger
import info.nightscout.rx.logging.LTag import info.nightscout.rx.logging.LTag
import info.nightscout.rx.weardata.EventData import info.nightscout.rx.weardata.EventData
import java.lang.Long.max import io.reactivex.rxjava3.disposables.Disposable
import java.lang.Long.min import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.math.roundToInt
/** /**
* Gets heart rate readings from watch and sends them once per minute to the phone. * Gets heart rate readings from watch and sends them to the phone.
*
* The Android API doesn't define how often heart rate events are sent do the
* listener, it could be once per second or only when the heart rate changes.
*
* Heart rate is not a point in time measurement but is always sampled over a
* certain time, i.e. you count the the number of heart beats and divide by the
* minutes that have passed. Therefore, the provided value has to be for the past.
* However, we ignore this here.
*
* We don't need very exact values, but rather values that are easy to consume
* and don't produce too much data that would cause much battery consumption.
* Therefore, this class averages the heart rate over a minute ([samplingIntervalMillis])
* and sends this value to the phone.
*
* We will not always get valid values, e.g. if the watch is taken of. The listener
* ignores such time unless we don't get good values for more than 90% of time. Since
* heart rate doesn't change so fast this should be good enough.
*/ */
class HeartRateListener( class HeartRateListener(
private val ctx: Context, private val ctx: Context,
private val aapsLogger: AAPSLogger private val aapsLogger: AAPSLogger,
) : SensorEventListener { aapsSchedulers: AapsSchedulers,
now: Long = System.currentTimeMillis(),
) : SensorEventListener, Disposable {
/** How often we send values to the phone. */
private val samplingIntervalMillis = 60_000L private val samplingIntervalMillis = 60_000L
private var sampler: Sampler? = null private val sampler = Sampler(now)
private var schedule: Disposable? = null
/** We only use values with these accuracies and ignore NO_CONTACT and UNRELIABLE. */
private val goodAccuracies = arrayOf(
SensorManager.SENSOR_STATUS_ACCURACY_LOW,
SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM,
SensorManager.SENSOR_STATUS_ACCURACY_HIGH,
)
init { init {
aapsLogger.info(LTag.WEAR, "Create ${javaClass.simpleName}") aapsLogger.info(LTag.WEAR, "Create ${javaClass.simpleName}")
@ -39,71 +71,116 @@ class HeartRateListener(
sensorManager.registerListener(this, heartRateSensor, SensorManager.SENSOR_DELAY_NORMAL) sensorManager.registerListener(this, heartRateSensor, SensorManager.SENSOR_DELAY_NORMAL)
} }
} }
schedule = aapsSchedulers.io.schedulePeriodicallyDirect(
::send, samplingIntervalMillis, samplingIntervalMillis, TimeUnit.MILLISECONDS)
} }
/**
* Gets the most recent heart rate reading and null if there is no valid
* value at the moment.
*/
val currentHeartRateBpm get() = sampler.currentBpm?.roundToInt()
@VisibleForTesting @VisibleForTesting
var sendHeartRate: (EventData.ActionHeartRate)->Unit = { hr -> ctx.startService(IntentWearToMobile(ctx, hr)) } var sendHeartRate: (EventData.ActionHeartRate)->Unit = { hr -> ctx.startService(IntentWearToMobile(ctx, hr)) }
fun onDestroy() { override fun isDisposed() = schedule == null
aapsLogger.info(LTag.WEAR, "Destroy ${javaClass.simpleName}")
override fun dispose() {
aapsLogger.info(LTag.WEAR, "Dispose ${javaClass.simpleName}")
schedule?.dispose()
(ctx.getSystemService(SENSOR_SERVICE) as SensorManager?)?.unregisterListener(this) (ctx.getSystemService(SENSOR_SERVICE) as SensorManager?)?.unregisterListener(this)
} }
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { /** Sends currently sampled value to the phone. Executed every [samplingIntervalMillis]. */
private fun send() {
send(System.currentTimeMillis())
} }
private fun send(sampler: Sampler) { @VisibleForTesting
sampler.heartRate.let { hr -> fun send(timestampMillis: Long) {
sampler.getAndReset(timestampMillis)?.let { hr ->
aapsLogger.info(LTag.WEAR, "Send heart rate $hr") aapsLogger.info(LTag.WEAR, "Send heart rate $hr")
sendHeartRate(hr) sendHeartRate(hr)
} }
} }
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
onAccuracyChanged(sensor.type, accuracy, System.currentTimeMillis())
}
@VisibleForTesting
fun onAccuracyChanged(sensorType: Int, accuracy: Int, timestampMillis: Long) {
if (sensorType != Sensor.TYPE_HEART_RATE) {
aapsLogger.error(LTag.WEAR, "Invalid SensorEvent $sensorType $accuracy")
return
}
if (accuracy !in goodAccuracies) sampler.setHeartRate(timestampMillis, null)
}
override fun onSensorChanged(event: SensorEvent) { override fun onSensorChanged(event: SensorEvent) {
onSensorChanged(event.sensor?.type, event.accuracy, System.currentTimeMillis(), event.values) onSensorChanged(event.sensor?.type, event.accuracy, System.currentTimeMillis(), event.values)
} }
@VisibleForTesting @VisibleForTesting
fun onSensorChanged(sensorType: Int?, accuracy: Int, timestamp: Long, values: FloatArray) { fun onSensorChanged(sensorType: Int?, accuracy: Int, timestampMillis: Long, values: FloatArray) {
if (sensorType == null || sensorType != Sensor.TYPE_HEART_RATE || values.isEmpty()) { if (sensorType == null || sensorType != Sensor.TYPE_HEART_RATE || values.isEmpty()) {
aapsLogger.error(LTag.WEAR, "Invalid SensorEvent $sensorType $accuracy $timestamp ${values.joinToString()}") aapsLogger.error(LTag.WEAR, "Invalid SensorEvent $sensorType $accuracy $timestampMillis ${values.joinToString()}")
return return
} }
when (accuracy) { val heartRate = values[0].toDouble().takeIf { accuracy in goodAccuracies }
SensorManager.SENSOR_STATUS_NO_CONTACT -> return sampler.setHeartRate(timestampMillis, heartRate)
SensorManager.SENSOR_STATUS_UNRELIABLE -> return }
private class Sampler(timestampMillis: Long) {
private var startMillis: Long = timestampMillis
private var lastEventMillis: Long = timestampMillis
/** Number of heart beats sampled so far. */
private var beats: Double = 0.0
/** Time we could sample valid values during the current sampling interval. */
private var activeMillis: Long = 0
private val device = (Build.MANUFACTURER ?: "unknown") + " " + (Build.MODEL ?: "unknown")
private val lock = ReentrantLock()
var currentBpm: Double? = null
get() = field
private set(value) { field = value }
private fun Long.toMinute(): Double = this / 60_000.0
private fun fix(timestampMillis: Long) {
currentBpm?.let { bpm ->
val elapsed = timestampMillis - lastEventMillis
beats += elapsed.toMinute() * bpm
activeMillis += elapsed
}
lastEventMillis = timestampMillis
} }
val heartRate = values[0].toInt()
sampler = sampler.let { s -> /** Gets the current sampled value and resets the samplers clock to the given timestamp. */
if (s == null || s.age(timestamp) !in 0..120_000 ) { fun getAndReset(timestampMillis: Long): EventData.ActionHeartRate? {
Sampler(timestamp, heartRate) lock.withLock {
} else if (s.age(timestamp) >= samplingIntervalMillis) { fix(timestampMillis)
send(s) return if (10 * activeMillis > lastEventMillis - startMillis) {
Sampler(timestamp, heartRate) val bpm = beats / activeMillis.toMinute()
} else { EventData.ActionHeartRate(timestampMillis - startMillis, timestampMillis, bpm, device)
s.addHeartRate(timestamp, heartRate) } else {
s null
}.also {
startMillis = timestampMillis
lastEventMillis = timestampMillis
beats = 0.0
activeMillis = 0
}
}
}
fun setHeartRate(timestampMillis: Long, heartRate: Double?) {
lock.withLock {
if (timestampMillis < lastEventMillis) return
fix(timestampMillis)
currentBpm = heartRate
} }
} }
} }
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
}
}
} }

View file

@ -1,6 +1,5 @@
package info.nightscout.androidaps.interaction package info.nightscout.androidaps.interaction
import preference.WearPreferenceActivity
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -9,7 +8,7 @@ import dagger.android.AndroidInjection
import info.nightscout.androidaps.R import info.nightscout.androidaps.R
import info.nightscout.rx.logging.AAPSLogger import info.nightscout.rx.logging.AAPSLogger
import info.nightscout.rx.logging.LTag import info.nightscout.rx.logging.LTag
import preference.WearPreferenceActivity
import javax.inject.Inject import javax.inject.Inject
class ConfigurationActivity : WearPreferenceActivity() { class ConfigurationActivity : WearPreferenceActivity() {

View file

@ -152,10 +152,15 @@ abstract class BaseWatchFace : WatchFace() {
private fun updateHeartRateListener() { private fun updateHeartRateListener() {
if (sp.getBoolean(R.string.key_heart_rate_sampling, false)) { if (sp.getBoolean(R.string.key_heart_rate_sampling, false)) {
heartRateListener = heartRateListener ?: HeartRateListener(this, aapsLogger) if (heartRateListener == null) {
heartRateListener = HeartRateListener(
this, aapsLogger, aapsSchedulers).also { hrl -> disposable += hrl }
}
} else { } else {
heartRateListener?.onDestroy() heartRateListener?.let { hrl ->
heartRateListener = null disposable.remove(hrl)
heartRateListener = null
}
} }
} }
@ -240,7 +245,6 @@ abstract class BaseWatchFace : WatchFace() {
override fun onDestroy() { override fun onDestroy() {
disposable.clear() disposable.clear()
heartRateListener?.onDestroy()
simpleUi.onDestroy() simpleUi.onDestroy()
super.onDestroy() super.onDestroy()
} }

View file

@ -3,21 +3,43 @@ package info.nightscout.androidaps.heartrate
import android.content.Context import android.content.Context
import android.hardware.Sensor import android.hardware.Sensor
import android.hardware.SensorManager import android.hardware.SensorManager
import info.nightscout.rx.AapsSchedulers
import info.nightscout.rx.logging.AAPSLoggerTest import info.nightscout.rx.logging.AAPSLoggerTest
import info.nightscout.rx.weardata.EventData.ActionHeartRate import info.nightscout.rx.weardata.EventData.ActionHeartRate
import io.reactivex.rxjava3.core.Scheduler
import io.reactivex.rxjava3.disposables.Disposable
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mockito
import org.mockito.Mockito.mock import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import java.util.concurrent.TimeUnit
internal class HeartRateListenerTest { internal class HeartRateListenerTest {
private val aapsLogger = AAPSLoggerTest() private val aapsLogger = AAPSLoggerTest()
private val aapsSchedulers = object: AapsSchedulers {
override val main: Scheduler = mock(Scheduler::class.java)
override val io: Scheduler = mock(Scheduler::class.java)
override val cpu: Scheduler = mock(Scheduler::class.java)
override val newThread: Scheduler = mock(Scheduler::class.java)
}
private val schedule = mock(Disposable::class.java)
private val heartRates = mutableListOf<ActionHeartRate>() private val heartRates = mutableListOf<ActionHeartRate>()
private val device = "unknown unknown" private val device = "unknown unknown"
private fun create(): HeartRateListener { private fun create(timestampMillis: Long): HeartRateListener {
val ctx = mock(Context::class.java) val ctx = mock(Context::class.java)
val listener = HeartRateListener(ctx, aapsLogger) `when`(aapsSchedulers.io.schedulePeriodicallyDirect(
any(), eq(60_000L), eq(60_000L), eq(TimeUnit.MILLISECONDS))).thenReturn(schedule)
val listener = HeartRateListener(ctx, aapsLogger, aapsSchedulers, timestampMillis)
verify(aapsSchedulers.io).schedulePeriodicallyDirect(
any(), eq(60_000L), eq(60_000L), eq(TimeUnit.MILLISECONDS))
listener.sendHeartRate = { hr -> heartRates.add(hr) } listener.sendHeartRate = { hr -> heartRates.add(hr) }
return listener return listener
} }
@ -32,73 +54,111 @@ internal class HeartRateListenerTest {
} }
@BeforeEach @BeforeEach
fun init() { fun before() {
heartRates.clear() heartRates.clear()
} }
@AfterEach
fun cleanup() {
Mockito.verifyNoInteractions(aapsSchedulers.main)
Mockito.verifyNoMoreInteractions(aapsSchedulers.io)
Mockito.verifyNoInteractions(aapsSchedulers.cpu)
Mockito.verifyNoInteractions(aapsSchedulers.newThread)
verify(schedule).dispose()
}
@Test @Test
fun onSensorChanged() { fun onSensorChanged() {
val listener = create()
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
sendSensorEvent(listener, start, 80) val d1 = 10_000L
val d2 = 20_000L
val listener = create(start)
assertNull(listener.currentHeartRateBpm)
sendSensorEvent(listener, start + d1, 80)
assertEquals(0, heartRates.size) assertEquals(0, heartRates.size)
sendSensorEvent(listener, start + 60_001L,180) assertEquals(80, listener.currentHeartRateBpm)
listener.send(start + d2)
assertEquals(1, heartRates.size) assertEquals(1, heartRates.size)
assertEquals(ActionHeartRate(start, start, 80, device), heartRates.first()) assertEquals(ActionHeartRate(d2, start + d2, 80.0, device), heartRates.first())
listener.dispose()
} }
@Test @Test
fun onSensorChanged2() { fun onSensorChanged2() {
val listener = create()
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
sendSensorEvent(listener, start, 80) val d1 = 10_000L
assertEquals(0, heartRates.size) val d2 = 40_000L
sendSensorEvent(listener, start + 1000L,100) val listener = create(start)
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) sendSensorEvent(listener, start, 80)
assertEquals(0, heartRates.size) assertEquals(0, heartRates.size)
val start2 = start + 120_001L assertEquals(80, listener.currentHeartRateBpm)
sendSensorEvent(listener, start2, 100) sendSensorEvent(listener, start + d1,100)
assertEquals(0, heartRates.size) assertEquals(0, heartRates.size)
sendSensorEvent(listener, start + 180_001L, 180) assertEquals(100, listener.currentHeartRateBpm)
listener.send(start + d2)
assertEquals(1, heartRates.size) assertEquals(1, heartRates.size)
assertEquals(ActionHeartRate(start2, start2, 100, device), heartRates.first()) assertEquals(ActionHeartRate(d2, start + d2, 95.0, device), heartRates.first())
listener.dispose()
} }
@Test @Test
fun onSensorChangedMultiple() { fun onSensorChangedMultiple() {
val listener = create()
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val d1 = 10_000L
val d2 = 40_000L
val listener = create(start)
sendSensorEvent(listener, start, 80) sendSensorEvent(listener, start, 80)
assertEquals(0, heartRates.size) listener.send(start + d1)
val start2 = start + 60_000L
sendSensorEvent(listener, start2,100)
assertEquals(1, heartRates.size) assertEquals(1, heartRates.size)
sendSensorEvent(listener, start2 + 60_000L,180)
sendSensorEvent(listener, start + d1,100)
assertEquals(1, heartRates.size)
listener.send(start + d2)
assertEquals(2, heartRates.size) assertEquals(2, heartRates.size)
assertEquals(ActionHeartRate(start, start, 80, device), heartRates[0])
assertEquals(ActionHeartRate(start2, start2, 100, device), heartRates[1]) assertEquals(ActionHeartRate(d1, start + d1, 80.0, device), heartRates[0])
assertEquals(ActionHeartRate(d2 - d1, start + d2, 100.0, device), heartRates[1])
listener.dispose()
} }
@Test @Test
fun onSensorChangedNoContact() { fun onSensorChangedNoContact() {
val listener = create()
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val d1 = 10_000L
val d2 = 40_000L
val listener = create(start)
sendSensorEvent(listener, start, 80) sendSensorEvent(listener, start, 80)
assertEquals(0, heartRates.size) sendSensorEvent(listener, start + d1, 100, accuracy = SensorManager.SENSOR_STATUS_NO_CONTACT)
sendSensorEvent(listener, start + 1000L, 100, accuracy = SensorManager.SENSOR_STATUS_NO_CONTACT) assertNull(listener.currentHeartRateBpm)
assertEquals(0, heartRates.size) listener.send(start + d2)
sendSensorEvent(listener, start + 60_001L, 180)
assertEquals(1, heartRates.size) assertEquals(1, heartRates.size)
assertEquals(ActionHeartRate(start, start, 80, device), heartRates.first()) assertEquals(ActionHeartRate(d2, start + d2, 80.0, device), heartRates.first())
listener.dispose()
}
@Test
fun onAccuracyChanged() {
val start = System.currentTimeMillis()
val d1 = 10_000L
val d2 = 40_000L
val d3 = 70_000L
val listener = create(start)
sendSensorEvent(listener, start, 80)
listener.onAccuracyChanged(Sensor.TYPE_HEART_RATE, SensorManager.SENSOR_STATUS_UNRELIABLE, start + d1)
sendSensorEvent(listener, start + d2, 100)
listener.send(start + d3)
assertEquals(1, heartRates.size)
assertEquals(ActionHeartRate(d3, start + d3, 95.0, device), heartRates.first())
listener.dispose()
} }
} }