Merge pull request #2562 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

View file

@ -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,

View file

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

View file

@ -150,4 +150,7 @@ interface OverviewData {
val dsMinScale: Scale
var dsMaxSeries: 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,
COB_FAIL_OVER,
IOB_PREDICTION,
BUCKETED_BG
BUCKETED_BG,
HEARTRATE,
}
/**
@ -324,6 +325,10 @@ public class PointsWithLabelGraphSeries<E extends DataPointWithLabelInterface> 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
}

View file

@ -15,7 +15,8 @@ interface OverviewMenus {
BGI,
SEN,
ACT,
DEVSLOPE
DEVSLOPE,
HR,
}
val setting: List<Array<Boolean>>

View file

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

View file

@ -181,6 +181,7 @@
<attr name="bolusDataPointColor" format="reference|color" />
<attr name="profileSwitchColor" 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_FINGER_STICK_BG_VALUE" format="reference|color" />
<attr name="therapyEvent_EXERCISE" format="reference|color" />

View file

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

View file

@ -219,6 +219,7 @@
<item name="inRangeBackground">@color/inRangeBackground</item>
<item name="devSlopePosColor">@color/devSlopePos</item>
<item name="devSlopeNegColor">@color/devSlopeNeg</item>
<item name="heartRateColor">@color/heartRate</item>
<item name="deviationGreyColor">@color/deviationGrey</item>
<item name="deviationBlackColor">@color/deviationBlack</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"
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 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),

View file

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

View file

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

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),
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 {

View file

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

View file

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

View file

@ -291,6 +291,7 @@
<string name="overview_show_predictions">Predictions</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_activity">Activity</string>
<string name="overview_show_bgi">Blood Glucose Impact</string>
@ -308,6 +309,7 @@
<string name="abs_insulin_shortname">ABS</string>
<string name="devslope_shortname">DEVSLOPE</string>
<string name="treatments_shortname">TREAT</string>
<string name="heartRate_shortname">HR</string>
<string name="sensitivity_shortname">SENS</string>
<string name="graph_scale">Graph scale</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="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.BODY_SENSORS" />
<uses-sdk tools:overrideLibrary="androidx.wear.tiles" />
@ -268,6 +269,10 @@
</intent-filter>
</service>
<service
android:name=".heartrate.HeartRateListener"
android:exported="true" />
<service
android:name=".complications.LongStatusComplication"
android:icon="@drawable/ic_aaps_full"

View file

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

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
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() {

View file

@ -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<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.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) {

View file

@ -233,5 +233,6 @@
<string name="old">old</string>
<string name="old_warning">!old!</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>

View file

@ -180,4 +180,11 @@
android:summary="Input Design"
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>

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 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<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))
return Result.success()
}