Local http server communication to integrate Garmin devices.
This commit is contained in:
parent
bb133cdbce
commit
5a17a05ee0
19 changed files with 1490 additions and 1 deletions
|
@ -43,6 +43,7 @@ import app.aaps.plugins.configuration.maintenance.MaintenancePlugin
|
||||||
import app.aaps.plugins.constraints.safety.SafetyPlugin
|
import app.aaps.plugins.constraints.safety.SafetyPlugin
|
||||||
import app.aaps.plugins.insulin.InsulinOrefFreePeakPlugin
|
import app.aaps.plugins.insulin.InsulinOrefFreePeakPlugin
|
||||||
import app.aaps.plugins.main.general.smsCommunicator.SmsCommunicatorPlugin
|
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.main.general.wear.WearPlugin
|
||||||
import app.aaps.plugins.sensitivity.SensitivityAAPSPlugin
|
import app.aaps.plugins.sensitivity.SensitivityAAPSPlugin
|
||||||
import app.aaps.plugins.sensitivity.SensitivityOref1Plugin
|
import app.aaps.plugins.sensitivity.SensitivityOref1Plugin
|
||||||
|
@ -128,6 +129,7 @@ class MyPreferenceFragment : PreferenceFragmentCompat(), OnSharedPreferenceChang
|
||||||
@Inject lateinit var nsSettingStatus: NSSettingsStatus
|
@Inject lateinit var nsSettingStatus: NSSettingsStatus
|
||||||
@Inject lateinit var openHumansUploaderPlugin: OpenHumansUploaderPlugin
|
@Inject lateinit var openHumansUploaderPlugin: OpenHumansUploaderPlugin
|
||||||
@Inject lateinit var diaconnG8Plugin: DiaconnG8Plugin
|
@Inject lateinit var diaconnG8Plugin: DiaconnG8Plugin
|
||||||
|
@Inject lateinit var garminPlugin: GarminPlugin
|
||||||
|
|
||||||
override fun onAttach(context: Context) {
|
override fun onAttach(context: Context) {
|
||||||
AndroidSupportInjection.inject(this)
|
AndroidSupportInjection.inject(this)
|
||||||
|
@ -229,6 +231,7 @@ class MyPreferenceFragment : PreferenceFragmentCompat(), OnSharedPreferenceChang
|
||||||
addPreferencesFromResource(app.aaps.plugins.configuration.R.xml.pref_datachoices, rootKey)
|
addPreferencesFromResource(app.aaps.plugins.configuration.R.xml.pref_datachoices, rootKey)
|
||||||
addPreferencesFromResourceIfEnabled(maintenancePlugin, rootKey)
|
addPreferencesFromResourceIfEnabled(maintenancePlugin, rootKey)
|
||||||
addPreferencesFromResourceIfEnabled(openHumansUploaderPlugin, rootKey)
|
addPreferencesFromResourceIfEnabled(openHumansUploaderPlugin, rootKey)
|
||||||
|
addPreferencesFromResourceIfEnabled(garminPlugin, rootKey)
|
||||||
}
|
}
|
||||||
initSummary(preferenceScreen, pluginId != -1)
|
initSummary(preferenceScreen, pluginId != -1)
|
||||||
preprocessPreferences()
|
preprocessPreferences()
|
||||||
|
|
|
@ -22,6 +22,7 @@ import app.aaps.plugins.insulin.InsulinOrefRapidActingPlugin
|
||||||
import app.aaps.plugins.insulin.InsulinOrefUltraRapidActingPlugin
|
import app.aaps.plugins.insulin.InsulinOrefUltraRapidActingPlugin
|
||||||
import app.aaps.plugins.main.general.actions.ActionsPlugin
|
import app.aaps.plugins.main.general.actions.ActionsPlugin
|
||||||
import app.aaps.plugins.main.general.food.FoodPlugin
|
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.overview.OverviewPlugin
|
||||||
import app.aaps.plugins.main.general.persistentNotification.PersistentNotificationPlugin
|
import app.aaps.plugins.main.general.persistentNotification.PersistentNotificationPlugin
|
||||||
import app.aaps.plugins.main.general.smsCommunicator.SmsCommunicatorPlugin
|
import app.aaps.plugins.main.general.smsCommunicator.SmsCommunicatorPlugin
|
||||||
|
@ -465,6 +466,12 @@ abstract class PluginsListModule {
|
||||||
@IntKey(610)
|
@IntKey(610)
|
||||||
abstract fun bindAvgSmoothingPlugin(plugin: AvgSmoothingPlugin): PluginBase
|
abstract fun bindAvgSmoothingPlugin(plugin: AvgSmoothingPlugin): PluginBase
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@AllConfigs
|
||||||
|
@IntoMap
|
||||||
|
@IntKey(623)
|
||||||
|
abstract fun bindGarminPlugin(plugin: GarminPlugin): PluginBase
|
||||||
|
|
||||||
@Qualifier
|
@Qualifier
|
||||||
annotation class AllConfigs
|
annotation class AllConfigs
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ enum class LTag(val tag: String, val defaultValue: Boolean = true, val requiresR
|
||||||
DATABASE("DATABASE"),
|
DATABASE("DATABASE"),
|
||||||
DATATREATMENTS("DATATREATMENTS"),
|
DATATREATMENTS("DATATREATMENTS"),
|
||||||
EVENTS("EVENTS", defaultValue = false, requiresRestart = true),
|
EVENTS("EVENTS", defaultValue = false, requiresRestart = true),
|
||||||
|
GARMIN("GARMIN"),
|
||||||
GLUCOSE("GLUCOSE", defaultValue = false),
|
GLUCOSE("GLUCOSE", defaultValue = false),
|
||||||
HTTP("HTTP"),
|
HTTP("HTTP"),
|
||||||
LOCATION("LOCATION"),
|
LOCATION("LOCATION"),
|
||||||
|
|
|
@ -187,7 +187,9 @@ data class UserEntry(
|
||||||
Overview, //From OverViewPlugin
|
Overview, //From OverViewPlugin
|
||||||
Stats, //From Stat Activity
|
Stats, //From Stat Activity
|
||||||
Aaps, // MainApp
|
Aaps, // MainApp
|
||||||
|
GarminDevice,
|
||||||
Unknown //if necessary
|
Unknown //if necessary
|
||||||
|
,
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
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>)
|
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.ConfigBuilder -> app.aaps.core.ui.R.drawable.ic_cogs
|
||||||
Sources.Overview -> app.aaps.core.ui.R.drawable.ic_home
|
Sources.Overview -> app.aaps.core.ui.R.drawable.ic_home
|
||||||
Sources.Aaps -> R.drawable.ic_aaps
|
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
|
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.iob.IobCobCalculator
|
||||||
import app.aaps.core.interfaces.smsCommunicator.SmsCommunicator
|
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.persistentNotification.DummyService
|
||||||
import app.aaps.plugins.main.general.smsCommunicator.SmsCommunicatorPlugin
|
import app.aaps.plugins.main.general.smsCommunicator.SmsCommunicatorPlugin
|
||||||
import app.aaps.plugins.main.general.wear.WearFragment
|
import app.aaps.plugins.main.general.wear.WearFragment
|
||||||
|
@ -22,7 +23,8 @@ import dagger.android.ContributesAndroidInjector
|
||||||
SkinsUiModule::class,
|
SkinsUiModule::class,
|
||||||
ActionsModule::class,
|
ActionsModule::class,
|
||||||
WearModule::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="default_range">DEFAULT RANGE</string>
|
||||||
<string name="target">target</string>
|
<string name="target">target</string>
|
||||||
<string name="rate_duration">Rate: %1$.2fU/h (%2$.2f%%) \nDuration %3$d min</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>
|
</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