From ecea212c83a2666305143b80ae02a131eb52cd58 Mon Sep 17 00:00:00 2001 From: Robert Buessow Date: Wed, 10 May 2023 17:02:14 +0200 Subject: [PATCH 1/6] Support heart rate in overview graph --- .../activities/HistoryBrowseActivity.kt | 3 +++ .../nightscout/core/graph/OverviewData.kt | 5 +++- .../core/graph/data/HeartRateDataPoint.kt | 24 +++++++++++++++++++ .../data/PointsWithLabelGraphSeries.java | 7 +++++- .../interfaces/overview/OverviewMenus.kt | 5 ++-- core/ui/src/main/res/values-night/styles.xml | 1 + core/ui/src/main/res/values/attrs.xml | 3 ++- core/ui/src/main/res/values/colors.xml | 1 + core/ui/src/main/res/values/styles.xml | 1 + .../overview/OverviewDataImpl.kt | 3 +++ .../general/overview/OverviewFragment.kt | 6 ++++- .../general/overview/OverviewMenusImpl.kt | 5 ++-- .../general/overview/graphData/GraphData.kt | 11 ++++++++- plugins/main/src/main/res/values/strings.xml | 2 ++ .../workflow/PrepareTreatmentsDataWorker.kt | 10 +++++++- 15 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 core/graph/src/main/java/info/nightscout/core/graph/data/HeartRateDataPoint.kt diff --git a/app/src/main/java/info/nightscout/androidaps/activities/HistoryBrowseActivity.kt b/app/src/main/java/info/nightscout/androidaps/activities/HistoryBrowseActivity.kt index a22c9ba427..83b3813f61 100644 --- a/app/src/main/java/info/nightscout/androidaps/activities/HistoryBrowseActivity.kt +++ b/app/src/main/java/info/nightscout/androidaps/activities/HistoryBrowseActivity.kt @@ -327,6 +327,7 @@ class HistoryBrowseActivity : TranslatedDaggerAppCompatActivity() { var useRatioForScale = false var useDSForScale = false var useBGIForScale = false + var useHRForScale = false when { menuChartSettings[g + 1][OverviewMenus.CharType.ABS.ordinal] -> useABSForScale = true menuChartSettings[g + 1][OverviewMenus.CharType.IOB.ordinal] -> useIobForScale = true @@ -335,6 +336,7 @@ class HistoryBrowseActivity : TranslatedDaggerAppCompatActivity() { menuChartSettings[g + 1][OverviewMenus.CharType.BGI.ordinal] -> useBGIForScale = true menuChartSettings[g + 1][OverviewMenus.CharType.SEN.ordinal] -> useRatioForScale = true menuChartSettings[g + 1][OverviewMenus.CharType.DEVSLOPE.ordinal] -> useDSForScale = true + menuChartSettings[g + 1][OverviewMenus.CharType.HR.ordinal] -> useHRForScale = true } val alignDevBgiScale = menuChartSettings[g + 1][OverviewMenus.CharType.DEV.ordinal] && menuChartSettings[g + 1][OverviewMenus.CharType.BGI.ordinal] @@ -345,6 +347,7 @@ class HistoryBrowseActivity : TranslatedDaggerAppCompatActivity() { if (menuChartSettings[g + 1][OverviewMenus.CharType.BGI.ordinal]) secondGraphData.addMinusBGI(useBGIForScale, if (alignDevBgiScale) 1.0 else 0.8) if (menuChartSettings[g + 1][OverviewMenus.CharType.SEN.ordinal]) secondGraphData.addRatio(useRatioForScale, if (useRatioForScale) 1.0 else 0.8) if (menuChartSettings[g + 1][OverviewMenus.CharType.DEVSLOPE.ordinal] && config.isDev()) secondGraphData.addDeviationSlope(useDSForScale, 1.0) + if (menuChartSettings[g + 1][OverviewMenus.CharType.HR.ordinal] && config.isDev()) secondGraphData.addHeartRate(useHRForScale, 1.0) // set manual x bounds to have nice steps secondGraphData.formatAxis(historyBrowserData.overviewData.fromTime, historyBrowserData.overviewData.endTime) diff --git a/core/graph/src/main/java/info/nightscout/core/graph/OverviewData.kt b/core/graph/src/main/java/info/nightscout/core/graph/OverviewData.kt index 9a1eea13ba..c566cbb511 100644 --- a/core/graph/src/main/java/info/nightscout/core/graph/OverviewData.kt +++ b/core/graph/src/main/java/info/nightscout/core/graph/OverviewData.kt @@ -150,4 +150,7 @@ interface OverviewData { val dsMinScale: Scale var dsMaxSeries: LineGraphSeries var dsMinSeries: LineGraphSeries -} \ No newline at end of file + var heartRateScale: Scale + var heartRateGraphSeries: LineGraphSeries + +} diff --git a/core/graph/src/main/java/info/nightscout/core/graph/data/HeartRateDataPoint.kt b/core/graph/src/main/java/info/nightscout/core/graph/data/HeartRateDataPoint.kt new file mode 100644 index 0000000000..1fef69a2b5 --- /dev/null +++ b/core/graph/src/main/java/info/nightscout/core/graph/data/HeartRateDataPoint.kt @@ -0,0 +1,24 @@ +package info.nightscout.core.graph.data + +import android.content.Context +import android.graphics.Paint +import info.nightscout.database.entities.HeartRate +import info.nightscout.shared.interfaces.ResourceHelper + +class HeartRateDataPoint( + private val data: HeartRate, + private val rh: ResourceHelper, +) : DataPointWithLabelInterface { + + override fun getX(): Double = (data.timestamp - data.duration).toDouble() + override fun getY(): Double = data.beatsPerMinute + override fun setY(y: Double) {} + + override val label: String = "" + override val duration = data.duration + override val shape = PointsWithLabelGraphSeries.Shape.HEARTRATE + override val size = 1f + override val paintStyle: Paint.Style = Paint.Style.FILL + + override fun color(context: Context?): Int = rh.gac(context, info.nightscout.core.ui.R.attr.heartRateColor) +} diff --git a/core/graph/src/main/java/info/nightscout/core/graph/data/PointsWithLabelGraphSeries.java b/core/graph/src/main/java/info/nightscout/core/graph/data/PointsWithLabelGraphSeries.java index 47f744a9fa..49f560efc3 100644 --- a/core/graph/src/main/java/info/nightscout/core/graph/data/PointsWithLabelGraphSeries.java +++ b/core/graph/src/main/java/info/nightscout/core/graph/data/PointsWithLabelGraphSeries.java @@ -54,7 +54,8 @@ public class PointsWithLabelGraphSeries e GENERAL_WITH_DURATION, COB_FAIL_OVER, IOB_PREDICTION, - BUCKETED_BG + BUCKETED_BG, + HEARTRATE, } /** @@ -324,6 +325,10 @@ public class PointsWithLabelGraphSeries e mPaint.setStrokeWidth(5); canvas.drawRect(endX - 3, bounds.top + py - 3, xPlusLength + 3, bounds.bottom + py + 3, mPaint); } + } else if (value.getShape() == Shape.HEARTRATE) { + mPaint.setStyle(Paint.Style.FILL_AND_STROKE); + mPaint.setStrokeWidth(0); + canvas.drawCircle(endX, endY, 1F, mPaint); } // set values above point } diff --git a/core/interfaces/src/main/java/info/nightscout/interfaces/overview/OverviewMenus.kt b/core/interfaces/src/main/java/info/nightscout/interfaces/overview/OverviewMenus.kt index 0129bcbe19..9622530524 100644 --- a/core/interfaces/src/main/java/info/nightscout/interfaces/overview/OverviewMenus.kt +++ b/core/interfaces/src/main/java/info/nightscout/interfaces/overview/OverviewMenus.kt @@ -15,7 +15,8 @@ interface OverviewMenus { BGI, SEN, ACT, - DEVSLOPE + DEVSLOPE, + HR, } val setting: List> @@ -23,4 +24,4 @@ interface OverviewMenus { fun setupChartMenu(context: Context, chartButton: ImageButton) fun enabledTypes(graph: Int): String fun isEnabledIn(type: CharType): Int -} \ No newline at end of file +} diff --git a/core/ui/src/main/res/values-night/styles.xml b/core/ui/src/main/res/values-night/styles.xml index 6e3ca1508f..e78363af48 100644 --- a/core/ui/src/main/res/values-night/styles.xml +++ b/core/ui/src/main/res/values-night/styles.xml @@ -216,6 +216,7 @@ @color/inRangeBackground @color/devSlopePos @color/devSlopeNeg + @color/heartRate @color/deviationGrey @color/deviationBlack @color/deviationGreen diff --git a/core/ui/src/main/res/values/attrs.xml b/core/ui/src/main/res/values/attrs.xml index 380bb21a83..cc75771f30 100644 --- a/core/ui/src/main/res/values/attrs.xml +++ b/core/ui/src/main/res/values/attrs.xml @@ -181,6 +181,7 @@ + @@ -221,4 +222,4 @@ - \ No newline at end of file + diff --git a/core/ui/src/main/res/values/colors.xml b/core/ui/src/main/res/values/colors.xml index 86f1a39470..91179d4355 100644 --- a/core/ui/src/main/res/values/colors.xml +++ b/core/ui/src/main/res/values/colors.xml @@ -153,6 +153,7 @@ #00EEEE #FFFFFF00 #FFFF00FF + #FFFFFF66 #F6CE22 #FF0000 #7484E2 diff --git a/core/ui/src/main/res/values/styles.xml b/core/ui/src/main/res/values/styles.xml index a1bf69ad9d..e9029cc2be 100644 --- a/core/ui/src/main/res/values/styles.xml +++ b/core/ui/src/main/res/values/styles.xml @@ -219,6 +219,7 @@ @color/inRangeBackground @color/devSlopePos @color/devSlopeNeg + @color/heartRate @color/deviationGrey @color/deviationBlack @color/deviationGreen diff --git a/implementation/src/main/java/info/nightscout/implementation/overview/OverviewDataImpl.kt b/implementation/src/main/java/info/nightscout/implementation/overview/OverviewDataImpl.kt index dd153fa18c..c7bf701bcd 100644 --- a/implementation/src/main/java/info/nightscout/implementation/overview/OverviewDataImpl.kt +++ b/implementation/src/main/java/info/nightscout/implementation/overview/OverviewDataImpl.kt @@ -87,6 +87,7 @@ class OverviewDataImpl @Inject constructor( dsMinSeries = LineGraphSeries() treatmentsSeries = PointsWithLabelGraphSeries() epsSeries = PointsWithLabelGraphSeries() + heartRateGraphSeries = LineGraphSeries() } override fun initRange() { @@ -322,4 +323,6 @@ class OverviewDataImpl @Inject constructor( override val dsMinScale = Scale() override var dsMaxSeries: LineGraphSeries = LineGraphSeries() override var dsMinSeries: LineGraphSeries = LineGraphSeries() + override var heartRateScale = Scale() + override var heartRateGraphSeries: LineGraphSeries = LineGraphSeries() } diff --git a/plugins/main/src/main/java/info/nightscout/plugins/general/overview/OverviewFragment.kt b/plugins/main/src/main/java/info/nightscout/plugins/general/overview/OverviewFragment.kt index f59254d745..e4714d1079 100644 --- a/plugins/main/src/main/java/info/nightscout/plugins/general/overview/OverviewFragment.kt +++ b/plugins/main/src/main/java/info/nightscout/plugins/general/overview/OverviewFragment.kt @@ -1030,6 +1030,7 @@ class OverviewFragment : DaggerFragment(), View.OnClickListener, OnLongClickList var useRatioForScale = false var useDSForScale = false var useBGIForScale = false + var useHRForScale = false when { menuChartSettings[g + 1][OverviewMenus.CharType.ABS.ordinal] -> useABSForScale = true menuChartSettings[g + 1][OverviewMenus.CharType.IOB.ordinal] -> useIobForScale = true @@ -1038,6 +1039,7 @@ class OverviewFragment : DaggerFragment(), View.OnClickListener, OnLongClickList menuChartSettings[g + 1][OverviewMenus.CharType.BGI.ordinal] -> useBGIForScale = true menuChartSettings[g + 1][OverviewMenus.CharType.SEN.ordinal] -> useRatioForScale = true menuChartSettings[g + 1][OverviewMenus.CharType.DEVSLOPE.ordinal] -> useDSForScale = true + menuChartSettings[g + 1][OverviewMenus.CharType.HR.ordinal] -> useHRForScale = true } val alignDevBgiScale = menuChartSettings[g + 1][OverviewMenus.CharType.DEV.ordinal] && menuChartSettings[g + 1][OverviewMenus.CharType.BGI.ordinal] @@ -1052,6 +1054,7 @@ class OverviewFragment : DaggerFragment(), View.OnClickListener, OnLongClickList if (useDSForScale) 1.0 else 0.8, useRatioForScale ) + if (menuChartSettings[g + 1][OverviewMenus.CharType.HR.ordinal]) secondGraphData.addHeartRate(useHRForScale, if (useHRForScale) 1.0 else 0.8) // set manual x bounds to have nice steps secondGraphData.formatAxis(overviewData.fromTime, overviewData.endTime) @@ -1067,7 +1070,8 @@ class OverviewFragment : DaggerFragment(), View.OnClickListener, OnLongClickList menuChartSettings[g + 1][OverviewMenus.CharType.DEV.ordinal] || menuChartSettings[g + 1][OverviewMenus.CharType.BGI.ordinal] || menuChartSettings[g + 1][OverviewMenus.CharType.SEN.ordinal] || - menuChartSettings[g + 1][OverviewMenus.CharType.DEVSLOPE.ordinal] + menuChartSettings[g + 1][OverviewMenus.CharType.DEVSLOPE.ordinal] || + menuChartSettings[g + 1][OverviewMenus.CharType.HR.ordinal] ).toVisibility() secondaryGraphsData[g].performUpdate() } diff --git a/plugins/main/src/main/java/info/nightscout/plugins/general/overview/OverviewMenusImpl.kt b/plugins/main/src/main/java/info/nightscout/plugins/general/overview/OverviewMenusImpl.kt index d9d1abd0a1..b4fb21b8e9 100644 --- a/plugins/main/src/main/java/info/nightscout/plugins/general/overview/OverviewMenusImpl.kt +++ b/plugins/main/src/main/java/info/nightscout/plugins/general/overview/OverviewMenusImpl.kt @@ -47,7 +47,8 @@ class OverviewMenusImpl @Inject constructor( BGI(R.string.overview_show_bgi, info.nightscout.core.ui.R.attr.bgiColor, info.nightscout.core.ui.R.attr.menuTextColor, primary = false, secondary = true, shortnameId = R.string.bgi_shortname), SEN(R.string.overview_show_sensitivity, info.nightscout.core.ui.R.attr.ratioColor, info.nightscout.core.ui.R.attr.menuTextColorInverse, primary = false, secondary = true, shortnameId = R.string.sensitivity_shortname), ACT(R.string.overview_show_activity, info.nightscout.core.ui.R.attr.activityColor, info.nightscout.core.ui.R.attr.menuTextColor, primary = true, secondary = false, shortnameId = R.string.activity_shortname), - DEVSLOPE(R.string.overview_show_deviation_slope, info.nightscout.core.ui.R.attr.devSlopePosColor, info.nightscout.core.ui.R.attr.menuTextColor, primary = false, secondary = true, shortnameId = R.string.devslope_shortname) + DEVSLOPE(R.string.overview_show_deviation_slope, info.nightscout.core.ui.R.attr.devSlopePosColor, info.nightscout.core.ui.R.attr.menuTextColor, primary = false, secondary = true, shortnameId = R.string.devslope_shortname), + HR(R.string.overview_show_heartRate, info.nightscout.core.ui.R.attr.heartRateColor, info.nightscout.core.ui.R.attr.menuTextColor, primary = false, secondary = true, shortnameId = R.string.heartRate_shortname), } companion object { @@ -202,4 +203,4 @@ class OverviewMenusImpl @Inject constructor( return -1 } -} \ No newline at end of file +} diff --git a/plugins/main/src/main/java/info/nightscout/plugins/general/overview/graphData/GraphData.kt b/plugins/main/src/main/java/info/nightscout/plugins/general/overview/graphData/GraphData.kt index 1efaf873c1..f7898f954d 100644 --- a/plugins/main/src/main/java/info/nightscout/plugins/general/overview/graphData/GraphData.kt +++ b/plugins/main/src/main/java/info/nightscout/plugins/general/overview/graphData/GraphData.kt @@ -258,5 +258,14 @@ class GraphData( // draw it graph.onDataChanged(false, false) } -} + fun addHeartRate(useForScale: Boolean, scale: Double) { + val maxHR = overviewData.heartRateGraphSeries.highestValueY + if (useForScale) { + minY = 0.0 + maxY = maxHR + } + addSeries(overviewData.heartRateGraphSeries) + overviewData.heartRateScale.multiplier = maxY * scale / maxHR + } +} diff --git a/plugins/main/src/main/res/values/strings.xml b/plugins/main/src/main/res/values/strings.xml index 754ea02211..df582c2e6f 100644 --- a/plugins/main/src/main/res/values/strings.xml +++ b/plugins/main/src/main/res/values/strings.xml @@ -291,6 +291,7 @@ Predictions Treatments + Heart Rate Deviation slope Activity Blood Glucose Impact @@ -308,6 +309,7 @@ ABS DEVSLOPE TREAT + HR SENS Graph scale Graph diff --git a/workflow/src/main/java/info/nightscout/workflow/PrepareTreatmentsDataWorker.kt b/workflow/src/main/java/info/nightscout/workflow/PrepareTreatmentsDataWorker.kt index f012e8c4b9..f5e2a3e747 100644 --- a/workflow/src/main/java/info/nightscout/workflow/PrepareTreatmentsDataWorker.kt +++ b/workflow/src/main/java/info/nightscout/workflow/PrepareTreatmentsDataWorker.kt @@ -3,6 +3,7 @@ package info.nightscout.workflow import android.content.Context import androidx.work.WorkerParameters import androidx.work.workDataOf +import com.jjoe64.graphview.series.LineGraphSeries import info.nightscout.core.events.EventIobCalculationProgress import info.nightscout.core.graph.OverviewData import info.nightscout.core.graph.data.BolusDataPoint @@ -10,6 +11,8 @@ import info.nightscout.core.graph.data.CarbsDataPoint import info.nightscout.core.graph.data.DataPointWithLabelInterface import info.nightscout.core.graph.data.EffectiveProfileSwitchDataPoint import info.nightscout.core.graph.data.ExtendedBolusDataPoint +import info.nightscout.core.graph.data.FixedLineGraphSeries +import info.nightscout.core.graph.data.HeartRateDataPoint import info.nightscout.core.graph.data.PointsWithLabelGraphSeries import info.nightscout.core.graph.data.TherapyEventDataPoint import info.nightscout.core.utils.receivers.DataWorkerStorage @@ -129,6 +132,11 @@ class PrepareTreatmentsDataWorker( data.overviewData.therapyEventSeries = PointsWithLabelGraphSeries(filteredTherapyEvents.toTypedArray()) data.overviewData.epsSeries = PointsWithLabelGraphSeries(filteredEps.toTypedArray()) + data.overviewData.heartRateGraphSeries = LineGraphSeries( + repository.getHeartRatesFromTimeToTime(fromTime, endTime) + .map { hr -> HeartRateDataPoint(hr, rh) } + .toTypedArray()).apply { color = rh.gac(null, info.nightscout.core.ui.R.attr.heartRateColor) } + rxBus.send(EventIobCalculationProgress(CalculationWorkflow.ProgressData.PREPARE_TREATMENTS_DATA, 100, null)) return Result.success() } @@ -149,4 +157,4 @@ class PrepareTreatmentsDataWorker( private fun List.filterTimeframe(fromTime: Long, endTime: Long): List = filter { it.x + it.duration >= fromTime && it.x <= endTime } -} \ No newline at end of file +} From 7f0c361ca252fec7b9e28cfab383bc373f2182c4 Mon Sep 17 00:00:00 2001 From: Robert Buessow Date: Wed, 10 May 2023 14:06:01 +0200 Subject: [PATCH 2/6] Add AppRepository::getHeartRatesFromTimeToTime --- .../database/impl/HeartRateDaoTest.kt | 20 ++++++++++++++++++- .../nightscout/database/impl/AppRepository.kt | 3 +++ .../database/impl/daos/HeartRateDao.kt | 3 +++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/database/impl/src/androidTest/java/info/nightscout/database/impl/HeartRateDaoTest.kt b/database/impl/src/androidTest/java/info/nightscout/database/impl/HeartRateDaoTest.kt index 29e94cccc1..dcd3e6bb6a 100644 --- a/database/impl/src/androidTest/java/info/nightscout/database/impl/HeartRateDaoTest.kt +++ b/database/impl/src/androidTest/java/info/nightscout/database/impl/HeartRateDaoTest.kt @@ -92,8 +92,26 @@ internal class HeartRateDaoTest { } } - companion object { + @Test + fun getFromTimeToTime() { + createDatabase().use { db -> + val dao = db.heartRateDao + val timestamp = System.currentTimeMillis() + val hr1 = createHeartRate(timestamp = timestamp, beatsPerMinute = 80.0) + val hr2 = createHeartRate(timestamp = timestamp + 1, beatsPerMinute = 150.0) + val hr3 = createHeartRate(timestamp = timestamp + 2, beatsPerMinute = 160.0) + dao.insertNewEntry(hr1) + dao.insertNewEntry(hr2) + dao.insertNewEntry(hr3) + assertEquals(listOf(hr1, hr2, hr3), dao.getFromTimeToTime(timestamp, timestamp + 2)) + assertEquals(listOf(hr1, hr2), dao.getFromTimeToTime(timestamp, timestamp + 1)) + assertEquals(listOf(hr2), dao.getFromTimeToTime(timestamp + 1, timestamp + 1)) + assertTrue(dao.getFromTimeToTime(timestamp + 3, timestamp + 10).isEmpty()) + } + } + + companion object { private const val TEST_DB_NAME = "testDatabase" fun createHeartRate(timestamp: Long? = null, beatsPerMinute: Double = 80.0) = diff --git a/database/impl/src/main/java/info/nightscout/database/impl/AppRepository.kt b/database/impl/src/main/java/info/nightscout/database/impl/AppRepository.kt index 91b4863668..12e1c629cc 100644 --- a/database/impl/src/main/java/info/nightscout/database/impl/AppRepository.kt +++ b/database/impl/src/main/java/info/nightscout/database/impl/AppRepository.kt @@ -934,6 +934,9 @@ import kotlin.math.roundToInt fun getHeartRatesFromTime(timeMillis: Long) = database.heartRateDao.getFromTime(timeMillis) + fun getHeartRatesFromTimeToTime(startMillis: Long, endMillis: Long) = + database.heartRateDao.getFromTimeToTime(startMillis, endMillis) + suspend fun collectNewEntriesSince(since: Long, until: Long, limit: Int, offset: Int) = NewEntries( apsResults = database.apsResultDao.getNewEntriesSince(since, until, limit, offset), apsResultLinks = database.apsResultLinkDao.getNewEntriesSince(since, until, limit, offset), diff --git a/database/impl/src/main/java/info/nightscout/database/impl/daos/HeartRateDao.kt b/database/impl/src/main/java/info/nightscout/database/impl/daos/HeartRateDao.kt index 516a8025d7..82ce3f3c38 100644 --- a/database/impl/src/main/java/info/nightscout/database/impl/daos/HeartRateDao.kt +++ b/database/impl/src/main/java/info/nightscout/database/impl/daos/HeartRateDao.kt @@ -23,6 +23,9 @@ internal interface HeartRateDao : TraceableDao { @Query("SELECT * FROM $TABLE_HEART_RATE WHERE timestamp >= :timestamp ORDER BY timestamp") fun getFromTime(timestamp: Long): List + @Query("SELECT * FROM $TABLE_HEART_RATE WHERE timestamp BETWEEN :startMillis AND :endMillis ORDER BY timestamp") + fun getFromTimeToTime(startMillis: Long, endMillis: Long): List + @Query("SELECT * FROM $TABLE_HEART_RATE WHERE timestamp > :since AND timestamp <= :until LIMIT :limit OFFSET :offset") fun getNewEntriesSince(since: Long, until: Long, limit: Int, offset: Int): List } From 411ec8bd685305a3eab0b5155d79c6cb79054a76 Mon Sep 17 00:00:00 2001 From: Robert Buessow Date: Mon, 15 May 2023 16:33:08 +0200 Subject: [PATCH 3/6] 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()) + } +} From 7f14535852e89333abda4dab90513bc440bba32f Mon Sep 17 00:00:00 2001 From: robertbuessow Date: Fri, 19 May 2023 16:44:27 +0200 Subject: [PATCH 4/6] Store heart rate events from watch in local storage. --- .../wear/wearintegration/DataHandlerMobile.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataHandlerMobile.kt b/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataHandlerMobile.kt index 0bcd489faf..118c342d0b 100644 --- a/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataHandlerMobile.kt +++ b/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataHandlerMobile.kt @@ -19,6 +19,7 @@ import info.nightscout.database.ValueWrapper import info.nightscout.database.entities.Bolus import info.nightscout.database.entities.BolusCalculatorResult import info.nightscout.database.entities.GlucoseValue +import info.nightscout.database.entities.HeartRate import info.nightscout.database.entities.TemporaryBasal import info.nightscout.database.entities.TemporaryTarget import info.nightscout.database.entities.TotalDailyDose @@ -28,6 +29,7 @@ import info.nightscout.database.entities.interfaces.end import info.nightscout.database.impl.AppRepository import info.nightscout.database.impl.transactions.CancelCurrentTemporaryTargetIfAnyTransaction import info.nightscout.database.impl.transactions.InsertAndCancelCurrentTemporaryTargetTransaction +import info.nightscout.database.impl.transactions.InsertOrUpdateHeartRateTransaction import info.nightscout.interfaces.Config import info.nightscout.interfaces.Constants import info.nightscout.interfaces.GlucoseUnit @@ -308,6 +310,10 @@ class DataHandlerMobile @Inject constructor( aapsLogger.debug(LTag.WEAR, "WearException received $it from ${it.sourceNodeId}") fabricPrivacy.logWearException(it) }, fabricPrivacy::logException) + disposable += rxBus + .toObservable(EventData.ActionHeartRate::class.java) + .observeOn(aapsSchedulers.io) + .subscribe({ handleHeartRate(it) }, fabricPrivacy::logException) } private fun handleTddStatus() { @@ -1230,4 +1236,15 @@ class DataHandlerMobile @Inject constructor( @Synchronized private fun sendError(errorMessage: String) { rxBus.send(EventMobileToWear(EventData.ConfirmAction(rh.gs(info.nightscout.core.ui.R.string.error), errorMessage, returnCommand = EventData.Error(dateUtil.now())))) // ignore return path } + + /** Stores heart rate events coming from the Wear device. */ + private fun handleHeartRate(actionHeartRate: EventData.ActionHeartRate) { + aapsLogger.debug(LTag.WEAR, "Heart rate received $actionHeartRate from ${actionHeartRate.sourceNodeId}") + val hr = HeartRate( + duration = actionHeartRate.samplingEndMillis - actionHeartRate.samplingStartMillis, + timestamp = actionHeartRate.samplingEndMillis, + beatsPerMinute = actionHeartRate.beatsPerMinute.toDouble(), + device = actionHeartRate.device) + repository.runTransaction(InsertOrUpdateHeartRateTransaction(hr)) + } } From b82aeb93792e69ee86a97774519e51451bc82c2b Mon Sep 17 00:00:00 2001 From: robertbuessow Date: Mon, 22 May 2023 17:35:23 +0200 Subject: [PATCH 5/6] Rewrite HeartRateListener to better handle watches that don't regularely send heart rate events. Make ActionHeartRate and HeartRate consistent. --- .../info/nightscout/rx/weardata/EventData.kt | 13 +- .../wear/wearintegration/DataHandlerMobile.kt | 8 +- .../androidaps/heartrate/HeartRateListener.kt | 169 +++++++++++++----- .../interaction/ConfigurationActivity.kt | 3 +- .../watchfaces/utils/BaseWatchFace.kt | 12 +- .../heartrate/HeartRateListenerTest.kt | 134 ++++++++++---- 6 files changed, 240 insertions(+), 99 deletions(-) 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 bc176a8361..59c8bc6fa7 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,8 +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.* -import kotlin.collections.ArrayList +import org.joda.time.DateTime +import java.util.Objects @Serializable sealed class EventData : Event() { @@ -93,11 +93,12 @@ sealed class EventData : Event() { @Serializable data class ActionHeartRate( - val samplingStartMillis: Long, - val samplingEndMillis: Long, - val beatsPerMinute: Int, + val duration: Long, + val timestamp: Long, + val beatsPerMinute: Double, 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 diff --git a/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataHandlerMobile.kt b/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataHandlerMobile.kt index 118c342d0b..066e49bf2b 100644 --- a/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataHandlerMobile.kt +++ b/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataHandlerMobile.kt @@ -1241,10 +1241,10 @@ class DataHandlerMobile @Inject constructor( private fun handleHeartRate(actionHeartRate: EventData.ActionHeartRate) { aapsLogger.debug(LTag.WEAR, "Heart rate received $actionHeartRate from ${actionHeartRate.sourceNodeId}") val hr = HeartRate( - duration = actionHeartRate.samplingEndMillis - actionHeartRate.samplingStartMillis, - timestamp = actionHeartRate.samplingEndMillis, - beatsPerMinute = actionHeartRate.beatsPerMinute.toDouble(), + duration = actionHeartRate.duration, + timestamp = actionHeartRate.timestamp, + beatsPerMinute = actionHeartRate.beatsPerMinute, device = actionHeartRate.device) - repository.runTransaction(InsertOrUpdateHeartRateTransaction(hr)) + repository.runTransaction(InsertOrUpdateHeartRateTransaction(hr)).blockingAwait() } } diff --git a/wear/src/main/java/info/nightscout/androidaps/heartrate/HeartRateListener.kt b/wear/src/main/java/info/nightscout/androidaps/heartrate/HeartRateListener.kt index e8d247da6a..8b96463a63 100644 --- a/wear/src/main/java/info/nightscout/androidaps/heartrate/HeartRateListener.kt +++ b/wear/src/main/java/info/nightscout/androidaps/heartrate/HeartRateListener.kt @@ -9,22 +9,54 @@ import android.hardware.SensorManager import android.os.Build import androidx.annotation.VisibleForTesting import info.nightscout.androidaps.comm.IntentWearToMobile +import info.nightscout.rx.AapsSchedulers 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 +import io.reactivex.rxjava3.disposables.Disposable +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( private val ctx: Context, - private val aapsLogger: AAPSLogger -) : SensorEventListener { + private val aapsLogger: AAPSLogger, + aapsSchedulers: AapsSchedulers, + now: Long = System.currentTimeMillis(), +) : SensorEventListener, Disposable { + /** How often we send values to the phone. */ 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 { aapsLogger.info(LTag.WEAR, "Create ${javaClass.simpleName}") @@ -39,71 +71,116 @@ class HeartRateListener( 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 var sendHeartRate: (EventData.ActionHeartRate)->Unit = { hr -> ctx.startService(IntentWearToMobile(ctx, hr)) } - fun onDestroy() { - aapsLogger.info(LTag.WEAR, "Destroy ${javaClass.simpleName}") + override fun isDisposed() = schedule == null + + override fun dispose() { + aapsLogger.info(LTag.WEAR, "Dispose ${javaClass.simpleName}") + schedule?.dispose() (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) { - sampler.heartRate.let { hr -> + @VisibleForTesting + fun send(timestampMillis: Long) { + sampler.getAndReset(timestampMillis)?.let { hr -> aapsLogger.info(LTag.WEAR, "Send heart rate $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) { onSensorChanged(event.sensor?.type, event.accuracy, System.currentTimeMillis(), event.values) } @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()) { - aapsLogger.error(LTag.WEAR, "Invalid SensorEvent $sensorType $accuracy $timestamp ${values.joinToString()}") + aapsLogger.error(LTag.WEAR, "Invalid SensorEvent $sensorType $accuracy $timestampMillis ${values.joinToString()}") return } - when (accuracy) { - SensorManager.SENSOR_STATUS_NO_CONTACT -> return - SensorManager.SENSOR_STATUS_UNRELIABLE -> return + val heartRate = values[0].toDouble().takeIf { accuracy in goodAccuracies } + sampler.setHeartRate(timestampMillis, heartRate) + } + + 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 -> - 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 + + /** Gets the current sampled value and resets the samplers clock to the given timestamp. */ + fun getAndReset(timestampMillis: Long): EventData.ActionHeartRate? { + lock.withLock { + fix(timestampMillis) + return if (10 * activeMillis > lastEventMillis - startMillis) { + val bpm = beats / activeMillis.toMinute() + EventData.ActionHeartRate(timestampMillis - startMillis, timestampMillis, bpm, device) + } else { + 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 - } - } } diff --git a/wear/src/main/java/info/nightscout/androidaps/interaction/ConfigurationActivity.kt b/wear/src/main/java/info/nightscout/androidaps/interaction/ConfigurationActivity.kt index 1aebe91433..d3475dc4df 100644 --- a/wear/src/main/java/info/nightscout/androidaps/interaction/ConfigurationActivity.kt +++ b/wear/src/main/java/info/nightscout/androidaps/interaction/ConfigurationActivity.kt @@ -1,6 +1,5 @@ package info.nightscout.androidaps.interaction -import preference.WearPreferenceActivity import android.os.Bundle import android.view.View import android.view.ViewGroup @@ -9,7 +8,7 @@ import dagger.android.AndroidInjection import info.nightscout.androidaps.R import info.nightscout.rx.logging.AAPSLogger import info.nightscout.rx.logging.LTag - +import preference.WearPreferenceActivity import javax.inject.Inject class ConfigurationActivity : WearPreferenceActivity() { 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 3bface142b..a1cc53eab9 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 @@ -152,10 +152,15 @@ abstract class BaseWatchFace : WatchFace() { private fun updateHeartRateListener() { 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 { - heartRateListener?.onDestroy() - heartRateListener = null + heartRateListener?.let { hrl -> + disposable.remove(hrl) + heartRateListener = null + } } } @@ -240,7 +245,6 @@ abstract class BaseWatchFace : WatchFace() { override fun onDestroy() { disposable.clear() - heartRateListener?.onDestroy() simpleUi.onDestroy() super.onDestroy() } diff --git a/wear/src/test/java/info/nightscout/androidaps/heartrate/HeartRateListenerTest.kt b/wear/src/test/java/info/nightscout/androidaps/heartrate/HeartRateListenerTest.kt index 925a5af500..7b542b1afc 100644 --- a/wear/src/test/java/info/nightscout/androidaps/heartrate/HeartRateListenerTest.kt +++ b/wear/src/test/java/info/nightscout/androidaps/heartrate/HeartRateListenerTest.kt @@ -3,21 +3,43 @@ package info.nightscout.androidaps.heartrate import android.content.Context import android.hardware.Sensor import android.hardware.SensorManager +import info.nightscout.rx.AapsSchedulers import info.nightscout.rx.logging.AAPSLoggerTest 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.assertNull import org.junit.jupiter.api.BeforeEach 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.verify +import org.mockito.Mockito.`when` +import java.util.concurrent.TimeUnit internal class HeartRateListenerTest { 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() private val device = "unknown unknown" - private fun create(): HeartRateListener { + private fun create(timestampMillis: Long): HeartRateListener { 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) } return listener } @@ -32,73 +54,111 @@ internal class HeartRateListenerTest { } @BeforeEach - fun init() { + fun before() { 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 fun onSensorChanged() { - val listener = create() 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) - sendSensorEvent(listener, start + 60_001L,180) + assertEquals(80, listener.currentHeartRateBpm) + + listener.send(start + d2) 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 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()) - } + val d1 = 10_000L + val d2 = 40_000L + val listener = create(start) - @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(80, listener.currentHeartRateBpm) + sendSensorEvent(listener, start + d1,100) assertEquals(0, heartRates.size) - sendSensorEvent(listener, start + 180_001L, 180) + assertEquals(100, listener.currentHeartRateBpm) + + + listener.send(start + d2) 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 fun onSensorChangedMultiple() { - val listener = create() val start = System.currentTimeMillis() + val d1 = 10_000L + val d2 = 40_000L + val listener = create(start) + sendSensorEvent(listener, start, 80) - assertEquals(0, heartRates.size) - val start2 = start + 60_000L - sendSensorEvent(listener, start2,100) + listener.send(start + d1) 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(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 fun onSensorChangedNoContact() { - val listener = create() val start = System.currentTimeMillis() + val d1 = 10_000L + val d2 = 40_000L + val listener = create(start) + 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) + sendSensorEvent(listener, start + d1, 100, accuracy = SensorManager.SENSOR_STATUS_NO_CONTACT) + assertNull(listener.currentHeartRateBpm) + listener.send(start + d2) + 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() } } From c4ad9f12aa2c3cdf13287ac78d4a573020985fa1 Mon Sep 17 00:00:00 2001 From: robertbuessow Date: Mon, 29 May 2023 18:22:51 +0300 Subject: [PATCH 6/6] Request sensor permissions when heart rate sampling is enabled. --- .../WatchfaceConfigurationActivity.kt | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/wear/src/main/java/info/nightscout/androidaps/interaction/WatchfaceConfigurationActivity.kt b/wear/src/main/java/info/nightscout/androidaps/interaction/WatchfaceConfigurationActivity.kt index 2b06023155..aaec70673b 100644 --- a/wear/src/main/java/info/nightscout/androidaps/interaction/WatchfaceConfigurationActivity.kt +++ b/wear/src/main/java/info/nightscout/androidaps/interaction/WatchfaceConfigurationActivity.kt @@ -1,17 +1,28 @@ package info.nightscout.androidaps.interaction +import android.Manifest +import android.content.SharedPreferences +import android.content.pm.PackageManager import android.os.Bundle import android.view.View import android.view.ViewGroup +import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager import info.nightscout.androidaps.R +import info.nightscout.rx.logging.AAPSLogger +import info.nightscout.rx.logging.LTag import preference.WearPreferenceActivity +import javax.inject.Inject -class WatchfaceConfigurationActivity : WearPreferenceActivity() { +class WatchfaceConfigurationActivity : WearPreferenceActivity(), SharedPreferences.OnSharedPreferenceChangeListener { + + @Inject lateinit var aapsLogger: AAPSLogger override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) addPreferencesFromResource(R.xml.preferences) + PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(this) val view = window.decorView as ViewGroup removeBackgroundRecursively(view) view.background = ContextCompat.getDrawable(this, R.drawable.settings_background) @@ -24,4 +35,38 @@ class WatchfaceConfigurationActivity : WearPreferenceActivity() { removeBackgroundRecursively(parent.getChildAt(i)) parent.background = null } + + override fun onSharedPreferenceChanged(sp: SharedPreferences, key: String?) { + if (key == getString(R.string.key_heart_rate_sampling)) { + if (sp.getBoolean(key, false)) { + requestBodySensorPermission() + } + } + } + + private fun requestBodySensorPermission() { + val permission = Manifest.permission.BODY_SENSORS + if (ActivityCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, arrayOf(permission), BODY_SENSOR_PERMISSION_REQUEST_CODE) + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray) { + if (requestCode == BODY_SENSOR_PERMISSION_REQUEST_CODE) { + if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + aapsLogger.info(LTag.WEAR, "Sensor permission for heart rate granted") + } else { + aapsLogger.warn(LTag.WEAR, "Sensor permission for heart rate denied") + } + } else { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + } + + companion object { + private const val BODY_SENSOR_PERMISSION_REQUEST_CODE = 1 + } } \ No newline at end of file