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..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,6 +3,7 @@ package info.nightscout.rx.weardata import info.nightscout.rx.events.Event import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import org.joda.time.DateTime import java.util.Objects @Serializable @@ -90,6 +91,16 @@ sealed class EventData : Event() { @Serializable data class ActionQuickWizardPreCheck(val guid: String) : EventData() + @Serializable + data class ActionHeartRate( + val duration: Long, + val timestamp: Long, + val beatsPerMinute: Double, + val device: String): EventData() { + override fun toString() = + "HR ${beatsPerMinute.toInt()} at ${DateTime(timestamp)} for ${duration / 1000.0}sec $device" + } + @Serializable data class ActionTempTargetPreCheck( val command: TempTargetCommand, 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/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 } 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/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..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 @@ -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.duration, + timestamp = actionHeartRate.timestamp, + beatsPerMinute = actionHeartRate.beatsPerMinute, + device = actionHeartRate.device) + repository.runTransaction(InsertOrUpdateHeartRateTransaction(hr)).blockingAwait() + } } 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/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)) } + + 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) + } + + /** Sends currently sampled value to the phone. Executed every [samplingIntervalMillis]. */ + private fun send() { + send(System.currentTimeMillis()) + } + + @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, timestampMillis: Long, values: FloatArray) { + if (sensorType == null || sensorType != Sensor.TYPE_HEART_RATE || values.isEmpty()) { + aapsLogger.error(LTag.WEAR, "Invalid SensorEvent $sensorType $accuracy $timestampMillis ${values.joinToString()}") + 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 + } + + /** 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 + } + } + } +} 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/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 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..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 @@ -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,20 @@ abstract class BaseWatchFace : WatchFace() { invalidate() } + private fun updateHeartRateListener() { + if (sp.getBoolean(R.string.key_heart_rate_sampling, false)) { + if (heartRateListener == null) { + heartRateListener = HeartRateListener( + this, aapsLogger, aapsSchedulers).also { hrl -> disposable += hrl } + } + } else { + heartRateListener?.let { hrl -> + disposable.remove(hrl) + 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) { 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..7b542b1afc --- /dev/null +++ b/wear/src/test/java/info/nightscout/androidaps/heartrate/HeartRateListenerTest.kt @@ -0,0 +1,164 @@ +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(timestampMillis: Long): HeartRateListener { + val ctx = mock(Context::class.java) + `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 + } + + 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 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 start = System.currentTimeMillis() + 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(80, listener.currentHeartRateBpm) + + listener.send(start + d2) + assertEquals(1, heartRates.size) + assertEquals(ActionHeartRate(d2, start + d2, 80.0, device), heartRates.first()) + listener.dispose() + } + + @Test + fun onSensorChanged2() { + val start = System.currentTimeMillis() + val d1 = 10_000L + val d2 = 40_000L + val listener = create(start) + + sendSensorEvent(listener, start, 80) + assertEquals(0, heartRates.size) + assertEquals(80, listener.currentHeartRateBpm) + sendSensorEvent(listener, start + d1,100) + assertEquals(0, heartRates.size) + assertEquals(100, listener.currentHeartRateBpm) + + + listener.send(start + d2) + assertEquals(1, heartRates.size) + assertEquals(ActionHeartRate(d2, start + d2, 95.0, device), heartRates.first()) + listener.dispose() + } + + @Test + fun onSensorChangedMultiple() { + val start = System.currentTimeMillis() + val d1 = 10_000L + val d2 = 40_000L + val listener = create(start) + + sendSensorEvent(listener, start, 80) + listener.send(start + d1) + assertEquals(1, heartRates.size) + + sendSensorEvent(listener, start + d1,100) + assertEquals(1, heartRates.size) + listener.send(start + d2) + assertEquals(2, heartRates.size) + + 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 start = System.currentTimeMillis() + val d1 = 10_000L + val d2 = 40_000L + val listener = create(start) + + sendSensorEvent(listener, start, 80) + sendSensorEvent(listener, start + d1, 100, accuracy = SensorManager.SENSOR_STATUS_NO_CONTACT) + assertNull(listener.currentHeartRateBpm) + listener.send(start + d2) + + assertEquals(1, heartRates.size) + 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() + } +} 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 +}