Local http server communication to integrate Garmin devices.
This commit is contained in:
parent
bb133cdbce
commit
5a17a05ee0
|
@ -43,6 +43,7 @@ import app.aaps.plugins.configuration.maintenance.MaintenancePlugin
|
|||
import app.aaps.plugins.constraints.safety.SafetyPlugin
|
||||
import app.aaps.plugins.insulin.InsulinOrefFreePeakPlugin
|
||||
import app.aaps.plugins.main.general.smsCommunicator.SmsCommunicatorPlugin
|
||||
import app.aaps.plugins.main.general.garmin.GarminPlugin
|
||||
import app.aaps.plugins.main.general.wear.WearPlugin
|
||||
import app.aaps.plugins.sensitivity.SensitivityAAPSPlugin
|
||||
import app.aaps.plugins.sensitivity.SensitivityOref1Plugin
|
||||
|
@ -128,6 +129,7 @@ class MyPreferenceFragment : PreferenceFragmentCompat(), OnSharedPreferenceChang
|
|||
@Inject lateinit var nsSettingStatus: NSSettingsStatus
|
||||
@Inject lateinit var openHumansUploaderPlugin: OpenHumansUploaderPlugin
|
||||
@Inject lateinit var diaconnG8Plugin: DiaconnG8Plugin
|
||||
@Inject lateinit var garminPlugin: GarminPlugin
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
AndroidSupportInjection.inject(this)
|
||||
|
@ -229,6 +231,7 @@ class MyPreferenceFragment : PreferenceFragmentCompat(), OnSharedPreferenceChang
|
|||
addPreferencesFromResource(app.aaps.plugins.configuration.R.xml.pref_datachoices, rootKey)
|
||||
addPreferencesFromResourceIfEnabled(maintenancePlugin, rootKey)
|
||||
addPreferencesFromResourceIfEnabled(openHumansUploaderPlugin, rootKey)
|
||||
addPreferencesFromResourceIfEnabled(garminPlugin, rootKey)
|
||||
}
|
||||
initSummary(preferenceScreen, pluginId != -1)
|
||||
preprocessPreferences()
|
||||
|
|
|
@ -22,6 +22,7 @@ import app.aaps.plugins.insulin.InsulinOrefRapidActingPlugin
|
|||
import app.aaps.plugins.insulin.InsulinOrefUltraRapidActingPlugin
|
||||
import app.aaps.plugins.main.general.actions.ActionsPlugin
|
||||
import app.aaps.plugins.main.general.food.FoodPlugin
|
||||
import app.aaps.plugins.main.general.garmin.GarminPlugin
|
||||
import app.aaps.plugins.main.general.overview.OverviewPlugin
|
||||
import app.aaps.plugins.main.general.persistentNotification.PersistentNotificationPlugin
|
||||
import app.aaps.plugins.main.general.smsCommunicator.SmsCommunicatorPlugin
|
||||
|
@ -465,6 +466,12 @@ abstract class PluginsListModule {
|
|||
@IntKey(610)
|
||||
abstract fun bindAvgSmoothingPlugin(plugin: AvgSmoothingPlugin): PluginBase
|
||||
|
||||
@Binds
|
||||
@AllConfigs
|
||||
@IntoMap
|
||||
@IntKey(623)
|
||||
abstract fun bindGarminPlugin(plugin: GarminPlugin): PluginBase
|
||||
|
||||
@Qualifier
|
||||
annotation class AllConfigs
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ enum class LTag(val tag: String, val defaultValue: Boolean = true, val requiresR
|
|||
DATABASE("DATABASE"),
|
||||
DATATREATMENTS("DATATREATMENTS"),
|
||||
EVENTS("EVENTS", defaultValue = false, requiresRestart = true),
|
||||
GARMIN("GARMIN"),
|
||||
GLUCOSE("GLUCOSE", defaultValue = false),
|
||||
HTTP("HTTP"),
|
||||
LOCATION("LOCATION"),
|
||||
|
|
|
@ -187,7 +187,9 @@ data class UserEntry(
|
|||
Overview, //From OverViewPlugin
|
||||
Stats, //From Stat Activity
|
||||
Aaps, // MainApp
|
||||
GarminDevice,
|
||||
Unknown //if necessary
|
||||
,
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -17,5 +17,16 @@ class InsertOrUpdateHeartRateTransaction(private val heartRate: HeartRate) :
|
|||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
other as InsertOrUpdateHeartRateTransaction
|
||||
return heartRate == other.heartRate
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return heartRate.hashCode()
|
||||
}
|
||||
|
||||
data class TransactionResult(val inserted: List<HeartRate>, val updated: List<HeartRate>)
|
||||
}
|
||||
|
|
|
@ -108,6 +108,7 @@ class UserEntryPresentationHelperImpl @Inject constructor(
|
|||
Sources.ConfigBuilder -> app.aaps.core.ui.R.drawable.ic_cogs
|
||||
Sources.Overview -> app.aaps.core.ui.R.drawable.ic_home
|
||||
Sources.Aaps -> R.drawable.ic_aaps
|
||||
Sources.GarminDevice -> app.aaps.core.ui.R.drawable.ic_generic_icon
|
||||
Sources.Unknown -> app.aaps.core.ui.R.drawable.ic_generic_icon
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ package app.aaps.plugins.main.di
|
|||
|
||||
import app.aaps.core.interfaces.iob.IobCobCalculator
|
||||
import app.aaps.core.interfaces.smsCommunicator.SmsCommunicator
|
||||
import app.aaps.plugins.main.general.garmin.GarminModule
|
||||
import app.aaps.plugins.main.general.persistentNotification.DummyService
|
||||
import app.aaps.plugins.main.general.smsCommunicator.SmsCommunicatorPlugin
|
||||
import app.aaps.plugins.main.general.wear.WearFragment
|
||||
|
@ -22,7 +23,8 @@ import dagger.android.ContributesAndroidInjector
|
|||
SkinsUiModule::class,
|
||||
ActionsModule::class,
|
||||
WearModule::class,
|
||||
OverviewModule::class
|
||||
OverviewModule::class,
|
||||
GarminModule::class,
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
@ -0,0 +1,187 @@
|
|||
package app.aaps.plugins.main.general.garmin
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.IntBuffer
|
||||
import java.nio.LongBuffer
|
||||
import java.util.Base64
|
||||
|
||||
/** Efficient encoding for glucose/timestamp pairs.
|
||||
*
|
||||
* Garmin devices don't have much memory when deserializing received JSON messages.
|
||||
* In particular older devices my kill our app when we send 2h of glucose values. Therefore, we
|
||||
* encode the values efficiently.
|
||||
* We use [var encoding](https://en.wikipedia.org/wiki/Variable-width_encoding). In order to
|
||||
* keep timestamps small, we encode the difference to the previous pair and to encode negative values
|
||||
* efficiently, we use [zig-zag encoding](https://en.wikipedia.org/wiki/Variable-length_quantity).
|
||||
*/
|
||||
class DeltaVarEncodedList {
|
||||
private var lastValues: IntArray
|
||||
private var data: ByteArray
|
||||
private val start: Int = 0
|
||||
private var end: Int = 0
|
||||
|
||||
val byteSize: Int get() = end - start
|
||||
var size: Int = 0
|
||||
private set
|
||||
|
||||
/** Creates a new list of given size.
|
||||
*
|
||||
* @param byteSize How large the internal buffer should be. The buffer doesn't grow
|
||||
* automatically, so you need to set it large enough.
|
||||
* @param entrySize Size of each entry (e.g. 2 for glucose+timestamp). Delta is computed on each
|
||||
* entrySize value.
|
||||
*/
|
||||
constructor(byteSize: Int, entrySize: Int) {
|
||||
data = ByteArray(toLongBoundary(byteSize))
|
||||
lastValues = IntArray(entrySize)
|
||||
}
|
||||
|
||||
/** Creates a list from encoded values.
|
||||
*
|
||||
* @param lastValues the last values of the list. Needs to be entrySize long.
|
||||
* @param byteBuffer the encoded data
|
||||
*/
|
||||
constructor(lastValues: IntArray, byteBuffer: ByteBuffer) {
|
||||
this.lastValues = lastValues
|
||||
data = ByteArray(byteBuffer.limit())
|
||||
byteBuffer.position(0)
|
||||
byteBuffer.get(data)
|
||||
end = data.size
|
||||
val it = DeltaIterator()
|
||||
while (it.next()) {
|
||||
size++
|
||||
}
|
||||
}
|
||||
|
||||
/** Gets the encoded data. */
|
||||
fun encodedData(): List<Long> {
|
||||
val byteBuffer: ByteBuffer = ByteBuffer.wrap(data)
|
||||
byteBuffer.order(ByteOrder.LITTLE_ENDIAN)
|
||||
byteBuffer.limit(toLongBoundary(end))
|
||||
val buffer: LongBuffer = byteBuffer.asLongBuffer()
|
||||
val encodedData: MutableList<Long> = ArrayList(buffer.limit())
|
||||
while (buffer.position() < buffer.limit()) {
|
||||
encodedData.add(buffer.get())
|
||||
}
|
||||
return encodedData
|
||||
}
|
||||
|
||||
fun encodedBase64(): String {
|
||||
val byteBuffer: ByteBuffer = ByteBuffer.wrap(data, start, end)
|
||||
byteBuffer.order(ByteOrder.LITTLE_ENDIAN)
|
||||
return String(Base64.getEncoder().encode(byteBuffer).array())
|
||||
}
|
||||
|
||||
private fun addVarEncoded(value: Int) {
|
||||
var remaining: Int = value
|
||||
do {
|
||||
// Grow data if needed (double size).
|
||||
if (end == data.size) {
|
||||
val newData = ByteArray(2 * end)
|
||||
System.arraycopy(data, 0, newData, 0, end)
|
||||
data = newData
|
||||
}
|
||||
if ((remaining and 0x7f.inv()) != 0) {
|
||||
data[end++] = ((remaining and 0x7f) or 0x80).toByte()
|
||||
} else {
|
||||
data[end++] = remaining.toByte()
|
||||
}
|
||||
remaining = remaining ushr 7
|
||||
} while (remaining != 0)
|
||||
}
|
||||
|
||||
private fun addI(value: Int, idx: Int) {
|
||||
val delta: Int = value - lastValues[idx]
|
||||
addVarEncoded(zigzagEncode(delta))
|
||||
lastValues[idx] = value
|
||||
}
|
||||
|
||||
/** Adds an entry to the buffer.
|
||||
*
|
||||
* [values] length must be the same as entrySize provided in the constructor. */
|
||||
fun add(vararg values: Int) {
|
||||
if (values.size != lastValues.size) {
|
||||
throw IllegalArgumentException()
|
||||
}
|
||||
for (idx in values.indices) {
|
||||
addI(values[idx], idx)
|
||||
}
|
||||
size++
|
||||
}
|
||||
|
||||
fun toArray(): IntArray {
|
||||
val values: IntBuffer = IntBuffer.allocate(lastValues.size * size)
|
||||
val it = DeltaIterator()
|
||||
while (it.next()) {
|
||||
values.put(it.current())
|
||||
}
|
||||
val next: IntArray = lastValues.copyOf(lastValues.size)
|
||||
var nextIdx: Int = next.size - 1
|
||||
for (valueIdx in values.position() - 1 downTo 0) {
|
||||
val value: Int = values.get(valueIdx)
|
||||
values.put(valueIdx, next[nextIdx])
|
||||
next[nextIdx] -= value
|
||||
nextIdx = (nextIdx + 1) % next.size
|
||||
}
|
||||
return values.array()
|
||||
}
|
||||
|
||||
private inner class DeltaIterator {
|
||||
|
||||
private val buffer: ByteBuffer = ByteBuffer.wrap(data)
|
||||
private val currentValues: IntArray = IntArray(lastValues.size)
|
||||
private var more: Boolean = false
|
||||
fun current(): IntArray {
|
||||
return currentValues
|
||||
}
|
||||
|
||||
private fun readNext(): Int {
|
||||
var v = 0
|
||||
var offset = 0
|
||||
var b: Int
|
||||
do {
|
||||
if (!buffer.hasRemaining()) {
|
||||
more = false
|
||||
return 0
|
||||
}
|
||||
b = buffer.get().toInt()
|
||||
v = v or ((b and 0x7f) shl offset)
|
||||
offset += 7
|
||||
} while ((b and 0x80) != 0)
|
||||
return zigzagDecode(v)
|
||||
}
|
||||
|
||||
operator fun next(): Boolean {
|
||||
if (!buffer.hasRemaining()) return false
|
||||
more = true
|
||||
var i = 0
|
||||
while (i < currentValues.size && more) {
|
||||
currentValues[i] = readNext()
|
||||
i++
|
||||
}
|
||||
return more
|
||||
}
|
||||
|
||||
init {
|
||||
buffer.position(start)
|
||||
buffer.limit(end)
|
||||
buffer.order(ByteOrder.LITTLE_ENDIAN)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private fun toLongBoundary(i: Int): Int {
|
||||
return 8 * ((i + 7) / 8)
|
||||
}
|
||||
|
||||
private fun zigzagEncode(i: Int): Int {
|
||||
return (i shr 31) xor (i shl 1)
|
||||
}
|
||||
|
||||
private fun zigzagDecode(i: Int): Int {
|
||||
return (i ushr 1) xor -(i and 1)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package app.aaps.plugins.main.general.garmin
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
|
||||
@Module
|
||||
abstract class GarminModule {
|
||||
@Suppress("unused")
|
||||
@Binds abstract fun bindLoopHub(loopHub: LoopHubImpl): LoopHub
|
||||
}
|
|
@ -0,0 +1,245 @@
|
|||
package app.aaps.plugins.main.general.garmin
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import app.aaps.core.interfaces.db.GlucoseUnit
|
||||
import app.aaps.core.interfaces.logging.AAPSLogger
|
||||
import app.aaps.core.interfaces.logging.LTag
|
||||
import app.aaps.core.interfaces.plugin.PluginBase
|
||||
import app.aaps.core.interfaces.plugin.PluginDescription
|
||||
import app.aaps.core.interfaces.plugin.PluginType
|
||||
import app.aaps.core.interfaces.resources.ResourceHelper
|
||||
import app.aaps.core.interfaces.rx.bus.RxBus
|
||||
import app.aaps.core.interfaces.rx.events.EventNewBG
|
||||
import app.aaps.core.interfaces.rx.events.EventPreferenceChange
|
||||
import app.aaps.core.interfaces.sharedPreferences.SP
|
||||
import app.aaps.database.entities.GlucoseValue
|
||||
import app.aaps.plugins.main.R
|
||||
import com.google.gson.JsonObject
|
||||
import dagger.android.HasAndroidInjector
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import java.net.SocketAddress
|
||||
import java.net.URI
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
import java.util.concurrent.locks.Condition
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Support communication with Garmin devices.
|
||||
*
|
||||
* This plugin supports sending glucose values to Garmin devices and receiving
|
||||
* carbs, heart rate and pump disconnect events from the device. It communicates
|
||||
* via HTTP on localhost or Garmin's native CIQ library.
|
||||
*/
|
||||
@Singleton
|
||||
class GarminPlugin @Inject constructor(
|
||||
injector: HasAndroidInjector,
|
||||
aapsLogger: AAPSLogger,
|
||||
resourceHelper: ResourceHelper,
|
||||
private val loopHub: LoopHub,
|
||||
private val rxBus: RxBus,
|
||||
private val sp: SP,
|
||||
) : PluginBase(
|
||||
PluginDescription()
|
||||
.mainType(PluginType.GENERAL)
|
||||
.pluginName(R.string.garmin)
|
||||
.shortName(R.string.garmin)
|
||||
.description(R.string.garmin_description)
|
||||
.preferencesId(R.xml.pref_garmin),
|
||||
aapsLogger, resourceHelper, injector
|
||||
) {
|
||||
/** HTTP Server for local HTTP server communication (device app requests values) .*/
|
||||
private var server: HttpServer? = null
|
||||
|
||||
private val disposable = CompositeDisposable()
|
||||
|
||||
@VisibleForTesting
|
||||
var clock: Clock = Clock.systemUTC()
|
||||
|
||||
private val valueLock = ReentrantLock()
|
||||
@VisibleForTesting
|
||||
var newValue: Condition = valueLock.newCondition()
|
||||
private var lastGlucoseValueTimestamp: Long? = null
|
||||
private val glucoseUnitStr get() = if (loopHub.glucoseUnit == GlucoseUnit.MGDL) "mgdl" else "mmoll"
|
||||
|
||||
private fun onPreferenceChange(event: EventPreferenceChange) {
|
||||
aapsLogger.info(LTag.GARMIN, "preferences change ${event.changedKey}")
|
||||
setupHttpServer()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
aapsLogger.info(LTag.GARMIN, "start")
|
||||
disposable.add(
|
||||
rxBus
|
||||
.toObservable(EventPreferenceChange::class.java)
|
||||
.observeOn(Schedulers.io())
|
||||
.subscribe(::onPreferenceChange)
|
||||
)
|
||||
setupHttpServer()
|
||||
}
|
||||
|
||||
private fun setupHttpServer() {
|
||||
if (sp.getBoolean("communication_http", false)) {
|
||||
val port = sp.getInt("communication_http_port", 28891)
|
||||
if (server != null && server?.port == port) return
|
||||
aapsLogger.info(LTag.GARMIN, "starting HTTP server on $port")
|
||||
server?.close()
|
||||
server = HttpServer(aapsLogger, port).apply {
|
||||
registerEndpoint("/get", ::onGetBloodGlucose)
|
||||
}
|
||||
} else if (server != null) {
|
||||
aapsLogger.info(LTag.GARMIN, "stopping HTTP server")
|
||||
server?.close()
|
||||
server = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
disposable.clear()
|
||||
aapsLogger.info(LTag.GARMIN, "Stop")
|
||||
server?.close()
|
||||
server = null
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
/** Receive new blood glucose events.
|
||||
*
|
||||
* Stores new blood glucose values in lastGlucoseValue to make sure we return
|
||||
* these values immediately when values are requested by Garmin device.
|
||||
* Sends a message to the Garmin devices via the ciqMessenger. */
|
||||
@VisibleForTesting
|
||||
fun onNewBloodGlucose(event: EventNewBG) {
|
||||
val timestamp = event.glucoseValueTimestamp ?: return
|
||||
aapsLogger.info(LTag.GARMIN, "onNewBloodGlucose ${Date(timestamp)}")
|
||||
valueLock.withLock {
|
||||
if ((lastGlucoseValueTimestamp?: 0) >= timestamp) return
|
||||
lastGlucoseValueTimestamp = timestamp
|
||||
newValue.signalAll()
|
||||
}
|
||||
}
|
||||
|
||||
/** Gets the last 2+ hours of glucose values. */
|
||||
@VisibleForTesting
|
||||
fun getGlucoseValues(): List<GlucoseValue> {
|
||||
val from = clock.instant().minus(Duration.ofHours(2).plusMinutes(9))
|
||||
return loopHub.getGlucoseValues(from, true)
|
||||
}
|
||||
|
||||
/** Get the last 2+ hours of glucose values and waits in case a new value should arrive soon. */
|
||||
private fun getGlucoseValues(maxWait: Duration): List<GlucoseValue> {
|
||||
val glucoseFrequency = Duration.ofMinutes(5)
|
||||
val glucoseValues = getGlucoseValues()
|
||||
val last = glucoseValues.lastOrNull() ?: return emptyList()
|
||||
val delay = Duration.ofMillis(clock.millis() - last.timestamp)
|
||||
return if (!maxWait.isZero
|
||||
&& delay > glucoseFrequency
|
||||
&& delay < glucoseFrequency.plusMinutes(1)) {
|
||||
valueLock.withLock {
|
||||
aapsLogger.debug(LTag.GARMIN, "waiting for new glucose (delay=$delay)")
|
||||
newValue.awaitNanos(maxWait.toNanos())
|
||||
}
|
||||
getGlucoseValues()
|
||||
} else {
|
||||
glucoseValues
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodedGlucose(glucoseValues: List<GlucoseValue>): String {
|
||||
val encodedGlucose = DeltaVarEncodedList(glucoseValues.size * 16, 2)
|
||||
for (glucose: GlucoseValue in glucoseValues) {
|
||||
val timeSec: Int = (glucose.timestamp / 1000).toInt()
|
||||
val glucoseMgDl: Int = glucose.value.roundToInt()
|
||||
encodedGlucose.add(timeSec, glucoseMgDl)
|
||||
}
|
||||
aapsLogger.info(
|
||||
LTag.GARMIN,
|
||||
"retrieved ${glucoseValues.size} last ${Date(glucoseValues.lastOrNull()?.timestamp ?: 0L)} ${encodedGlucose.size}"
|
||||
)
|
||||
return encodedGlucose.encodedBase64()
|
||||
}
|
||||
|
||||
/** Responses to get glucose value request by the device.
|
||||
*
|
||||
* Also, gets the heart rate readings from the device.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun onGetBloodGlucose(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
|
||||
aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri")
|
||||
receiveHeartRate(uri)
|
||||
val profileName = loopHub.currentProfileName
|
||||
val waitSec = getQueryParameter(uri, "wait", 0L)
|
||||
val glucoseValues = getGlucoseValues(Duration.ofSeconds(waitSec))
|
||||
val jo = JsonObject()
|
||||
jo.addProperty("encodedGlucose", encodedGlucose(glucoseValues))
|
||||
jo.addProperty("remainingInsulin", loopHub.insulinOnboard)
|
||||
jo.addProperty("glucoseUnit", glucoseUnitStr)
|
||||
loopHub.temporaryBasal.also {
|
||||
if (!it.isNaN()) jo.addProperty("temporaryBasalRate", it)
|
||||
}
|
||||
jo.addProperty("profile", profileName.first().toString())
|
||||
jo.addProperty("connected", loopHub.isConnected)
|
||||
return jo.toString().also {
|
||||
aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri, result: $it")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getQueryParameter(uri: URI, name: String) = (uri.query ?: "")
|
||||
.split("&")
|
||||
.map { kv -> kv.split("=") }
|
||||
.firstOrNull { kv -> kv.size == 2 && kv[0] == name }?.get(1)
|
||||
|
||||
private fun getQueryParameter(
|
||||
uri: URI,
|
||||
@Suppress("SameParameterValue") name: String,
|
||||
@Suppress("SameParameterValue") defaultValue: Boolean): Boolean {
|
||||
return when (getQueryParameter(uri, name)?.lowercase()) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
private fun getQueryParameter(
|
||||
uri: URI, name: String,
|
||||
@Suppress("SameParameterValue") defaultValue: Long
|
||||
): Long {
|
||||
val value = getQueryParameter(uri, name)
|
||||
return try {
|
||||
if (value.isNullOrEmpty()) defaultValue else value.toLong()
|
||||
} catch (e: NumberFormatException) {
|
||||
aapsLogger.error(LTag.GARMIN, "invalid $name value '$value'")
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun receiveHeartRate(uri: URI) {
|
||||
val avg: Int = getQueryParameter(uri, "hr", 0L).toInt()
|
||||
val samplingStartSec: Long = getQueryParameter(uri, "hrStart", 0L)
|
||||
val samplingEndSec: Long = getQueryParameter(uri, "hrEnd", 0L)
|
||||
val device: String? = getQueryParameter(uri, "device")
|
||||
receiveHeartRate(
|
||||
Instant.ofEpochSecond(samplingStartSec), Instant.ofEpochSecond(samplingEndSec),
|
||||
avg, device, getQueryParameter(uri, "test", false))
|
||||
}
|
||||
|
||||
private fun receiveHeartRate(
|
||||
samplingStart: Instant, samplingEnd: Instant,
|
||||
avg: Int, device: String?, test: Boolean) {
|
||||
aapsLogger.info(LTag.GARMIN, "average heart rate $avg BPM test=$test")
|
||||
if (test) return
|
||||
if (avg > 10 && samplingStart > Instant.ofEpochMilli(0L) && samplingEnd > samplingStart) {
|
||||
loopHub.storeHeartRate(samplingStart, samplingEnd, avg, device)
|
||||
} else {
|
||||
aapsLogger.warn(LTag.GARMIN, "Skip saving invalid HR $avg $samplingStart..$samplingEnd")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,257 @@
|
|||
package app.aaps.plugins.main.general.garmin
|
||||
|
||||
import android.os.StrictMode
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import app.aaps.core.interfaces.logging.AAPSLogger
|
||||
import app.aaps.core.interfaces.logging.LTag
|
||||
import java.io.*
|
||||
import java.lang.Thread.UncaughtExceptionHandler
|
||||
import java.net.*
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
/** Basic HTTP server to communicate with Garmin device via localhost. */
|
||||
class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val port: Int): Closeable {
|
||||
private val serverThread: Thread
|
||||
private val workerExecutor: Executor = Executors.newCachedThreadPool()
|
||||
private val endpoints: MutableMap<String, (SocketAddress, URI, String?)->CharSequence> =
|
||||
ConcurrentHashMap()
|
||||
private var serverSocket: ServerSocket? = null
|
||||
private val readyLock = ReentrantLock()
|
||||
private val readyCond = readyLock.newCondition()
|
||||
|
||||
init {
|
||||
serverThread = Thread { runServer() }
|
||||
serverThread.name = "GarminHttpServer"
|
||||
serverThread.isDaemon = true
|
||||
serverThread.uncaughtExceptionHandler = UncaughtExceptionHandler { _, e ->
|
||||
e.printStackTrace()
|
||||
aapsLogger.error(LTag.GARMIN, "uncaught in HTTP server", e)
|
||||
serverSocket?.use {}
|
||||
}
|
||||
serverThread.start()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
try {
|
||||
serverSocket?.close()
|
||||
serverSocket = null
|
||||
} catch (_: IOException) {
|
||||
}
|
||||
try {
|
||||
serverThread.join(10_000L)
|
||||
} catch (_: InterruptedException) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for the server to start listing to requests. */
|
||||
fun awaitReady(wait: Duration): Boolean {
|
||||
var waitNanos = wait.toNanos()
|
||||
readyLock.withLock {
|
||||
while (serverSocket?.isBound != true && waitNanos > 0L) {
|
||||
waitNanos = readyCond.awaitNanos(waitNanos)
|
||||
}
|
||||
}
|
||||
return serverSocket?.isBound ?: false
|
||||
}
|
||||
|
||||
/** Register an endpoint (path) to handle requests. */
|
||||
fun registerEndpoint(path: String, endpoint: (SocketAddress, URI, String?)->CharSequence) {
|
||||
aapsLogger.info(LTag.GARMIN,"Register: '$path'")
|
||||
endpoints[path] = endpoint
|
||||
}
|
||||
|
||||
|
||||
// @Suppress("all")
|
||||
private fun respond(
|
||||
@Suppress("SameParameterValue") code: Int,
|
||||
body: CharSequence,
|
||||
@Suppress("SameParameterValue") contentType: String,
|
||||
out: OutputStream) {
|
||||
respond(code, body.toString().toByteArray(Charset.forName("UTF8")), contentType, out)
|
||||
}
|
||||
|
||||
private fun respond(code: Int, out: OutputStream) {
|
||||
respond(code, null as ByteArray?, null, out)
|
||||
}
|
||||
|
||||
private fun respond(code: Int, body: ByteArray?, contentType: String?, out: OutputStream) {
|
||||
val header = StringBuilder()
|
||||
header.append("HTTP/1.1 ").append(code).append(" OK\r\n")
|
||||
if (body != null) {
|
||||
appendHeader("Content-Length", "" + body.size, header)
|
||||
}
|
||||
if (contentType != null) {
|
||||
appendHeader("Content-Type", contentType, header)
|
||||
}
|
||||
header.append("\r\n")
|
||||
val bout = BufferedOutputStream(out)
|
||||
bout.write(header.toString().toByteArray(StandardCharsets.US_ASCII))
|
||||
if (body != null) {
|
||||
bout.write(body)
|
||||
}
|
||||
bout.flush()
|
||||
}
|
||||
|
||||
private fun handleRequest(s: Socket) {
|
||||
val out = s.getOutputStream()
|
||||
try {
|
||||
val (uri, reqBody) = parseRequest(s.getInputStream())
|
||||
if ("favicon.ico" == uri.path) {
|
||||
respond(HttpURLConnection.HTTP_NOT_FOUND, out)
|
||||
return
|
||||
}
|
||||
val endpoint = endpoints[uri.path ?: ""]
|
||||
if (endpoint == null) {
|
||||
aapsLogger.error(LTag.GARMIN, "request path not found '" + uri.path + "'")
|
||||
respond(HttpURLConnection.HTTP_NOT_FOUND, out)
|
||||
} else {
|
||||
try {
|
||||
val body = endpoint(s.remoteSocketAddress, uri, reqBody)
|
||||
respond(HttpURLConnection.HTTP_OK, body, "application/json", out)
|
||||
} catch (e: Exception) {
|
||||
aapsLogger.error(LTag.GARMIN, "endpoint " + uri.path + " failed", e)
|
||||
respond(HttpURLConnection.HTTP_INTERNAL_ERROR, out)
|
||||
}
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
// Client may just connect without sending anything.
|
||||
aapsLogger.debug(LTag.GARMIN, "socket timeout: " + e.message)
|
||||
return
|
||||
} catch (e: IOException) {
|
||||
aapsLogger.error(LTag.GARMIN, "Invalid request", e)
|
||||
respond(HttpURLConnection.HTTP_BAD_REQUEST, out)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private fun runServer() = try {
|
||||
// Policy won't work in unit tests, so ignore NULL builder.
|
||||
@Suppress("UNNECESSARY_SAFE_CALL")
|
||||
val policy = StrictMode.ThreadPolicy.Builder()?.permitAll()?.build()
|
||||
if (policy != null) StrictMode.setThreadPolicy(policy)
|
||||
readyLock.withLock {
|
||||
serverSocket = ServerSocket()
|
||||
serverSocket!!.bind(
|
||||
// Garmin will only connect to IP4 localhost. Therefore, we need to explicitly listen
|
||||
// on that loopback interface and cannot use InetAddress.getLoopbackAddress(). That
|
||||
// gives ::1 (IP6 localhost).
|
||||
InetSocketAddress(Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1)), port))
|
||||
readyCond.signalAll() }
|
||||
aapsLogger.info(LTag.GARMIN,"accept connections on " + serverSocket!!.localSocketAddress)
|
||||
while (true) {
|
||||
val socket = serverSocket!!.accept()
|
||||
aapsLogger.info(LTag.GARMIN,"accept " + socket.remoteSocketAddress)
|
||||
workerExecutor.execute {
|
||||
Thread.currentThread().name = "worker" + Thread.currentThread().id
|
||||
try {
|
||||
socket.use { s ->
|
||||
s.soTimeout = 10_000
|
||||
handleRequest(s)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
aapsLogger.error(LTag.GARMIN, "response failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
aapsLogger.error("Server crashed", e)
|
||||
} finally {
|
||||
try {
|
||||
serverSocket?.close()
|
||||
serverSocket = null
|
||||
} catch (e: IOException) {
|
||||
aapsLogger.error(LTag.GARMIN, "Socked close failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val REQUEST_HEADER = Pattern.compile("(GET|POST) (\\S*) HTTP/1.1")
|
||||
private val HEADER_LINE = Pattern.compile("([A-Za-z-]+)\\s*:\\s*(.*)")
|
||||
|
||||
private fun readLine(input: InputStream, charset: Charset): String {
|
||||
val buffer = ByteArrayOutputStream(input.available())
|
||||
loop@while (true) {
|
||||
when (val c = input.read()) {
|
||||
'\r'.code -> {}
|
||||
-1 -> break@loop
|
||||
'\n'.code -> break@loop
|
||||
else -> buffer.write(c)
|
||||
}
|
||||
}
|
||||
return String(buffer.toByteArray(), charset)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun readBody(input: InputStream, length: Int): String {
|
||||
var remaining = length
|
||||
val buffer = ByteArrayOutputStream(input.available())
|
||||
var c: Int = -1
|
||||
while (remaining-- > 0 && (input.read().also { c = it }) != -1) {
|
||||
buffer.write(c)
|
||||
}
|
||||
return buffer.toString("UTF8")
|
||||
}
|
||||
|
||||
/** Parses a requests and returns the URI and the request body. */
|
||||
@VisibleForTesting
|
||||
internal fun parseRequest(input: InputStream): Pair<URI, String?> {
|
||||
val headerLine = readLine(input, Charset.forName("ASCII"))
|
||||
val p = REQUEST_HEADER.matcher(headerLine)
|
||||
if (!p.matches()) {
|
||||
throw IOException("invalid HTTP header '$headerLine'")
|
||||
}
|
||||
val post = ("POST" == p.group(1))
|
||||
var uri = URI(p.group(2))
|
||||
val headers: MutableMap<String, String?> = HashMap()
|
||||
while (true) {
|
||||
val line = readLine(input, Charset.forName("ASCII"))
|
||||
if (line.isEmpty()) {
|
||||
break
|
||||
}
|
||||
val m = HEADER_LINE.matcher(line)
|
||||
if (!m.matches()) {
|
||||
throw IOException("invalid header line '$line'")
|
||||
}
|
||||
headers[m.group(1)!!] = m.group(2)
|
||||
}
|
||||
var body: String?
|
||||
if (post) {
|
||||
var contentLength = Int.MAX_VALUE
|
||||
if (headers.containsKey("Content-Length")) {
|
||||
contentLength = headers["Content-Length"]!!.toInt()
|
||||
}
|
||||
val keepAlive = ("Keep-Alive" == headers["Connection"])
|
||||
val contentType = headers["Content-Type"]
|
||||
if (keepAlive && contentLength == Int.MAX_VALUE) {
|
||||
throw IOException("keep-alive without content-length for $uri")
|
||||
}
|
||||
body = readBody(input, contentLength)
|
||||
if (("application/x-www-form-urlencoded" == contentType)) {
|
||||
uri = URI(uri.scheme, uri.userInfo, uri.host, uri.port, uri.path, body, null)
|
||||
// uri.encodedQuery(body)
|
||||
body = null
|
||||
} else if ("application/json" != contentType && body.isNotBlank()) {
|
||||
body = null
|
||||
}
|
||||
} else {
|
||||
body = null
|
||||
}
|
||||
return Pair(uri, body?.takeUnless(String::isBlank))
|
||||
}
|
||||
|
||||
private fun appendHeader(name: String, value: String, header: StringBuilder) {
|
||||
header.append(name)
|
||||
header.append(": ")
|
||||
header.append(value)
|
||||
header.append("\r\n")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package app.aaps.plugins.main.general.garmin
|
||||
|
||||
import app.aaps.core.interfaces.db.GlucoseUnit
|
||||
import app.aaps.core.interfaces.profile.Profile
|
||||
import app.aaps.database.entities.GlucoseValue
|
||||
import java.time.Instant
|
||||
|
||||
/** Abstraction from all the functionality we need from the AAPS app. */
|
||||
interface LoopHub {
|
||||
|
||||
/** Returns the active insulin profile. */
|
||||
val currentProfile: Profile?
|
||||
|
||||
/** Returns the name of the active insulin profile. */
|
||||
val currentProfileName: String
|
||||
|
||||
/** Returns the glucose unit (mg/dl or mmol/l) as selected by the user. */
|
||||
val glucoseUnit: GlucoseUnit
|
||||
|
||||
/** Returns the remaining bolus insulin on board. */
|
||||
val insulinOnboard: Double
|
||||
|
||||
/** Returns true if the pump is connected. */
|
||||
val isConnected: Boolean
|
||||
|
||||
/** Returns true if the current profile is set of a limited amount of time. */
|
||||
val isTemporaryProfile: Boolean
|
||||
|
||||
/** Returns the factor by which the basal rate is currently raised (> 1) or lowered (< 1). */
|
||||
val temporaryBasal: Double
|
||||
|
||||
/** Retrieves the glucose values starting at from. */
|
||||
fun getGlucoseValues(from: Instant, ascending: Boolean): List<GlucoseValue>
|
||||
|
||||
/** Stores hear rate readings that a taken and averaged of the given interval. */
|
||||
fun storeHeartRate(
|
||||
samplingStart: Instant, samplingEnd: Instant,
|
||||
avgHeartRate: Int,
|
||||
device: String?
|
||||
)
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
package app.aaps.plugins.main.general.garmin
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import app.aaps.core.interfaces.aps.Loop
|
||||
import app.aaps.core.interfaces.db.GlucoseUnit
|
||||
import app.aaps.core.interfaces.iob.IobCobCalculator
|
||||
import app.aaps.core.interfaces.profile.Profile
|
||||
import app.aaps.core.interfaces.profile.ProfileFunction
|
||||
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.impl.AppRepository
|
||||
import app.aaps.database.impl.transactions.InsertOrUpdateHeartRateTransaction
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
/**
|
||||
* Interface to the functionality of the looping algorithm and storage systems.
|
||||
*/
|
||||
class LoopHubImpl @Inject constructor(
|
||||
private val iobCobCalculator: IobCobCalculator,
|
||||
private val loop: Loop,
|
||||
private val profileFunction: ProfileFunction,
|
||||
private val repo: AppRepository,
|
||||
) : LoopHub {
|
||||
|
||||
@VisibleForTesting
|
||||
var clock: Clock = Clock.systemUTC()
|
||||
|
||||
/** Returns the active insulin profile. */
|
||||
override val currentProfile: Profile? get() = profileFunction.getProfile()
|
||||
|
||||
/** Returns the name of the active insulin profile. */
|
||||
override val currentProfileName: String
|
||||
get() = profileFunction.getProfileName()
|
||||
|
||||
/** Returns the glucose unit (mg/dl or mmol/l) as selected by the user. */
|
||||
override val glucoseUnit: GlucoseUnit
|
||||
get() = profileFunction.getProfile()?.units ?: GlucoseUnit.MGDL
|
||||
|
||||
/** Returns the remaining bolus insulin on board. */
|
||||
override val insulinOnboard: Double
|
||||
get() = iobCobCalculator.calculateIobFromBolus().iob
|
||||
|
||||
/** Returns true if the pump is connected. */
|
||||
override val isConnected: Boolean get() = !loop.isDisconnected
|
||||
|
||||
/** Returns true if the current profile is set of a limited amount of time. */
|
||||
override val isTemporaryProfile: Boolean
|
||||
get() {
|
||||
val resp = repo.getEffectiveProfileSwitchActiveAt(clock.millis())
|
||||
val ps: EffectiveProfileSwitch? =
|
||||
(resp.blockingGet() as? ValueWrapper.Existing<EffectiveProfileSwitch>)?.value
|
||||
return ps != null && ps.originalDuration > 0
|
||||
}
|
||||
|
||||
/** Returns the factor by which the basal rate is currently raised (> 1) or lowered (< 1). */
|
||||
override val temporaryBasal: Double
|
||||
get() {
|
||||
val apsResult = loop.lastRun?.constraintsProcessed
|
||||
return if (apsResult == null) Double.NaN else apsResult.percent / 100.0
|
||||
}
|
||||
|
||||
/** Retrieves the glucose values starting at from. */
|
||||
override fun getGlucoseValues(from: Instant, ascending: Boolean): List<GlucoseValue> {
|
||||
return repo.compatGetBgReadingsDataFromTime(from.toEpochMilli(), ascending)
|
||||
.blockingGet()
|
||||
}
|
||||
|
||||
/** Stores hear rate readings that a taken and averaged of the given interval. */
|
||||
override fun storeHeartRate(
|
||||
samplingStart: Instant, samplingEnd: Instant,
|
||||
avgHeartRate: Int,
|
||||
device: String?) {
|
||||
val hr = HeartRate(
|
||||
timestamp = samplingStart.toEpochMilli(),
|
||||
duration = samplingEnd.toEpochMilli() - samplingStart.toEpochMilli(),
|
||||
dateCreated = clock.millis(),
|
||||
beatsPerMinute = avgHeartRate.toDouble(),
|
||||
device = device ?: "Garmin",
|
||||
)
|
||||
repo.runTransaction(InsertOrUpdateHeartRateTransaction(hr)).blockingAwait()
|
||||
}
|
||||
}
|
|
@ -401,5 +401,8 @@
|
|||
<string name="default_range">DEFAULT RANGE</string>
|
||||
<string name="target">target</string>
|
||||
<string name="rate_duration">Rate: %1$.2fU/h (%2$.2f%%) \nDuration %3$d min</string>
|
||||
<string name="garmin">Garmin</string>
|
||||
<string name="garmin_description">Connection to Garmin device (Fenix, Edge, …)</string>
|
||||
<string name="key_garmin_settings">Garmin settings</string>
|
||||
|
||||
</resources>
|
||||
|
|
23
plugins/main/src/main/res/xml/pref_garmin.xml
Normal file
23
plugins/main/src/main/res/xml/pref_garmin.xml
Normal file
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<PreferenceCategory
|
||||
android:key="@string/key_garmin_settings"
|
||||
app:initialExpandedChildrenCount="0"
|
||||
android:title="@string/garmin">
|
||||
|
||||
<CheckBoxPreference
|
||||
android:defaultValue="true"
|
||||
android:key="communication_http"
|
||||
android:title="Local HTTP server" />
|
||||
|
||||
<EditTextPreference
|
||||
android:defaultValue="28891"
|
||||
android:digits="0123456789"
|
||||
android:inputType="numberDecimal"
|
||||
android:key="communication_http_port"
|
||||
android:title="Local HTTP server port" />
|
||||
</PreferenceCategory>
|
||||
</PreferenceScreen>
|
|
@ -0,0 +1,192 @@
|
|||
package app.aaps.plugins.main.general.garmin
|
||||
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
internal class DeltaVarEncodedListTest {
|
||||
|
||||
@Test fun empty() {
|
||||
val l = DeltaVarEncodedList(100, 2)
|
||||
assertArrayEquals(IntArray(0), l.toArray())
|
||||
}
|
||||
|
||||
@Test fun add1() {
|
||||
val l = DeltaVarEncodedList(100, 2)
|
||||
l.add(10, 12)
|
||||
assertArrayEquals(intArrayOf(10, 12), l.toArray())
|
||||
}
|
||||
|
||||
@Test fun add2() {
|
||||
val l = DeltaVarEncodedList(100, 2)
|
||||
l.add(10, 16)
|
||||
l.add(17, 9)
|
||||
assertArrayEquals(intArrayOf(10, 16, 17, 9), l.toArray())
|
||||
}
|
||||
|
||||
@Test fun add3() {
|
||||
val l = DeltaVarEncodedList(100, 2)
|
||||
l.add(10, 16)
|
||||
l.add(17, 9)
|
||||
l.add(-4, 5)
|
||||
assertArrayEquals(intArrayOf(10, 16, 17, 9, -4, 5), l.toArray())
|
||||
}
|
||||
|
||||
@Test fun decode() {
|
||||
val bytes = ByteBuffer.allocate(6)
|
||||
bytes.order(ByteOrder.LITTLE_ENDIAN)
|
||||
bytes.putChar(65044.toChar())
|
||||
bytes.putChar(33026.toChar())
|
||||
bytes.putChar(4355.toChar())
|
||||
val l = DeltaVarEncodedList(intArrayOf(-1), bytes)
|
||||
assertEquals(4, l.size.toLong())
|
||||
assertArrayEquals(intArrayOf(10, 201, 8, -1), l.toArray())
|
||||
}
|
||||
|
||||
@Test fun decodeUneven() {
|
||||
val bytes = ByteBuffer.allocate(8)
|
||||
bytes.order(ByteOrder.LITTLE_ENDIAN)
|
||||
bytes.putChar(65044.toChar())
|
||||
bytes.putChar(33026.toChar())
|
||||
bytes.putChar(59395.toChar())
|
||||
bytes.putChar(10.toChar())
|
||||
val l = DeltaVarEncodedList(intArrayOf(700), ByteBuffer.wrap(bytes.array(), 0, 7))
|
||||
assertEquals(4, l.size.toLong())
|
||||
assertArrayEquals(intArrayOf(10, 201, 8, 700), l.toArray())
|
||||
}
|
||||
|
||||
@Test fun decodeInt() {
|
||||
val bytes = ByteBuffer.allocate(8)
|
||||
bytes.order(ByteOrder.LITTLE_ENDIAN)
|
||||
bytes.putInt(-2130510316).putInt(714755)
|
||||
val l = DeltaVarEncodedList(intArrayOf(700), ByteBuffer.wrap(bytes.array(), 0, 7))
|
||||
assertEquals(4, l.size.toLong())
|
||||
assertArrayEquals(intArrayOf(10, 201, 8, 700), l.toArray())
|
||||
}
|
||||
|
||||
@Test fun decodeInt1() {
|
||||
val bytes = ByteBuffer.allocate(3 * 4)
|
||||
bytes.order(ByteOrder.LITTLE_ENDIAN)
|
||||
bytes.putInt(-2019904035).putInt(335708683).putInt(529409)
|
||||
val l = DeltaVarEncodedList(intArrayOf(1483884930, 132), ByteBuffer.wrap(bytes.array(), 0, 11))
|
||||
assertEquals(3, l.size.toLong())
|
||||
assertArrayEquals(intArrayOf(1483884910, 129, 1483884920, 128, 1483884930, 132), l.toArray())
|
||||
}
|
||||
|
||||
@Test fun decodeInt2() {
|
||||
val bytes = ByteBuffer.allocate(100)
|
||||
bytes.order(ByteOrder.LITTLE_ENDIAN)
|
||||
bytes
|
||||
.putInt(-1761405951)
|
||||
.putInt(335977999)
|
||||
.putInt(335746050)
|
||||
.putInt(336008197)
|
||||
.putInt(335680514)
|
||||
.putInt(335746053)
|
||||
.putInt(-1761405949)
|
||||
val l = DeltaVarEncodedList(intArrayOf(1483880370, 127), ByteBuffer.wrap(bytes.array(), 0, 28))
|
||||
assertEquals(12, l.size.toLong())
|
||||
assertArrayEquals(
|
||||
intArrayOf(
|
||||
1483879986,
|
||||
999,
|
||||
1483879984,
|
||||
27,
|
||||
1483880383,
|
||||
37,
|
||||
1483880384,
|
||||
47,
|
||||
1483880382,
|
||||
57,
|
||||
1483880379,
|
||||
67,
|
||||
1483880375,
|
||||
77,
|
||||
1483880376,
|
||||
87,
|
||||
1483880377,
|
||||
97,
|
||||
1483880374,
|
||||
107,
|
||||
1483880372,
|
||||
117,
|
||||
1483880370,
|
||||
127
|
||||
),
|
||||
l.toArray()
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun decodeInt3() {
|
||||
val bytes = ByteBuffer.allocate(2 * 4)
|
||||
bytes.order(ByteOrder.LITTLE_ENDIAN)
|
||||
bytes.putInt(-2020427796).putInt(166411)
|
||||
val l = DeltaVarEncodedList(intArrayOf(1483886070, 133), ByteBuffer.wrap(bytes.array(), 0, 7))
|
||||
assertEquals(1, l.size.toLong())
|
||||
assertArrayEquals(intArrayOf(1483886070, 133), l.toArray())
|
||||
}
|
||||
|
||||
@Test fun decodePairs() {
|
||||
val bytes = ByteBuffer.allocate(10)
|
||||
bytes.order(ByteOrder.LITTLE_ENDIAN)
|
||||
bytes.putChar(51220.toChar())
|
||||
bytes.putChar(65025.toChar())
|
||||
bytes.putChar(514.toChar())
|
||||
bytes.putChar(897.toChar())
|
||||
bytes.putChar(437.toChar())
|
||||
val l = DeltaVarEncodedList(intArrayOf(8, 10), bytes)
|
||||
assertEquals(3, l.size.toLong())
|
||||
assertArrayEquals(intArrayOf(10, 100, 201, 101, 8, 10), l.toArray())
|
||||
}
|
||||
|
||||
@Test fun encoding() {
|
||||
val l = DeltaVarEncodedList(100, 2)
|
||||
l.add(10, 16)
|
||||
l.add(17, 9)
|
||||
l.add(-4, 5)
|
||||
val dataList = l.encodedData()
|
||||
val byteBuffer = ByteBuffer.allocate(dataList.size * 8)
|
||||
byteBuffer.order(ByteOrder.LITTLE_ENDIAN)
|
||||
val longBuffer = byteBuffer.asLongBuffer()
|
||||
for (i in dataList.indices) {
|
||||
longBuffer.put(dataList[i])
|
||||
}
|
||||
byteBuffer.rewind()
|
||||
byteBuffer.limit(l.byteSize)
|
||||
val l2 = DeltaVarEncodedList(intArrayOf(-4, 5), byteBuffer)
|
||||
assertArrayEquals(intArrayOf(10, 16, 17, 9, -4, 5), l2.toArray())
|
||||
}
|
||||
|
||||
@Test fun encoding2() {
|
||||
val l = DeltaVarEncodedList(100, 2)
|
||||
val values = intArrayOf(
|
||||
1511636926, 137, 1511637226, 138, 1511637526, 138, 1511637826, 137, 1511638126, 136,
|
||||
1511638426, 135, 1511638726, 134, 1511639026, 132, 1511639326, 130, 1511639626, 128,
|
||||
1511639926, 126, 1511640226, 124, 1511640526, 121, 1511640826, 118, 1511641127, 117,
|
||||
1511641427, 116, 1511641726, 115, 1511642027, 113, 1511642326, 111, 1511642627, 109,
|
||||
1511642927, 107, 1511643227, 107, 1511643527, 107, 1511643827, 106, 1511644127, 105,
|
||||
1511644427, 104, 1511644727, 104, 1511645027, 104, 1511645327, 104, 1511645626, 104,
|
||||
1511645926, 104, 1511646226, 105, 1511646526, 106, 1511646826, 107, 1511647126, 109,
|
||||
1511647426, 108
|
||||
)
|
||||
|
||||
for(i in values.indices step 2) {
|
||||
l.add(values[i], values[i + 1])
|
||||
}
|
||||
assertArrayEquals(values, l.toArray())
|
||||
val dataList = l.encodedData()
|
||||
val byteBuffer = ByteBuffer.allocate(dataList.size * 8)
|
||||
byteBuffer.order(ByteOrder.LITTLE_ENDIAN)
|
||||
val longBuffer = byteBuffer.asLongBuffer()
|
||||
for (i in dataList.indices) {
|
||||
longBuffer.put(dataList[i])
|
||||
}
|
||||
byteBuffer.rewind()
|
||||
byteBuffer.limit(l.byteSize)
|
||||
val l2 = DeltaVarEncodedList(intArrayOf(1511647426, 108), byteBuffer)
|
||||
assertArrayEquals(values, l2.toArray())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
package app.aaps.plugins.main.general.garmin
|
||||
|
||||
import app.aaps.core.interfaces.resources.ResourceHelper
|
||||
import app.aaps.core.interfaces.rx.events.EventNewBG
|
||||
import app.aaps.core.interfaces.sharedPreferences.SP
|
||||
import app.aaps.database.entities.GlucoseValue
|
||||
import app.aaps.shared.tests.TestBase
|
||||
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.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mock
|
||||
import org.mockito.Mockito.atMost
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.verifyNoMoreInteractions
|
||||
import org.mockito.Mockito.`when`
|
||||
import java.net.URI
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.concurrent.locks.Condition
|
||||
|
||||
class GarminPluginTest: TestBase() {
|
||||
private lateinit var gp: GarminPlugin
|
||||
|
||||
@Mock private lateinit var rh: ResourceHelper
|
||||
@Mock private lateinit var sp: SP
|
||||
@Mock private lateinit var loopHub: LoopHub
|
||||
private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC"))
|
||||
|
||||
private var injector: HasAndroidInjector = HasAndroidInjector {
|
||||
AndroidInjector {
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
gp = GarminPlugin(injector, aapsLogger, rh, loopHub, rxBus, sp)
|
||||
gp.clock = clock
|
||||
`when`(loopHub.currentProfileName).thenReturn("Default")
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun verifyNoFurtherInteractions() {
|
||||
verify(loopHub, atMost(2)).currentProfileName
|
||||
verifyNoMoreInteractions(loopHub)
|
||||
}
|
||||
|
||||
private val getGlucoseValuesFrom = clock.instant()
|
||||
.minus(2, ChronoUnit.HOURS)
|
||||
.minus(9, ChronoUnit.MINUTES)
|
||||
|
||||
private fun createUri(params: Map<String, Any>): URI {
|
||||
return URI("http://foo?" + params.entries.joinToString(separator = "&") { (k, v) ->
|
||||
"$k=$v"})
|
||||
}
|
||||
|
||||
private fun createHeartRate(@Suppress("SameParameterValue") heartRate: Int) = mapOf<String, Any>(
|
||||
"hr" to heartRate,
|
||||
"hrStart" to 1001L,
|
||||
"hrEnd" to 2001L,
|
||||
"device" to "Test_Device")
|
||||
|
||||
private fun createGlucoseValue(timestamp: Instant, value: Double = 93.0) = GlucoseValue(
|
||||
timestamp = timestamp.toEpochMilli(), raw = 90.0, value = value,
|
||||
trendArrow = GlucoseValue.TrendArrow.FLAT, noise = null,
|
||||
sourceSensor = GlucoseValue.SourceSensor.RANDOM
|
||||
)
|
||||
|
||||
@Test
|
||||
fun testReceiveHeartRateUri() {
|
||||
val hr = createHeartRate(99)
|
||||
val uri = createUri(hr)
|
||||
gp.receiveHeartRate(uri)
|
||||
verify(loopHub).storeHeartRate(
|
||||
Instant.ofEpochSecond(hr["hrStart"] as Long),
|
||||
Instant.ofEpochSecond(hr["hrEnd"] as Long),
|
||||
99,
|
||||
hr["device"] as String)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReceiveHeartRate_UriTestIsTrue() {
|
||||
val params = createHeartRate(99).toMutableMap()
|
||||
params["test"] = true
|
||||
val uri = createUri(params)
|
||||
gp.receiveHeartRate(uri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetGlucoseValues_NoLast() {
|
||||
val from = getGlucoseValuesFrom
|
||||
val prev = createGlucoseValue(clock.instant().minusSeconds(310))
|
||||
`when`(loopHub.getGlucoseValues(from, true)).thenReturn(listOf(prev))
|
||||
assertArrayEquals(arrayOf(prev), gp.getGlucoseValues().toTypedArray())
|
||||
verify(loopHub).getGlucoseValues(from, true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetGlucoseValues_NoNewLast() {
|
||||
val from = getGlucoseValuesFrom
|
||||
val lastTimesteamp = clock.instant()
|
||||
val prev = createGlucoseValue(clock.instant())
|
||||
gp.newValue = mock(Condition::class.java)
|
||||
`when`(loopHub.getGlucoseValues(from, true)).thenReturn(listOf(prev))
|
||||
gp.onNewBloodGlucose(EventNewBG(lastTimesteamp.toEpochMilli()))
|
||||
assertArrayEquals(arrayOf(prev), gp.getGlucoseValues().toTypedArray())
|
||||
|
||||
verify(gp.newValue).signalAll()
|
||||
verify(loopHub).getGlucoseValues(from, true)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
package app.aaps.plugins.main.general.garmin
|
||||
|
||||
import app.aaps.shared.tests.TestBase
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketAddress
|
||||
import java.net.URI
|
||||
import java.nio.charset.Charset
|
||||
import java.time.Duration
|
||||
|
||||
internal class HttpServerTest: TestBase() {
|
||||
|
||||
private fun toInputStream(s: String): InputStream {
|
||||
return ByteArrayInputStream(s.toByteArray(Charset.forName("ASCII")))
|
||||
}
|
||||
|
||||
@Test fun testReadBody() {
|
||||
val input = toInputStream("Test")
|
||||
assertEquals("Test", HttpServer.readBody(input, 100))
|
||||
}
|
||||
|
||||
@Test fun testReadBody_MoreContentThanLength() {
|
||||
val input = toInputStream("Test")
|
||||
assertEquals("Tes", HttpServer.readBody(input, 3))
|
||||
}
|
||||
|
||||
@Test fun testParseRequest_Get() {
|
||||
val req = """
|
||||
GET http://foo HTTP/1.1
|
||||
""".trimIndent()
|
||||
assertEquals(
|
||||
URI("http://foo") to null,
|
||||
HttpServer.parseRequest(toInputStream(req)))
|
||||
}
|
||||
|
||||
@Test fun testParseRequest_PostEmptyBody() {
|
||||
val req = """
|
||||
POST http://foo HTTP/1.1
|
||||
""".trimIndent()
|
||||
assertEquals(
|
||||
URI("http://foo") to null,
|
||||
HttpServer.parseRequest(toInputStream(req)))
|
||||
}
|
||||
|
||||
@Test fun testParseRequest_PostBody() {
|
||||
val req = """
|
||||
POST http://foo HTTP/1.1
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
a=1&b=2
|
||||
""".trimIndent()
|
||||
assertEquals(
|
||||
URI("http://foo?a=1&b=2") to null,
|
||||
HttpServer.parseRequest(toInputStream(req)))
|
||||
}
|
||||
|
||||
@Test fun testParseRequest_PostBodyContentLength() {
|
||||
val req = """
|
||||
POST http://foo HTTP/1.1
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Content-Length: 3
|
||||
|
||||
a=1&b=2
|
||||
""".trimIndent()
|
||||
assertEquals(
|
||||
URI("http://foo?a=1") to null,
|
||||
HttpServer.parseRequest(toInputStream(req)))
|
||||
}
|
||||
|
||||
@Test fun testRequest() {
|
||||
val port = 28895
|
||||
val reqUri = URI("http://127.0.0.1:$port/foo")
|
||||
HttpServer(aapsLogger, port).use { server ->
|
||||
server.registerEndpoint("/foo") { _: SocketAddress, uri: URI, _: String? ->
|
||||
assertEquals(URI("/foo"), uri)
|
||||
"test"
|
||||
}
|
||||
assertTrue(server.awaitReady(Duration.ofSeconds(10)))
|
||||
val resp = reqUri.toURL().openConnection() as HttpURLConnection
|
||||
assertEquals(200, resp.responseCode)
|
||||
val content = (resp.content as InputStream).reader().use { r -> r.readText() }
|
||||
assertEquals("test", content)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun testRequest_NotFound() {
|
||||
val port = 28895
|
||||
val reqUri = URI("http://127.0.0.1:$port/foo")
|
||||
HttpServer(aapsLogger, port).use { server ->
|
||||
assertTrue(server.awaitReady(Duration.ofSeconds(10)))
|
||||
val resp = reqUri.toURL().openConnection() as HttpURLConnection
|
||||
assertEquals(404, resp.responseCode)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,201 @@
|
|||
package app.aaps.plugins.main.general.garmin
|
||||
|
||||
|
||||
import app.aaps.core.interfaces.aps.APSResult
|
||||
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.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.queue.CommandQueue
|
||||
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.embedments.InsulinConfiguration
|
||||
import app.aaps.database.impl.AppRepository
|
||||
import app.aaps.database.impl.transactions.InsertOrUpdateHeartRateTransaction
|
||||
import app.aaps.shared.tests.TestBase
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
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.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mock
|
||||
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.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
|
||||
class LoopHubTest: TestBase() {
|
||||
@Mock lateinit var commandQueue: CommandQueue
|
||||
@Mock lateinit var constraints: ConstraintsChecker
|
||||
@Mock lateinit var iobCobCalculator: IobCobCalculator
|
||||
@Mock lateinit var loop: Loop
|
||||
@Mock lateinit var profileFunction: ProfileFunction
|
||||
@Mock lateinit var repo: AppRepository
|
||||
@Mock lateinit var userEntryLogger: UserEntryLogger
|
||||
|
||||
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.clock = clock
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun verifyNoFurtherInteractions() {
|
||||
verifyNoMoreInteractions(commandQueue)
|
||||
verifyNoMoreInteractions(constraints)
|
||||
verifyNoMoreInteractions(iobCobCalculator)
|
||||
verifyNoMoreInteractions(loop)
|
||||
verifyNoMoreInteractions(profileFunction)
|
||||
verifyNoMoreInteractions(repo)
|
||||
verifyNoMoreInteractions(userEntryLogger)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCurrentProfile() {
|
||||
val profile = mock(Profile::class.java)
|
||||
`when`(profileFunction.getProfile()).thenReturn(profile)
|
||||
assertEquals(profile, loopHub.currentProfile)
|
||||
verify(profileFunction, times(1)).getProfile()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCurrentProfileName() {
|
||||
`when`(profileFunction.getProfileName()).thenReturn("pro")
|
||||
assertEquals("pro", loopHub.currentProfileName)
|
||||
verify(profileFunction, times(1)).getProfileName()
|
||||
}
|
||||
|
||||
@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)
|
||||
assertEquals(GlucoseUnit.MGDL, loopHub.glucoseUnit)
|
||||
verify(profileFunction, times(1)).getProfile()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsulinOnBoard() {
|
||||
val iobTotal = IobTotal(time = 0).apply { iob = 23.9 }
|
||||
`when`(iobCobCalculator.calculateIobFromBolus()).thenReturn(iobTotal)
|
||||
assertEquals(23.9, loopHub.insulinOnboard, 1e-10)
|
||||
verify(iobCobCalculator, times(1)).calculateIobFromBolus()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIsConnected() {
|
||||
`when`(loop.isDisconnected).thenReturn(false)
|
||||
assertEquals(true, loopHub.isConnected)
|
||||
verify(loop, times(1)).isDisconnected
|
||||
}
|
||||
|
||||
private fun effectiveProfileSwitch(duration: Long) = EffectiveProfileSwitch(
|
||||
timestamp = 100,
|
||||
basalBlocks = emptyList(),
|
||||
isfBlocks = emptyList(),
|
||||
icBlocks = emptyList(),
|
||||
targetBlocks = emptyList(),
|
||||
glucoseUnit = EffectiveProfileSwitch.GlucoseUnit.MGDL,
|
||||
originalProfileName = "foo",
|
||||
originalCustomizedName = "bar",
|
||||
originalTimeshift = 0,
|
||||
originalPercentage = 100,
|
||||
originalDuration = duration,
|
||||
originalEnd = 100 + duration,
|
||||
insulinConfiguration = InsulinConfiguration(
|
||||
"label", 0, 0
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun testIsTemporaryProfileTrue() {
|
||||
val eps = effectiveProfileSwitch(10)
|
||||
`when`(repo.getEffectiveProfileSwitchActiveAt(clock.millis())).thenReturn(
|
||||
Single.just(ValueWrapper.Existing(eps)))
|
||||
assertEquals(true, loopHub.isTemporaryProfile)
|
||||
verify(repo, times(1)).getEffectiveProfileSwitchActiveAt(clock.millis())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIsTemporaryProfileFalse() {
|
||||
val eps = effectiveProfileSwitch(0)
|
||||
`when`(repo.getEffectiveProfileSwitchActiveAt(clock.millis())).thenReturn(
|
||||
Single.just(ValueWrapper.Existing(eps)))
|
||||
assertEquals(false, loopHub.isTemporaryProfile)
|
||||
verify(repo).getEffectiveProfileSwitchActiveAt(clock.millis())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTemporaryBasal() {
|
||||
val apsResult = mock(APSResult::class.java)
|
||||
`when`(apsResult.percent).thenReturn(45)
|
||||
val lastRun = Loop.LastRun().apply { constraintsProcessed = apsResult }
|
||||
`when`(loop.lastRun).thenReturn(lastRun)
|
||||
assertEquals(0.45, loopHub.temporaryBasal, 1e-6)
|
||||
verify(loop).lastRun
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTemporaryBasalNoRun() {
|
||||
`when`(loop.lastRun).thenReturn(null)
|
||||
assertTrue(loopHub.temporaryBasal.isNaN())
|
||||
verify(loop, times(1)).lastRun
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetGlucoseValues() {
|
||||
val glucoseValues = listOf(
|
||||
GlucoseValue(
|
||||
timestamp = 1_000_000L, raw = 90.0, value = 93.0,
|
||||
trendArrow = GlucoseValue.TrendArrow.FLAT, noise = null,
|
||||
sourceSensor = GlucoseValue.SourceSensor.DEXCOM_G5_XDRIP))
|
||||
`when`(repo.compatGetBgReadingsDataFromTime(1001_000, false))
|
||||
.thenReturn(Single.just(glucoseValues))
|
||||
assertArrayEquals(
|
||||
glucoseValues.toTypedArray(),
|
||||
loopHub.getGlucoseValues(Instant.ofEpochMilli(1001_000), false).toTypedArray())
|
||||
verify(repo).compatGetBgReadingsDataFromTime(1001_000, false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testStoreHeartRate() {
|
||||
val samplingStart = Instant.ofEpochMilli(1_001_000)
|
||||
val samplingEnd = Instant.ofEpochMilli(1_101_000)
|
||||
val hr = HeartRate(
|
||||
timestamp = samplingStart.toEpochMilli(),
|
||||
duration = samplingEnd.toEpochMilli() - samplingStart.toEpochMilli(),
|
||||
dateCreated = clock.millis(),
|
||||
beatsPerMinute = 101.0,
|
||||
device = "Test Device")
|
||||
`when`(repo.runTransaction(InsertOrUpdateHeartRateTransaction(hr))).thenReturn(
|
||||
Completable.fromCallable {
|
||||
InsertOrUpdateHeartRateTransaction.TransactionResult(
|
||||
emptyList(), emptyList())})
|
||||
loopHub.storeHeartRate(
|
||||
samplingStart, samplingEnd, 101, "Test Device")
|
||||
verify(repo).runTransaction(InsertOrUpdateHeartRateTransaction(hr))
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue