Get heart rate readings from Wear watch sensor and send to phone.
This commit is contained in:
parent
7f0c361ca2
commit
411ec8bd68
8 changed files with 255 additions and 4 deletions
|
@ -3,7 +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.Objects
|
import java.util.*
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class EventData : Event() {
|
sealed class EventData : Event() {
|
||||||
|
@ -90,6 +91,15 @@ sealed class EventData : Event() {
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ActionQuickWizardPreCheck(val guid: String) : EventData()
|
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
|
@Serializable
|
||||||
data class ActionTempTargetPreCheck(
|
data class ActionTempTargetPreCheck(
|
||||||
val command: TempTargetCommand,
|
val command: TempTargetCommand,
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
<uses-permission android:name="com.google.android.permission.PROVIDE_BACKGROUND" />
|
<uses-permission android:name="com.google.android.permission.PROVIDE_BACKGROUND" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.BODY_SENSORS" />
|
||||||
|
|
||||||
<uses-sdk tools:overrideLibrary="androidx.wear.tiles" />
|
<uses-sdk tools:overrideLibrary="androidx.wear.tiles" />
|
||||||
|
|
||||||
|
@ -268,6 +269,10 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".heartrate.HeartRateListener"
|
||||||
|
android:exported="true" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".complications.LongStatusComplication"
|
android:name=".complications.LongStatusComplication"
|
||||||
android:icon="@drawable/ic_aaps_full"
|
android:icon="@drawable/ic_aaps_full"
|
||||||
|
|
|
@ -4,6 +4,7 @@ import dagger.Module
|
||||||
import dagger.android.ContributesAndroidInjector
|
import dagger.android.ContributesAndroidInjector
|
||||||
import info.nightscout.androidaps.comm.DataLayerListenerServiceWear
|
import info.nightscout.androidaps.comm.DataLayerListenerServiceWear
|
||||||
import info.nightscout.androidaps.complications.*
|
import info.nightscout.androidaps.complications.*
|
||||||
|
import info.nightscout.androidaps.heartrate.HeartRateListener
|
||||||
import info.nightscout.androidaps.tile.*
|
import info.nightscout.androidaps.tile.*
|
||||||
import info.nightscout.androidaps.watchfaces.*
|
import info.nightscout.androidaps.watchfaces.*
|
||||||
import info.nightscout.androidaps.watchfaces.utils.BaseWatchFace
|
import info.nightscout.androidaps.watchfaces.utils.BaseWatchFace
|
||||||
|
@ -13,7 +14,7 @@ import info.nightscout.androidaps.watchfaces.utils.BaseWatchFace
|
||||||
abstract class WearServicesModule {
|
abstract class WearServicesModule {
|
||||||
|
|
||||||
@ContributesAndroidInjector abstract fun contributesDataLayerListenerService(): DataLayerListenerServiceWear
|
@ContributesAndroidInjector abstract fun contributesDataLayerListenerService(): DataLayerListenerServiceWear
|
||||||
|
@ContributesAndroidInjector abstract fun contributesHeartRateListenerService(): HeartRateListener
|
||||||
@ContributesAndroidInjector abstract fun contributesBaseComplicationProviderService(): BaseComplicationProviderService
|
@ContributesAndroidInjector abstract fun contributesBaseComplicationProviderService(): BaseComplicationProviderService
|
||||||
@ContributesAndroidInjector abstract fun contributesBrCobIobComplication(): BrCobIobComplication
|
@ContributesAndroidInjector abstract fun contributesBrCobIobComplication(): BrCobIobComplication
|
||||||
@ContributesAndroidInjector abstract fun contributesCobDetailedComplication(): CobDetailedComplication
|
@ContributesAndroidInjector abstract fun contributesCobDetailedComplication(): CobDetailedComplication
|
||||||
|
@ -45,4 +46,4 @@ abstract class WearServicesModule {
|
||||||
@ContributesAndroidInjector abstract fun contributesTempTargetTileService(): TempTargetTileService
|
@ContributesAndroidInjector abstract fun contributesTempTargetTileService(): TempTargetTileService
|
||||||
@ContributesAndroidInjector abstract fun contributesActionsTileService(): ActionsTileService
|
@ContributesAndroidInjector abstract fun contributesActionsTileService(): ActionsTileService
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
package info.nightscout.androidaps.heartrate
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Context.SENSOR_SERVICE
|
||||||
|
import android.hardware.Sensor
|
||||||
|
import android.hardware.SensorEvent
|
||||||
|
import android.hardware.SensorEventListener
|
||||||
|
import android.hardware.SensorManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import info.nightscout.androidaps.comm.IntentWearToMobile
|
||||||
|
import info.nightscout.rx.logging.AAPSLogger
|
||||||
|
import info.nightscout.rx.logging.LTag
|
||||||
|
import info.nightscout.rx.weardata.EventData
|
||||||
|
import java.lang.Long.max
|
||||||
|
import java.lang.Long.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets heart rate readings from watch and sends them once per minute to the phone.
|
||||||
|
*/
|
||||||
|
class HeartRateListener(
|
||||||
|
private val ctx: Context,
|
||||||
|
private val aapsLogger: AAPSLogger
|
||||||
|
) : SensorEventListener {
|
||||||
|
|
||||||
|
private val samplingIntervalMillis = 60_000L
|
||||||
|
private var sampler: Sampler? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
aapsLogger.info(LTag.WEAR, "Create ${javaClass.simpleName}")
|
||||||
|
val sensorManager = ctx.getSystemService(SENSOR_SERVICE) as SensorManager?
|
||||||
|
if (sensorManager == null) {
|
||||||
|
aapsLogger.warn(LTag.WEAR, "Cannot get sensor manager to get heart rate readings")
|
||||||
|
} else {
|
||||||
|
val heartRateSensor = sensorManager.getDefaultSensor(Sensor.TYPE_HEART_RATE)
|
||||||
|
if (heartRateSensor == null) {
|
||||||
|
aapsLogger.warn(LTag.WEAR, "Cannot get heart rate sensor")
|
||||||
|
} else {
|
||||||
|
sensorManager.registerListener(this, heartRateSensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
var sendHeartRate: (EventData.ActionHeartRate)->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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,6 +20,7 @@ import dagger.android.AndroidInjection
|
||||||
import info.nightscout.androidaps.R
|
import info.nightscout.androidaps.R
|
||||||
import info.nightscout.androidaps.data.RawDisplayData
|
import info.nightscout.androidaps.data.RawDisplayData
|
||||||
import info.nightscout.androidaps.events.EventWearPreferenceChange
|
import info.nightscout.androidaps.events.EventWearPreferenceChange
|
||||||
|
import info.nightscout.androidaps.heartrate.HeartRateListener
|
||||||
import info.nightscout.androidaps.interaction.menus.MainMenuActivity
|
import info.nightscout.androidaps.interaction.menus.MainMenuActivity
|
||||||
import info.nightscout.androidaps.interaction.utils.Persistence
|
import info.nightscout.androidaps.interaction.utils.Persistence
|
||||||
import info.nightscout.androidaps.interaction.utils.WearUtil
|
import info.nightscout.androidaps.interaction.utils.WearUtil
|
||||||
|
@ -99,6 +100,7 @@ abstract class BaseWatchFace : WatchFace() {
|
||||||
|
|
||||||
private var mLastSvg = ""
|
private var mLastSvg = ""
|
||||||
private var mLastDirection = ""
|
private var mLastDirection = ""
|
||||||
|
private var heartRateListener: HeartRateListener? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
// Not derived from DaggerService, do injection here
|
// Not derived from DaggerService, do injection here
|
||||||
|
@ -115,6 +117,7 @@ abstract class BaseWatchFace : WatchFace() {
|
||||||
.subscribe { event: EventWearPreferenceChange ->
|
.subscribe { event: EventWearPreferenceChange ->
|
||||||
simpleUi.updatePreferences()
|
simpleUi.updatePreferences()
|
||||||
if (event.changedKey != null && event.changedKey == "delta_granularity") rxBus.send(EventWearToMobile(ActionResendData("BaseWatchFace:onSharedPreferenceChanged")))
|
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()
|
if (layoutSet) setDataFields()
|
||||||
invalidate()
|
invalidate()
|
||||||
}
|
}
|
||||||
|
@ -139,6 +142,7 @@ abstract class BaseWatchFace : WatchFace() {
|
||||||
layoutView = binding.root
|
layoutView = binding.root
|
||||||
performViewSetup()
|
performViewSetup()
|
||||||
rxBus.send(EventWearToMobile(ActionResendData("BaseWatchFace::onCreate")))
|
rxBus.send(EventWearToMobile(ActionResendData("BaseWatchFace::onCreate")))
|
||||||
|
updateHeartRateListener()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun forceUpdate() {
|
private fun forceUpdate() {
|
||||||
|
@ -146,6 +150,15 @@ abstract class BaseWatchFace : WatchFace() {
|
||||||
invalidate()
|
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) {
|
override fun onTapCommand(tapType: Int, x: Int, y: Int, eventTime: Long) {
|
||||||
binding.chart?.let { chart ->
|
binding.chart?.let { chart ->
|
||||||
if (tapType == TAP_TYPE_TAP && x >= chart.left && x <= chart.right && y >= chart.top && y <= chart.bottom) {
|
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() {
|
override fun onDestroy() {
|
||||||
disposable.clear()
|
disposable.clear()
|
||||||
|
heartRateListener?.onDestroy()
|
||||||
simpleUi.onDestroy()
|
simpleUi.onDestroy()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
|
@ -233,5 +233,6 @@
|
||||||
<string name="old">old</string>
|
<string name="old">old</string>
|
||||||
<string name="old_warning">!old!</string>
|
<string name="old_warning">!old!</string>
|
||||||
<string name="error">!err!</string>
|
<string name="error">!err!</string>
|
||||||
|
<string name="key_heart_rate_sampling" translatable="false">heart_rate_sampling</string>
|
||||||
|
<string name="pref_heartRateSampling">Heart Rate</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -180,4 +180,11 @@
|
||||||
android:summary="Input Design"
|
android:summary="Input Design"
|
||||||
android:title="@string/pref_moreWatchfaceSettings" />
|
android:title="@string/pref_moreWatchfaceSettings" />
|
||||||
|
|
||||||
|
<CheckBoxPreference
|
||||||
|
android:defaultValue="false"
|
||||||
|
android:key="@string/key_heart_rate_sampling"
|
||||||
|
android:summary="Enable heart rate sampling."
|
||||||
|
android:title="@string/pref_heartRateSampling"
|
||||||
|
app:wear_iconOff="@drawable/settings_off"
|
||||||
|
app:wear_iconOn="@drawable/settings_on" />
|
||||||
</PreferenceScreen>
|
</PreferenceScreen>
|
||||||
|
|
|
@ -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<ActionHeartRate>()
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue