Merge pull request from buessow/stage

Get heart rate from Wear watch and display as graph in Overview
This commit is contained in:
Milos Kozak 2023-06-01 14:31:25 +02:00 committed by GitHub
commit 0c847616cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 562 additions and 17 deletions
app-wear-shared/shared/src/main/java/info/nightscout/rx/weardata
app/src/main/java/info/nightscout/androidaps/activities
core
graph/src/main/java/info/nightscout/core/graph
interfaces/src/main/java/info/nightscout/interfaces/overview
ui/src/main/res
database/impl/src
androidTest/java/info/nightscout/database/impl
main/java/info/nightscout/database/impl
implementation/src/main/java/info/nightscout/implementation/overview
plugins/main/src/main
java/info/nightscout/plugins/general
res/values
wear/src
main
test/java/info/nightscout/androidaps/heartrate
workflow/src/main/java/info/nightscout/workflow

View file

@ -3,6 +3,7 @@ package info.nightscout.rx.weardata
import info.nightscout.rx.events.Event import info.nightscout.rx.events.Event
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.joda.time.DateTime
import java.util.Objects import java.util.Objects
@Serializable @Serializable
@ -90,6 +91,16 @@ sealed class EventData : Event() {
@Serializable @Serializable
data class ActionQuickWizardPreCheck(val guid: String) : EventData() data class ActionQuickWizardPreCheck(val guid: String) : EventData()
@Serializable
data class ActionHeartRate(
val 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 @Serializable
data class ActionTempTargetPreCheck( data class ActionTempTargetPreCheck(
val command: TempTargetCommand, val command: TempTargetCommand,

View file

@ -327,6 +327,7 @@ class HistoryBrowseActivity : TranslatedDaggerAppCompatActivity() {
var useRatioForScale = false var useRatioForScale = false
var useDSForScale = false var useDSForScale = false
var useBGIForScale = false var useBGIForScale = false
var useHRForScale = false
when { when {
menuChartSettings[g + 1][OverviewMenus.CharType.ABS.ordinal] -> useABSForScale = true menuChartSettings[g + 1][OverviewMenus.CharType.ABS.ordinal] -> useABSForScale = true
menuChartSettings[g + 1][OverviewMenus.CharType.IOB.ordinal] -> useIobForScale = 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.BGI.ordinal] -> useBGIForScale = true
menuChartSettings[g + 1][OverviewMenus.CharType.SEN.ordinal] -> useRatioForScale = true menuChartSettings[g + 1][OverviewMenus.CharType.SEN.ordinal] -> useRatioForScale = true
menuChartSettings[g + 1][OverviewMenus.CharType.DEVSLOPE.ordinal] -> useDSForScale = 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] 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.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.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.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 // set manual x bounds to have nice steps
secondGraphData.formatAxis(historyBrowserData.overviewData.fromTime, historyBrowserData.overviewData.endTime) secondGraphData.formatAxis(historyBrowserData.overviewData.fromTime, historyBrowserData.overviewData.endTime)

View file

@ -150,4 +150,7 @@ interface OverviewData {
val dsMinScale: Scale val dsMinScale: Scale
var dsMaxSeries: LineGraphSeries<ScaledDataPoint> var dsMaxSeries: LineGraphSeries<ScaledDataPoint>
var dsMinSeries: LineGraphSeries<ScaledDataPoint> var dsMinSeries: LineGraphSeries<ScaledDataPoint>
} var heartRateScale: Scale
var heartRateGraphSeries: LineGraphSeries<DataPointWithLabelInterface>
}

View file

@ -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)
}

View file

@ -54,7 +54,8 @@ public class PointsWithLabelGraphSeries<E extends DataPointWithLabelInterface> e
GENERAL_WITH_DURATION, GENERAL_WITH_DURATION,
COB_FAIL_OVER, COB_FAIL_OVER,
IOB_PREDICTION, IOB_PREDICTION,
BUCKETED_BG BUCKETED_BG,
HEARTRATE,
} }
/** /**
@ -324,6 +325,10 @@ public class PointsWithLabelGraphSeries<E extends DataPointWithLabelInterface> e
mPaint.setStrokeWidth(5); mPaint.setStrokeWidth(5);
canvas.drawRect(endX - 3, bounds.top + py - 3, xPlusLength + 3, bounds.bottom + py + 3, mPaint); 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 // set values above point
} }

View file

@ -15,7 +15,8 @@ interface OverviewMenus {
BGI, BGI,
SEN, SEN,
ACT, ACT,
DEVSLOPE DEVSLOPE,
HR,
} }
val setting: List<Array<Boolean>> val setting: List<Array<Boolean>>
@ -23,4 +24,4 @@ interface OverviewMenus {
fun setupChartMenu(context: Context, chartButton: ImageButton) fun setupChartMenu(context: Context, chartButton: ImageButton)
fun enabledTypes(graph: Int): String fun enabledTypes(graph: Int): String
fun isEnabledIn(type: CharType): Int fun isEnabledIn(type: CharType): Int
} }

View file

@ -216,6 +216,7 @@
<item name="inRangeBackground">@color/inRangeBackground</item> <item name="inRangeBackground">@color/inRangeBackground</item>
<item name="devSlopePosColor">@color/devSlopePos</item> <item name="devSlopePosColor">@color/devSlopePos</item>
<item name="devSlopeNegColor">@color/devSlopeNeg</item> <item name="devSlopeNegColor">@color/devSlopeNeg</item>
<item name="heartRateColor">@color/heartRate</item>
<item name="deviationGreyColor">@color/deviationGrey</item> <item name="deviationGreyColor">@color/deviationGrey</item>
<item name="deviationBlackColor">@color/deviationBlack</item> <item name="deviationBlackColor">@color/deviationBlack</item>
<item name="deviationGreenColor">@color/deviationGreen</item> <item name="deviationGreenColor">@color/deviationGreen</item>

View file

@ -181,6 +181,7 @@
<attr name="bolusDataPointColor" format="reference|color" /> <attr name="bolusDataPointColor" format="reference|color" />
<attr name="profileSwitchColor" format="reference|color" /> <attr name="profileSwitchColor" format="reference|color" />
<attr name="originalBgValueColor" format="reference|color" /> <attr name="originalBgValueColor" format="reference|color" />
<attr name="heartRateColor" format="reference|color" />
<attr name="therapyEvent_NS_MBG" format="reference|color" /> <attr name="therapyEvent_NS_MBG" format="reference|color" />
<attr name="therapyEvent_FINGER_STICK_BG_VALUE" format="reference|color" /> <attr name="therapyEvent_FINGER_STICK_BG_VALUE" format="reference|color" />
<attr name="therapyEvent_EXERCISE" format="reference|color" /> <attr name="therapyEvent_EXERCISE" format="reference|color" />
@ -221,4 +222,4 @@
<attr name="crossTargetColor" format="reference|color" /> <attr name="crossTargetColor" format="reference|color" />
<!---Custom button --> <!---Custom button -->
<attr name="customBtnStyle" format="reference"/> <attr name="customBtnStyle" format="reference"/>
</resources> </resources>

View file

@ -153,6 +153,7 @@
<color name="bgi">#00EEEE</color> <color name="bgi">#00EEEE</color>
<color name="devSlopePos">#FFFFFF00</color> <color name="devSlopePos">#FFFFFF00</color>
<color name="devSlopeNeg">#FFFF00FF</color> <color name="devSlopeNeg">#FFFF00FF</color>
<color name="heartRate">#FFFFFF66</color>
<color name="actionsConfirm">#F6CE22</color> <color name="actionsConfirm">#F6CE22</color>
<color name="deviations">#FF0000</color> <color name="deviations">#FF0000</color>
<color name="cobAlert">#7484E2</color> <color name="cobAlert">#7484E2</color>

View file

@ -219,6 +219,7 @@
<item name="inRangeBackground">@color/inRangeBackground</item> <item name="inRangeBackground">@color/inRangeBackground</item>
<item name="devSlopePosColor">@color/devSlopePos</item> <item name="devSlopePosColor">@color/devSlopePos</item>
<item name="devSlopeNegColor">@color/devSlopeNeg</item> <item name="devSlopeNegColor">@color/devSlopeNeg</item>
<item name="heartRateColor">@color/heartRate</item>
<item name="deviationGreyColor">@color/deviationGrey</item> <item name="deviationGreyColor">@color/deviationGrey</item>
<item name="deviationBlackColor">@color/deviationBlack</item> <item name="deviationBlackColor">@color/deviationBlack</item>
<item name="deviationGreenColor">@color/deviationGreen</item> <item name="deviationGreenColor">@color/deviationGreen</item>

View file

@ -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" private const val TEST_DB_NAME = "testDatabase"
fun createHeartRate(timestamp: Long? = null, beatsPerMinute: Double = 80.0) = fun createHeartRate(timestamp: Long? = null, beatsPerMinute: Double = 80.0) =

View file

@ -934,6 +934,9 @@ import kotlin.math.roundToInt
fun getHeartRatesFromTime(timeMillis: Long) = database.heartRateDao.getFromTime(timeMillis) 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( suspend fun collectNewEntriesSince(since: Long, until: Long, limit: Int, offset: Int) = NewEntries(
apsResults = database.apsResultDao.getNewEntriesSince(since, until, limit, offset), apsResults = database.apsResultDao.getNewEntriesSince(since, until, limit, offset),
apsResultLinks = database.apsResultLinkDao.getNewEntriesSince(since, until, limit, offset), apsResultLinks = database.apsResultLinkDao.getNewEntriesSince(since, until, limit, offset),

View file

@ -23,6 +23,9 @@ internal interface HeartRateDao : TraceableDao<HeartRate> {
@Query("SELECT * FROM $TABLE_HEART_RATE WHERE timestamp >= :timestamp ORDER BY timestamp") @Query("SELECT * FROM $TABLE_HEART_RATE WHERE timestamp >= :timestamp ORDER BY timestamp")
fun getFromTime(timestamp: Long): List<HeartRate> fun getFromTime(timestamp: Long): List<HeartRate>
@Query("SELECT * FROM $TABLE_HEART_RATE WHERE timestamp BETWEEN :startMillis AND :endMillis ORDER BY timestamp")
fun getFromTimeToTime(startMillis: Long, endMillis: Long): List<HeartRate>
@Query("SELECT * FROM $TABLE_HEART_RATE WHERE timestamp > :since AND timestamp <= :until LIMIT :limit OFFSET :offset") @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<HeartRate> fun getNewEntriesSince(since: Long, until: Long, limit: Int, offset: Int): List<HeartRate>
} }

View file

@ -87,6 +87,7 @@ class OverviewDataImpl @Inject constructor(
dsMinSeries = LineGraphSeries() dsMinSeries = LineGraphSeries()
treatmentsSeries = PointsWithLabelGraphSeries() treatmentsSeries = PointsWithLabelGraphSeries()
epsSeries = PointsWithLabelGraphSeries() epsSeries = PointsWithLabelGraphSeries()
heartRateGraphSeries = LineGraphSeries()
} }
override fun initRange() { override fun initRange() {
@ -322,4 +323,6 @@ class OverviewDataImpl @Inject constructor(
override val dsMinScale = Scale() override val dsMinScale = Scale()
override var dsMaxSeries: LineGraphSeries<ScaledDataPoint> = LineGraphSeries() override var dsMaxSeries: LineGraphSeries<ScaledDataPoint> = LineGraphSeries()
override var dsMinSeries: LineGraphSeries<ScaledDataPoint> = LineGraphSeries() override var dsMinSeries: LineGraphSeries<ScaledDataPoint> = LineGraphSeries()
override var heartRateScale = Scale()
override var heartRateGraphSeries: LineGraphSeries<DataPointWithLabelInterface> = LineGraphSeries()
} }

View file

@ -1030,6 +1030,7 @@ class OverviewFragment : DaggerFragment(), View.OnClickListener, OnLongClickList
var useRatioForScale = false var useRatioForScale = false
var useDSForScale = false var useDSForScale = false
var useBGIForScale = false var useBGIForScale = false
var useHRForScale = false
when { when {
menuChartSettings[g + 1][OverviewMenus.CharType.ABS.ordinal] -> useABSForScale = true menuChartSettings[g + 1][OverviewMenus.CharType.ABS.ordinal] -> useABSForScale = true
menuChartSettings[g + 1][OverviewMenus.CharType.IOB.ordinal] -> useIobForScale = 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.BGI.ordinal] -> useBGIForScale = true
menuChartSettings[g + 1][OverviewMenus.CharType.SEN.ordinal] -> useRatioForScale = true menuChartSettings[g + 1][OverviewMenus.CharType.SEN.ordinal] -> useRatioForScale = true
menuChartSettings[g + 1][OverviewMenus.CharType.DEVSLOPE.ordinal] -> useDSForScale = 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] 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, if (useDSForScale) 1.0 else 0.8,
useRatioForScale 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 // set manual x bounds to have nice steps
secondGraphData.formatAxis(overviewData.fromTime, overviewData.endTime) 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.DEV.ordinal] ||
menuChartSettings[g + 1][OverviewMenus.CharType.BGI.ordinal] || menuChartSettings[g + 1][OverviewMenus.CharType.BGI.ordinal] ||
menuChartSettings[g + 1][OverviewMenus.CharType.SEN.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() ).toVisibility()
secondaryGraphsData[g].performUpdate() secondaryGraphsData[g].performUpdate()
} }

View file

@ -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), 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), 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), 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 { companion object {
@ -202,4 +203,4 @@ class OverviewMenusImpl @Inject constructor(
return -1 return -1
} }
} }

View file

@ -258,5 +258,14 @@ class GraphData(
// draw it // draw it
graph.onDataChanged(false, false) 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
}
}

View file

@ -19,6 +19,7 @@ import info.nightscout.database.ValueWrapper
import info.nightscout.database.entities.Bolus import info.nightscout.database.entities.Bolus
import info.nightscout.database.entities.BolusCalculatorResult import info.nightscout.database.entities.BolusCalculatorResult
import info.nightscout.database.entities.GlucoseValue import info.nightscout.database.entities.GlucoseValue
import info.nightscout.database.entities.HeartRate
import info.nightscout.database.entities.TemporaryBasal import info.nightscout.database.entities.TemporaryBasal
import info.nightscout.database.entities.TemporaryTarget import info.nightscout.database.entities.TemporaryTarget
import info.nightscout.database.entities.TotalDailyDose 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.AppRepository
import info.nightscout.database.impl.transactions.CancelCurrentTemporaryTargetIfAnyTransaction import info.nightscout.database.impl.transactions.CancelCurrentTemporaryTargetIfAnyTransaction
import info.nightscout.database.impl.transactions.InsertAndCancelCurrentTemporaryTargetTransaction import info.nightscout.database.impl.transactions.InsertAndCancelCurrentTemporaryTargetTransaction
import info.nightscout.database.impl.transactions.InsertOrUpdateHeartRateTransaction
import info.nightscout.interfaces.Config import info.nightscout.interfaces.Config
import info.nightscout.interfaces.Constants import info.nightscout.interfaces.Constants
import info.nightscout.interfaces.GlucoseUnit import info.nightscout.interfaces.GlucoseUnit
@ -308,6 +310,10 @@ class DataHandlerMobile @Inject constructor(
aapsLogger.debug(LTag.WEAR, "WearException received $it from ${it.sourceNodeId}") aapsLogger.debug(LTag.WEAR, "WearException received $it from ${it.sourceNodeId}")
fabricPrivacy.logWearException(it) fabricPrivacy.logWearException(it)
}, fabricPrivacy::logException) }, fabricPrivacy::logException)
disposable += rxBus
.toObservable(EventData.ActionHeartRate::class.java)
.observeOn(aapsSchedulers.io)
.subscribe({ handleHeartRate(it) }, fabricPrivacy::logException)
} }
private fun handleTddStatus() { private fun handleTddStatus() {
@ -1230,4 +1236,15 @@ class DataHandlerMobile @Inject constructor(
@Synchronized private fun sendError(errorMessage: String) { @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 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()
}
} }

View file

@ -291,6 +291,7 @@
<string name="overview_show_predictions">Predictions</string> <string name="overview_show_predictions">Predictions</string>
<string name="overview_show_treatments">Treatments</string> <string name="overview_show_treatments">Treatments</string>
<string name="overview_show_heartRate">Heart Rate</string>
<string name="overview_show_deviation_slope">Deviation slope</string> <string name="overview_show_deviation_slope">Deviation slope</string>
<string name="overview_show_activity">Activity</string> <string name="overview_show_activity">Activity</string>
<string name="overview_show_bgi">Blood Glucose Impact</string> <string name="overview_show_bgi">Blood Glucose Impact</string>
@ -308,6 +309,7 @@
<string name="abs_insulin_shortname">ABS</string> <string name="abs_insulin_shortname">ABS</string>
<string name="devslope_shortname">DEVSLOPE</string> <string name="devslope_shortname">DEVSLOPE</string>
<string name="treatments_shortname">TREAT</string> <string name="treatments_shortname">TREAT</string>
<string name="heartRate_shortname">HR</string>
<string name="sensitivity_shortname">SENS</string> <string name="sensitivity_shortname">SENS</string>
<string name="graph_scale">Graph scale</string> <string name="graph_scale">Graph scale</string>
<string name="graph_menu_divider_header">Graph</string> <string name="graph_menu_divider_header">Graph</string>

View file

@ -7,6 +7,7 @@
<uses-permission android:name="com.google.android.permission.PROVIDE_BACKGROUND" /> <uses-permission android:name="com.google.android.permission.PROVIDE_BACKGROUND" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.BODY_SENSORS" />
<uses-sdk tools:overrideLibrary="androidx.wear.tiles" /> <uses-sdk tools:overrideLibrary="androidx.wear.tiles" />
@ -268,6 +269,10 @@
</intent-filter> </intent-filter>
</service> </service>
<service
android:name=".heartrate.HeartRateListener"
android:exported="true" />
<service <service
android:name=".complications.LongStatusComplication" android:name=".complications.LongStatusComplication"
android:icon="@drawable/ic_aaps_full" android:icon="@drawable/ic_aaps_full"

View file

@ -4,6 +4,7 @@ import dagger.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
import info.nightscout.androidaps.comm.DataLayerListenerServiceWear import info.nightscout.androidaps.comm.DataLayerListenerServiceWear
import info.nightscout.androidaps.complications.* import info.nightscout.androidaps.complications.*
import info.nightscout.androidaps.heartrate.HeartRateListener
import info.nightscout.androidaps.tile.* import info.nightscout.androidaps.tile.*
import info.nightscout.androidaps.watchfaces.* import info.nightscout.androidaps.watchfaces.*
import info.nightscout.androidaps.watchfaces.utils.BaseWatchFace import info.nightscout.androidaps.watchfaces.utils.BaseWatchFace
@ -13,7 +14,7 @@ import info.nightscout.androidaps.watchfaces.utils.BaseWatchFace
abstract class WearServicesModule { abstract class WearServicesModule {
@ContributesAndroidInjector abstract fun contributesDataLayerListenerService(): DataLayerListenerServiceWear @ContributesAndroidInjector abstract fun contributesDataLayerListenerService(): DataLayerListenerServiceWear
@ContributesAndroidInjector abstract fun contributesHeartRateListenerService(): HeartRateListener
@ContributesAndroidInjector abstract fun contributesBaseComplicationProviderService(): BaseComplicationProviderService @ContributesAndroidInjector abstract fun contributesBaseComplicationProviderService(): BaseComplicationProviderService
@ContributesAndroidInjector abstract fun contributesBrCobIobComplication(): BrCobIobComplication @ContributesAndroidInjector abstract fun contributesBrCobIobComplication(): BrCobIobComplication
@ContributesAndroidInjector abstract fun contributesCobDetailedComplication(): CobDetailedComplication @ContributesAndroidInjector abstract fun contributesCobDetailedComplication(): CobDetailedComplication
@ -45,4 +46,4 @@ abstract class WearServicesModule {
@ContributesAndroidInjector abstract fun contributesTempTargetTileService(): TempTargetTileService @ContributesAndroidInjector abstract fun contributesTempTargetTileService(): TempTargetTileService
@ContributesAndroidInjector abstract fun contributesActionsTileService(): ActionsTileService @ContributesAndroidInjector abstract fun contributesActionsTileService(): ActionsTileService
} }

View file

@ -0,0 +1,186 @@
package info.nightscout.androidaps.heartrate
import android.content.Context
import android.content.Context.SENSOR_SERVICE
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Build
import androidx.annotation.VisibleForTesting
import info.nightscout.androidaps.comm.IntentWearToMobile
import info.nightscout.rx.AapsSchedulers
import info.nightscout.rx.logging.AAPSLogger
import info.nightscout.rx.logging.LTag
import info.nightscout.rx.weardata.EventData
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 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,
aapsSchedulers: AapsSchedulers,
now: Long = System.currentTimeMillis(),
) : SensorEventListener, Disposable {
/** How often we send values to the phone. */
private val samplingIntervalMillis = 60_000L
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}")
val sensorManager = ctx.getSystemService(SENSOR_SERVICE) as SensorManager?
if (sensorManager == null) {
aapsLogger.warn(LTag.WEAR, "Cannot get sensor manager to get heart rate readings")
} else {
val heartRateSensor = sensorManager.getDefaultSensor(Sensor.TYPE_HEART_RATE)
if (heartRateSensor == null) {
aapsLogger.warn(LTag.WEAR, "Cannot get heart rate sensor")
} else {
sensorManager.registerListener(this, heartRateSensor, SensorManager.SENSOR_DELAY_NORMAL)
}
}
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)) }
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
}
}
}
}

View file

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

View file

@ -1,17 +1,28 @@
package info.nightscout.androidaps.interaction package info.nightscout.androidaps.interaction
import android.Manifest
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import info.nightscout.androidaps.R import info.nightscout.androidaps.R
import info.nightscout.rx.logging.AAPSLogger
import info.nightscout.rx.logging.LTag
import preference.WearPreferenceActivity 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
addPreferencesFromResource(R.xml.preferences) addPreferencesFromResource(R.xml.preferences)
PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(this)
val view = window.decorView as ViewGroup val view = window.decorView as ViewGroup
removeBackgroundRecursively(view) removeBackgroundRecursively(view)
view.background = ContextCompat.getDrawable(this, R.drawable.settings_background) view.background = ContextCompat.getDrawable(this, R.drawable.settings_background)
@ -24,4 +35,38 @@ class WatchfaceConfigurationActivity : WearPreferenceActivity() {
removeBackgroundRecursively(parent.getChildAt(i)) removeBackgroundRecursively(parent.getChildAt(i))
parent.background = null 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<String>,
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
}
} }

View file

@ -20,6 +20,7 @@ import dagger.android.AndroidInjection
import info.nightscout.androidaps.R import info.nightscout.androidaps.R
import info.nightscout.androidaps.data.RawDisplayData import info.nightscout.androidaps.data.RawDisplayData
import info.nightscout.androidaps.events.EventWearPreferenceChange import info.nightscout.androidaps.events.EventWearPreferenceChange
import info.nightscout.androidaps.heartrate.HeartRateListener
import info.nightscout.androidaps.interaction.menus.MainMenuActivity import info.nightscout.androidaps.interaction.menus.MainMenuActivity
import info.nightscout.androidaps.interaction.utils.Persistence import info.nightscout.androidaps.interaction.utils.Persistence
import info.nightscout.androidaps.interaction.utils.WearUtil import info.nightscout.androidaps.interaction.utils.WearUtil
@ -99,6 +100,7 @@ abstract class BaseWatchFace : WatchFace() {
private var mLastSvg = "" private var mLastSvg = ""
private var mLastDirection = "" private var mLastDirection = ""
private var heartRateListener: HeartRateListener? = null
override fun onCreate() { override fun onCreate() {
// Not derived from DaggerService, do injection here // Not derived from DaggerService, do injection here
@ -115,6 +117,7 @@ abstract class BaseWatchFace : WatchFace() {
.subscribe { event: EventWearPreferenceChange -> .subscribe { event: EventWearPreferenceChange ->
simpleUi.updatePreferences() simpleUi.updatePreferences()
if (event.changedKey != null && event.changedKey == "delta_granularity") rxBus.send(EventWearToMobile(ActionResendData("BaseWatchFace:onSharedPreferenceChanged"))) if (event.changedKey != null && event.changedKey == "delta_granularity") rxBus.send(EventWearToMobile(ActionResendData("BaseWatchFace:onSharedPreferenceChanged")))
if (event.changedKey == getString(R.string.key_heart_rate_sampling)) updateHeartRateListener()
if (layoutSet) setDataFields() if (layoutSet) setDataFields()
invalidate() invalidate()
} }
@ -139,6 +142,7 @@ abstract class BaseWatchFace : WatchFace() {
layoutView = binding.root layoutView = binding.root
performViewSetup() performViewSetup()
rxBus.send(EventWearToMobile(ActionResendData("BaseWatchFace::onCreate"))) rxBus.send(EventWearToMobile(ActionResendData("BaseWatchFace::onCreate")))
updateHeartRateListener()
} }
private fun forceUpdate() { private fun forceUpdate() {
@ -146,6 +150,20 @@ abstract class BaseWatchFace : WatchFace() {
invalidate() 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) { override fun onTapCommand(tapType: Int, x: Int, y: Int, eventTime: Long) {
binding.chart?.let { chart -> binding.chart?.let { chart ->
if (tapType == TAP_TYPE_TAP && x >= chart.left && x <= chart.right && y >= chart.top && y <= chart.bottom) { if (tapType == TAP_TYPE_TAP && x >= chart.left && x <= chart.right && y >= chart.top && y <= chart.bottom) {

View file

@ -233,5 +233,6 @@
<string name="old">old</string> <string name="old">old</string>
<string name="old_warning">!old!</string> <string name="old_warning">!old!</string>
<string name="error">!err!</string> <string name="error">!err!</string>
<string name="key_heart_rate_sampling" translatable="false">heart_rate_sampling</string>
<string name="pref_heartRateSampling">Heart Rate</string>
</resources> </resources>

View file

@ -180,4 +180,11 @@
android:summary="Input Design" android:summary="Input Design"
android:title="@string/pref_moreWatchfaceSettings" /> android:title="@string/pref_moreWatchfaceSettings" />
<CheckBoxPreference
android:defaultValue="false"
android:key="@string/key_heart_rate_sampling"
android:summary="Enable heart rate sampling."
android:title="@string/pref_heartRateSampling"
app:wear_iconOff="@drawable/settings_off"
app:wear_iconOn="@drawable/settings_on" />
</PreferenceScreen> </PreferenceScreen>

View file

@ -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<ActionHeartRate>()
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()
}
}

View file

@ -3,6 +3,7 @@ package info.nightscout.workflow
import android.content.Context import android.content.Context
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import androidx.work.workDataOf import androidx.work.workDataOf
import com.jjoe64.graphview.series.LineGraphSeries
import info.nightscout.core.events.EventIobCalculationProgress import info.nightscout.core.events.EventIobCalculationProgress
import info.nightscout.core.graph.OverviewData import info.nightscout.core.graph.OverviewData
import info.nightscout.core.graph.data.BolusDataPoint 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.DataPointWithLabelInterface
import info.nightscout.core.graph.data.EffectiveProfileSwitchDataPoint import info.nightscout.core.graph.data.EffectiveProfileSwitchDataPoint
import info.nightscout.core.graph.data.ExtendedBolusDataPoint 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.PointsWithLabelGraphSeries
import info.nightscout.core.graph.data.TherapyEventDataPoint import info.nightscout.core.graph.data.TherapyEventDataPoint
import info.nightscout.core.utils.receivers.DataWorkerStorage import info.nightscout.core.utils.receivers.DataWorkerStorage
@ -129,6 +132,11 @@ class PrepareTreatmentsDataWorker(
data.overviewData.therapyEventSeries = PointsWithLabelGraphSeries(filteredTherapyEvents.toTypedArray()) data.overviewData.therapyEventSeries = PointsWithLabelGraphSeries(filteredTherapyEvents.toTypedArray())
data.overviewData.epsSeries = PointsWithLabelGraphSeries(filteredEps.toTypedArray()) data.overviewData.epsSeries = PointsWithLabelGraphSeries(filteredEps.toTypedArray())
data.overviewData.heartRateGraphSeries = LineGraphSeries<DataPointWithLabelInterface>(
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)) rxBus.send(EventIobCalculationProgress(CalculationWorkflow.ProgressData.PREPARE_TREATMENTS_DATA, 100, null))
return Result.success() return Result.success()
} }
@ -149,4 +157,4 @@ class PrepareTreatmentsDataWorker(
private fun <E : DataPointWithLabelInterface> List<E>.filterTimeframe(fromTime: Long, endTime: Long): List<E> = private fun <E : DataPointWithLabelInterface> List<E>.filterTimeframe(fromTime: Long, endTime: Long): List<E> =
filter { it.x + it.duration >= fromTime && it.x <= endTime } filter { it.x + it.duration >= fromTime && it.x <= endTime }
} }