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 = HttpServer(aapsLogger, port).apply {
registerEndpoint("/get", ::onGetBloodGlucose)
registerEndpoint("/carbs", ::onPostCarbs)
registerEndpoint("/connect", ::onConnectPump)
}
} else if (server != null) {
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")
}
}
/** 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). */
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. */
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. */
fun storeHeartRate(
samplingStart: Instant, samplingEnd: Instant,

View file

@ -2,15 +2,26 @@ package app.aaps.plugins.sync.garmin
import androidx.annotation.VisibleForTesting
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.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.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.entities.EffectiveProfileSwitch
import app.aaps.database.entities.GlucoseValue
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.transactions.CancelCurrentOfflineEventIfAnyTransaction
import app.aaps.database.impl.transactions.InsertOrUpdateHeartRateTransaction
import java.time.Clock
import java.time.Instant
@ -22,10 +33,15 @@ import javax.inject.Singleton
* Interface to the functionality of the looping algorithm and storage systems.
*/
class LoopHubImpl @Inject constructor(
private val aapsLogger: AAPSLogger,
private val commandQueue: CommandQueue,
private val constraintChecker: ConstraintsChecker,
private val iobCobCalculator: IobCobCalculator,
private val loop: Loop,
private val profileFunction: ProfileFunction,
private val repo: AppRepository,
private val userEntryLogger: UserEntryLogger,
private val sp: SP,
) : LoopHub {
@VisibleForTesting
@ -40,7 +56,9 @@ class LoopHubImpl @Inject constructor(
/** Returns the glucose unit (mg/dl or mmol/l) as selected by the user. */
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. */
override val insulinOnboard: Double
@ -65,12 +83,51 @@ class LoopHubImpl @Inject constructor(
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. */
override fun getGlucoseValues(from: Instant, ascending: Boolean): List<GlucoseValue> {
return repo.compatGetBgReadingsDataFromTime(from.toEpochMilli(), ascending)
.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. */
override fun storeHeartRate(
samplingStart: Instant, samplingEnd: Instant,

View file

@ -1,5 +1,6 @@
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.rx.events.EventNewBG
import app.aaps.core.interfaces.sharedPreferences.SP
@ -9,14 +10,18 @@ import dagger.android.AndroidInjector
import dagger.android.HasAndroidInjector
import org.junit.jupiter.api.AfterEach
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.Test
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.Mock
import org.mockito.Mockito.atMost
import org.mockito.Mockito.mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions
import org.mockito.Mockito.`when`
import java.net.SocketAddress
import java.net.URI
import java.time.Clock
import java.time.Instant
@ -113,4 +118,91 @@ class GarminPluginTest: TestBase() {
verify(gp.newValue).signalAll()
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
import app.aaps.core.interfaces.aps.APSResult
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.db.GlucoseUnit
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.profile.Profile
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.entities.EffectiveProfileSwitch
import app.aaps.database.entities.GlucoseValue
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.impl.AppRepository
import app.aaps.database.impl.transactions.CancelCurrentOfflineEventIfAnyTransaction
import app.aaps.database.impl.transactions.InsertOrUpdateHeartRateTransaction
import app.aaps.shared.tests.TestBase
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.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.argThat
import org.mockito.ArgumentMatchers.isNull
import org.mockito.Mock
import org.mockito.Mockito.mock
import org.mockito.Mockito.times
@ -45,13 +53,17 @@ class LoopHubTest: TestBase() {
@Mock lateinit var profileFunction: ProfileFunction
@Mock lateinit var repo: AppRepository
@Mock lateinit var userEntryLogger: UserEntryLogger
@Mock lateinit var sp: SP
private lateinit var loopHub: LoopHubImpl
private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC"))
@BeforeEach
fun setup() {
loopHub = LoopHubImpl(iobCobCalculator, loop, profileFunction, repo)
loopHub = LoopHubImpl(
aapsLogger, commandQueue, constraints, iobCobCalculator, loop,
profileFunction, repo, userEntryLogger, sp
)
loopHub.clock = clock
}
@ -83,18 +95,10 @@ class LoopHubTest: TestBase() {
@Test
fun testGlucoseUnit() {
val profile = mock(Profile::class.java)
`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)
`when`(sp.getString(app.aaps.core.utils.R.string.key_units, GlucoseUnit.MGDL.asText)).thenReturn("mg/dl")
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
@ -165,6 +169,32 @@ class LoopHubTest: TestBase() {
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
fun testGetGlucoseValues() {
val glucoseValues = listOf(
@ -180,6 +210,25 @@ class LoopHubTest: TestBase() {
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
fun testStoreHeartRate() {
val samplingStart = Instant.ofEpochMilli(1_001_000)