Merge pull request #3008 from buessow/dev-garmin

Garmin: Fix glucose unites and widget support
This commit is contained in:
Milos Kozak 2023-10-30 14:47:55 +01:00 committed by GitHub
commit 9700ccd5a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 256 additions and 14 deletions

View file

@ -94,6 +94,8 @@ class GarminPlugin @Inject constructor(
server?.close() server?.close()
server = HttpServer(aapsLogger, port).apply { server = HttpServer(aapsLogger, port).apply {
registerEndpoint("/get", ::onGetBloodGlucose) registerEndpoint("/get", ::onGetBloodGlucose)
registerEndpoint("/carbs", ::onPostCarbs)
registerEndpoint("/connect", ::onConnectPump)
} }
} else if (server != null) { } else if (server != null) {
aapsLogger.info(LTag.GARMIN, "stopping HTTP server") aapsLogger.info(LTag.GARMIN, "stopping HTTP server")
@ -243,4 +245,36 @@ class GarminPlugin @Inject constructor(
aapsLogger.warn(LTag.GARMIN, "Skip saving invalid HR $avg $samplingStart..$samplingEnd") aapsLogger.warn(LTag.GARMIN, "Skip saving invalid HR $avg $samplingStart..$samplingEnd")
} }
} }
/** Handles carb notification from the device. */
@VisibleForTesting
@Suppress("UNUSED_PARAMETER")
fun onPostCarbs(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
aapsLogger.info(LTag.GARMIN, "carbs from $caller, req: $uri")
postCarbs(getQueryParameter(uri, "carbs", 0L).toInt())
return ""
}
private fun postCarbs(carbs: Int) {
if (carbs > 0) {
loopHub.postCarbs(carbs)
}
}
/** Handles pump connected notification that the user entered on the Garmin device. */
@VisibleForTesting
@Suppress("UNUSED_PARAMETER")
fun onConnectPump(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
aapsLogger.info(LTag.GARMIN, "connect from $caller, req: $uri")
val minutes = getQueryParameter(uri, "disconnectMinutes", 0L).toInt()
if (minutes > 0) {
loopHub.disconnectPump(minutes)
} else {
loopHub.connectPump()
}
val jo = JsonObject()
jo.addProperty("connected", loopHub.isConnected)
return jo.toString()
}
} }

View file

@ -29,9 +29,19 @@ interface LoopHub {
/** Returns the factor by which the basal rate is currently raised (> 1) or lowered (< 1). */ /** Returns the factor by which the basal rate is currently raised (> 1) or lowered (< 1). */
val temporaryBasal: Double val temporaryBasal: Double
/** Tells the loop algorithm that the pump is physically connected. */
fun connectPump()
/** Tells the loop algorithm that the pump will be physically disconnected
* for the given number of minutes. */
fun disconnectPump(minutes: Int)
/** Retrieves the glucose values starting at from. */ /** Retrieves the glucose values starting at from. */
fun getGlucoseValues(from: Instant, ascending: Boolean): List<GlucoseValue> fun getGlucoseValues(from: Instant, ascending: Boolean): List<GlucoseValue>
/** Notifies the system that carbs were eaten and stores the value. */
fun postCarbs(carbohydrates: Int)
/** Stores hear rate readings that a taken and averaged of the given interval. */ /** Stores hear rate readings that a taken and averaged of the given interval. */
fun storeHeartRate( fun storeHeartRate(
samplingStart: Instant, samplingEnd: Instant, samplingStart: Instant, samplingEnd: Instant,

View file

@ -2,15 +2,26 @@ package app.aaps.plugins.sync.garmin
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import app.aaps.core.interfaces.aps.Loop import app.aaps.core.interfaces.aps.Loop
import app.aaps.core.interfaces.constraints.ConstraintsChecker
import app.aaps.core.interfaces.db.GlucoseUnit import app.aaps.core.interfaces.db.GlucoseUnit
import app.aaps.core.interfaces.iob.IobCobCalculator import app.aaps.core.interfaces.iob.IobCobCalculator
import app.aaps.core.interfaces.logging.AAPSLogger
import app.aaps.core.interfaces.logging.LTag
import app.aaps.core.interfaces.logging.UserEntryLogger
import app.aaps.core.interfaces.profile.Profile import app.aaps.core.interfaces.profile.Profile
import app.aaps.core.interfaces.profile.ProfileFunction import app.aaps.core.interfaces.profile.ProfileFunction
import app.aaps.core.interfaces.pump.DetailedBolusInfo
import app.aaps.core.interfaces.queue.CommandQueue
import app.aaps.core.interfaces.sharedPreferences.SP
import app.aaps.database.ValueWrapper import app.aaps.database.ValueWrapper
import app.aaps.database.entities.EffectiveProfileSwitch import app.aaps.database.entities.EffectiveProfileSwitch
import app.aaps.database.entities.GlucoseValue import app.aaps.database.entities.GlucoseValue
import app.aaps.database.entities.HeartRate import app.aaps.database.entities.HeartRate
import app.aaps.database.entities.OfflineEvent
import app.aaps.database.entities.UserEntry
import app.aaps.database.entities.ValueWithUnit
import app.aaps.database.impl.AppRepository import app.aaps.database.impl.AppRepository
import app.aaps.database.impl.transactions.CancelCurrentOfflineEventIfAnyTransaction
import app.aaps.database.impl.transactions.InsertOrUpdateHeartRateTransaction import app.aaps.database.impl.transactions.InsertOrUpdateHeartRateTransaction
import java.time.Clock import java.time.Clock
import java.time.Instant import java.time.Instant
@ -22,10 +33,15 @@ import javax.inject.Singleton
* Interface to the functionality of the looping algorithm and storage systems. * Interface to the functionality of the looping algorithm and storage systems.
*/ */
class LoopHubImpl @Inject constructor( class LoopHubImpl @Inject constructor(
private val aapsLogger: AAPSLogger,
private val commandQueue: CommandQueue,
private val constraintChecker: ConstraintsChecker,
private val iobCobCalculator: IobCobCalculator, private val iobCobCalculator: IobCobCalculator,
private val loop: Loop, private val loop: Loop,
private val profileFunction: ProfileFunction, private val profileFunction: ProfileFunction,
private val repo: AppRepository, private val repo: AppRepository,
private val userEntryLogger: UserEntryLogger,
private val sp: SP,
) : LoopHub { ) : LoopHub {
@VisibleForTesting @VisibleForTesting
@ -40,7 +56,9 @@ class LoopHubImpl @Inject constructor(
/** Returns the glucose unit (mg/dl or mmol/l) as selected by the user. */ /** Returns the glucose unit (mg/dl or mmol/l) as selected by the user. */
override val glucoseUnit: GlucoseUnit override val glucoseUnit: GlucoseUnit
get() = profileFunction.getProfile()?.units ?: GlucoseUnit.MGDL get() = GlucoseUnit.fromText(sp.getString(
app.aaps.core.utils.R.string.key_units,
GlucoseUnit.MGDL.asText))
/** Returns the remaining bolus insulin on board. */ /** Returns the remaining bolus insulin on board. */
override val insulinOnboard: Double override val insulinOnboard: Double
@ -65,12 +83,51 @@ class LoopHubImpl @Inject constructor(
return if (apsResult == null) Double.NaN else apsResult.percent / 100.0 return if (apsResult == null) Double.NaN else apsResult.percent / 100.0
} }
/** Tells the loop algorithm that the pump is physicallly connected. */
override fun connectPump() {
repo.runTransaction(
CancelCurrentOfflineEventIfAnyTransaction(clock.millis())
).subscribe()
commandQueue.cancelTempBasal(true, null)
userEntryLogger.log(UserEntry.Action.RECONNECT, UserEntry.Sources.GarminDevice)
}
/** Tells the loop algorithm that the pump will be physically disconnected
* for the given number of minutes. */
override fun disconnectPump(minutes: Int) {
currentProfile?.let { p ->
loop.goToZeroTemp(minutes, p, OfflineEvent.Reason.DISCONNECT_PUMP)
userEntryLogger.log(
UserEntry.Action.DISCONNECT,
UserEntry.Sources.GarminDevice,
ValueWithUnit.Minute(minutes)
)
}
}
/** Retrieves the glucose values starting at from. */ /** Retrieves the glucose values starting at from. */
override fun getGlucoseValues(from: Instant, ascending: Boolean): List<GlucoseValue> { override fun getGlucoseValues(from: Instant, ascending: Boolean): List<GlucoseValue> {
return repo.compatGetBgReadingsDataFromTime(from.toEpochMilli(), ascending) return repo.compatGetBgReadingsDataFromTime(from.toEpochMilli(), ascending)
.blockingGet() .blockingGet()
} }
/** Notifies the system that carbs were eaten and stores the value. */
override fun postCarbs(carbohydrates: Int) {
aapsLogger.info(LTag.GARMIN, "post $carbohydrates g carbohydrates")
val carbsAfterConstraints =
carbohydrates.coerceAtMost(constraintChecker.getMaxCarbsAllowed().value())
userEntryLogger.log(
UserEntry.Action.CARBS,
UserEntry.Sources.GarminDevice,
ValueWithUnit.Gram(carbsAfterConstraints)
)
val detailedBolusInfo = DetailedBolusInfo().apply {
eventType = DetailedBolusInfo.EventType.CARBS_CORRECTION
carbs = carbsAfterConstraints.toDouble()
}
commandQueue.bolus(detailedBolusInfo, null)
}
/** Stores hear rate readings that a taken and averaged of the given interval. */ /** Stores hear rate readings that a taken and averaged of the given interval. */
override fun storeHeartRate( override fun storeHeartRate(
samplingStart: Instant, samplingEnd: Instant, samplingStart: Instant, samplingEnd: Instant,

View file

@ -1,5 +1,6 @@
package app.aaps.plugins.sync.garmin package app.aaps.plugins.sync.garmin
import app.aaps.core.interfaces.db.GlucoseUnit
import app.aaps.core.interfaces.resources.ResourceHelper import app.aaps.core.interfaces.resources.ResourceHelper
import app.aaps.core.interfaces.rx.events.EventNewBG import app.aaps.core.interfaces.rx.events.EventNewBG
import app.aaps.core.interfaces.sharedPreferences.SP import app.aaps.core.interfaces.sharedPreferences.SP
@ -9,14 +10,18 @@ import dagger.android.AndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.Mock import org.mockito.Mock
import org.mockito.Mockito.atMost import org.mockito.Mockito.atMost
import org.mockito.Mockito.mock import org.mockito.Mockito.mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.Mockito.verifyNoMoreInteractions
import org.mockito.Mockito.`when` import org.mockito.Mockito.`when`
import java.net.SocketAddress
import java.net.URI import java.net.URI
import java.time.Clock import java.time.Clock
import java.time.Instant import java.time.Instant
@ -113,4 +118,91 @@ class GarminPluginTest: TestBase() {
verify(gp.newValue).signalAll() verify(gp.newValue).signalAll()
verify(loopHub).getGlucoseValues(from, true) verify(loopHub).getGlucoseValues(from, true)
} }
@Test
fun testOnGetBloodGlucose() {
`when`(loopHub.isConnected).thenReturn(true)
`when`(loopHub.insulinOnboard).thenReturn(3.14)
`when`(loopHub.temporaryBasal).thenReturn(0.8)
val from = getGlucoseValuesFrom
`when`(loopHub.getGlucoseValues(from, true)).thenReturn(
listOf(createGlucoseValue(Instant.ofEpochSecond(1_000))))
val hr = createHeartRate(99)
val uri = createUri(hr)
val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null)
assertEquals(
"{\"encodedGlucose\":\"0A+6AQ==\"," +
"\"remainingInsulin\":3.14," +
"\"glucoseUnit\":\"mmoll\",\"temporaryBasalRate\":0.8," +
"\"profile\":\"D\",\"connected\":true}",
result.toString())
verify(loopHub).getGlucoseValues(from, true)
verify(loopHub).insulinOnboard
verify(loopHub).temporaryBasal
verify(loopHub).isConnected
verify(loopHub).glucoseUnit
verify(loopHub).storeHeartRate(
Instant.ofEpochSecond(hr["hrStart"] as Long),
Instant.ofEpochSecond(hr["hrEnd"] as Long),
99,
hr["device"] as String)
}
@Test
fun testOnGetBloodGlucose_Wait() {
`when`(loopHub.isConnected).thenReturn(true)
`when`(loopHub.insulinOnboard).thenReturn(3.14)
`when`(loopHub.temporaryBasal).thenReturn(0.8)
`when`(loopHub.glucoseUnit).thenReturn(GlucoseUnit.MMOL)
val from = getGlucoseValuesFrom
`when`(loopHub.getGlucoseValues(from, true)).thenReturn(
listOf(createGlucoseValue(clock.instant().minusSeconds(330))))
val params = createHeartRate(99).toMutableMap()
params["wait"] = 10
val uri = createUri(params)
gp.newValue = mock(Condition::class.java)
val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null)
assertEquals(
"{\"encodedGlucose\":\"/wS6AQ==\"," +
"\"remainingInsulin\":3.14," +
"\"glucoseUnit\":\"mmoll\",\"temporaryBasalRate\":0.8," +
"\"profile\":\"D\",\"connected\":true}",
result.toString())
verify(gp.newValue).awaitNanos(anyLong())
verify(loopHub, times(2)).getGlucoseValues(from, true)
verify(loopHub).insulinOnboard
verify(loopHub).temporaryBasal
verify(loopHub).isConnected
verify(loopHub).glucoseUnit
verify(loopHub).storeHeartRate(
Instant.ofEpochSecond(params["hrStart"] as Long),
Instant.ofEpochSecond(params["hrEnd"] as Long),
99,
params["device"] as String)
}
@Test
fun testOnPostCarbs() {
val uri = createUri(mapOf("carbs" to "12"))
assertEquals("", gp.onPostCarbs(mock(SocketAddress::class.java), uri, null))
verify(loopHub).postCarbs(12)
}
@Test
fun testOnConnectPump_Disconnect() {
val uri = createUri(mapOf("disconnectMinutes" to "20"))
`when`(loopHub.isConnected).thenReturn(false)
assertEquals("{\"connected\":false}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null))
verify(loopHub).disconnectPump(20)
verify(loopHub).isConnected
}
@Test
fun testOnConnectPump_Connect() {
val uri = createUri(mapOf("disconnectMinutes" to "0"))
`when`(loopHub.isConnected).thenReturn(true)
assertEquals("{\"connected\":true}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null))
verify(loopHub).connectPump()
verify(loopHub).isConnected
}
} }

View file

@ -1,8 +1,8 @@
package app.aaps.plugins.sync.garmin package app.aaps.plugins.sync.garmin
import app.aaps.core.interfaces.aps.APSResult import app.aaps.core.interfaces.aps.APSResult
import app.aaps.core.interfaces.aps.Loop import app.aaps.core.interfaces.aps.Loop
import app.aaps.core.interfaces.constraints.Constraint
import app.aaps.core.interfaces.constraints.ConstraintsChecker import app.aaps.core.interfaces.constraints.ConstraintsChecker
import app.aaps.core.interfaces.db.GlucoseUnit import app.aaps.core.interfaces.db.GlucoseUnit
import app.aaps.core.interfaces.iob.IobCobCalculator import app.aaps.core.interfaces.iob.IobCobCalculator
@ -10,13 +10,19 @@ import app.aaps.core.interfaces.iob.IobTotal
import app.aaps.core.interfaces.logging.UserEntryLogger import app.aaps.core.interfaces.logging.UserEntryLogger
import app.aaps.core.interfaces.profile.Profile import app.aaps.core.interfaces.profile.Profile
import app.aaps.core.interfaces.profile.ProfileFunction import app.aaps.core.interfaces.profile.ProfileFunction
import app.aaps.core.interfaces.pump.DetailedBolusInfo
import app.aaps.core.interfaces.queue.CommandQueue import app.aaps.core.interfaces.queue.CommandQueue
import app.aaps.core.interfaces.sharedPreferences.SP
import app.aaps.database.ValueWrapper import app.aaps.database.ValueWrapper
import app.aaps.database.entities.EffectiveProfileSwitch import app.aaps.database.entities.EffectiveProfileSwitch
import app.aaps.database.entities.GlucoseValue import app.aaps.database.entities.GlucoseValue
import app.aaps.database.entities.HeartRate import app.aaps.database.entities.HeartRate
import app.aaps.database.entities.OfflineEvent
import app.aaps.database.entities.UserEntry
import app.aaps.database.entities.ValueWithUnit
import app.aaps.database.entities.embedments.InsulinConfiguration import app.aaps.database.entities.embedments.InsulinConfiguration
import app.aaps.database.impl.AppRepository import app.aaps.database.impl.AppRepository
import app.aaps.database.impl.transactions.CancelCurrentOfflineEventIfAnyTransaction
import app.aaps.database.impl.transactions.InsertOrUpdateHeartRateTransaction import app.aaps.database.impl.transactions.InsertOrUpdateHeartRateTransaction
import app.aaps.shared.tests.TestBase import app.aaps.shared.tests.TestBase
import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Completable
@ -27,6 +33,8 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.argThat
import org.mockito.ArgumentMatchers.isNull
import org.mockito.Mock import org.mockito.Mock
import org.mockito.Mockito.mock import org.mockito.Mockito.mock
import org.mockito.Mockito.times import org.mockito.Mockito.times
@ -45,13 +53,17 @@ class LoopHubTest: TestBase() {
@Mock lateinit var profileFunction: ProfileFunction @Mock lateinit var profileFunction: ProfileFunction
@Mock lateinit var repo: AppRepository @Mock lateinit var repo: AppRepository
@Mock lateinit var userEntryLogger: UserEntryLogger @Mock lateinit var userEntryLogger: UserEntryLogger
@Mock lateinit var sp: SP
private lateinit var loopHub: LoopHubImpl private lateinit var loopHub: LoopHubImpl
private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC")) private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC"))
@BeforeEach @BeforeEach
fun setup() { fun setup() {
loopHub = LoopHubImpl(iobCobCalculator, loop, profileFunction, repo) loopHub = LoopHubImpl(
aapsLogger, commandQueue, constraints, iobCobCalculator, loop,
profileFunction, repo, userEntryLogger, sp
)
loopHub.clock = clock loopHub.clock = clock
} }
@ -83,18 +95,10 @@ class LoopHubTest: TestBase() {
@Test @Test
fun testGlucoseUnit() { fun testGlucoseUnit() {
val profile = mock(Profile::class.java) `when`(sp.getString(app.aaps.core.utils.R.string.key_units, GlucoseUnit.MGDL.asText)).thenReturn("mg/dl")
`when`(profile.units).thenReturn(GlucoseUnit.MMOL)
`when`(profileFunction.getProfile()).thenReturn(profile)
assertEquals(GlucoseUnit.MMOL, loopHub.glucoseUnit)
verify(profileFunction, times(1)).getProfile()
}
@Test
fun testGlucoseUnitNullProfile() {
`when`(profileFunction.getProfile()).thenReturn(null)
assertEquals(GlucoseUnit.MGDL, loopHub.glucoseUnit) assertEquals(GlucoseUnit.MGDL, loopHub.glucoseUnit)
verify(profileFunction, times(1)).getProfile() `when`(sp.getString(app.aaps.core.utils.R.string.key_units, GlucoseUnit.MGDL.asText)).thenReturn("mmol")
assertEquals(GlucoseUnit.MMOL, loopHub.glucoseUnit)
} }
@Test @Test
@ -165,6 +169,32 @@ class LoopHubTest: TestBase() {
verify(loop, times(1)).lastRun verify(loop, times(1)).lastRun
} }
@Test
fun testConnectPump() {
val c = mock(Completable::class.java)
val dummy = CancelCurrentOfflineEventIfAnyTransaction(0)
val matcher = {
argThat<CancelCurrentOfflineEventIfAnyTransaction> { t -> t.timestamp == clock.millis() }}
`when`(repo.runTransaction(matcher() ?: dummy)).thenReturn(c)
loopHub.connectPump()
verify(repo).runTransaction(matcher() ?: dummy)
verify(commandQueue).cancelTempBasal(true, null)
verify(userEntryLogger).log(UserEntry.Action.RECONNECT, UserEntry.Sources.GarminDevice)
}
@Test
fun testDisconnectPump() {
val profile = mock(Profile::class.java)
`when`(profileFunction.getProfile()).thenReturn(profile)
loopHub.disconnectPump(23)
verify(profileFunction).getProfile()
verify(loop).goToZeroTemp(23, profile, OfflineEvent.Reason.DISCONNECT_PUMP)
verify(userEntryLogger).log(
UserEntry.Action.DISCONNECT,
UserEntry.Sources.GarminDevice,
ValueWithUnit.Minute(23))
}
@Test @Test
fun testGetGlucoseValues() { fun testGetGlucoseValues() {
val glucoseValues = listOf( val glucoseValues = listOf(
@ -180,6 +210,25 @@ class LoopHubTest: TestBase() {
verify(repo).compatGetBgReadingsDataFromTime(1001_000, false) verify(repo).compatGetBgReadingsDataFromTime(1001_000, false)
} }
@Test
fun testPostCarbs() {
@Suppress("unchecked_cast")
val constraint = mock(Constraint::class.java) as Constraint<Int>
`when`(constraint.value()).thenReturn(99)
`when`(constraints.getMaxCarbsAllowed()).thenReturn(constraint)
loopHub.postCarbs(100)
verify(constraints).getMaxCarbsAllowed()
verify(userEntryLogger).log(
UserEntry.Action.CARBS,
UserEntry.Sources.GarminDevice,
ValueWithUnit.Gram(99))
verify(commandQueue).bolus(
argThat { b ->
b!!.eventType == DetailedBolusInfo.EventType.CARBS_CORRECTION &&
b.carbs == 99.0 }?: DetailedBolusInfo() ,
isNull())
}
@Test @Test
fun testStoreHeartRate() { fun testStoreHeartRate() {
val samplingStart = Instant.ofEpochMilli(1_001_000) val samplingStart = Instant.ofEpochMilli(1_001_000)