Merge pull request #3059 from buessow/dev-garmin3
Support automatic exchange of communication key for HTTP via connecti…
This commit is contained in:
commit
9642f3ee26
19 changed files with 1821 additions and 44 deletions
|
@ -152,8 +152,11 @@ object Libs {
|
|||
}
|
||||
|
||||
object Mockito {
|
||||
private const val mockitoVersion = "5.6.0"
|
||||
|
||||
const val jupiter = "org.mockito:mockito-junit-jupiter:5.6.0"
|
||||
const val android = "org.mockito:mockito-android:$mockitoVersion"
|
||||
const val core = "org.mockito:mockito-core:$mockitoVersion"
|
||||
const val jupiter = "org.mockito:mockito-junit-jupiter:$mockitoVersion"
|
||||
const val kotlin = "org.mockito.kotlin:mockito-kotlin:5.1.0"
|
||||
}
|
||||
|
||||
|
|
|
@ -22,6 +22,9 @@ dependencies {
|
|||
androidTestImplementation(Libs.AndroidX.Test.rules)
|
||||
androidTestImplementation(Libs.Google.truth)
|
||||
androidTestImplementation(Libs.AndroidX.Test.uiAutomator)
|
||||
androidTestImplementation(Libs.Mockito.core)
|
||||
androidTestImplementation(Libs.Mockito.android)
|
||||
androidTestImplementation(Libs.Mockito.kotlin)
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
|
|
|
@ -30,6 +30,7 @@ dependencies {
|
|||
testImplementation(project(":shared:tests"))
|
||||
testImplementation(project(":implementation"))
|
||||
testImplementation(project(":plugins:aps"))
|
||||
androidTestImplementation(project(":shared:tests"))
|
||||
|
||||
// OpenHuman
|
||||
api(Libs.Squareup.Okhttp3.okhttp)
|
||||
|
@ -49,6 +50,10 @@ dependencies {
|
|||
// DataLayerListenerService
|
||||
api(Libs.Google.Android.PlayServices.wearable)
|
||||
|
||||
// Garmin
|
||||
api("com.garmin.connectiq:ciq-companion-app-sdk:2.0.2@aar")
|
||||
androidTestImplementation("com.garmin.connectiq:ciq-companion-app-sdk:2.0.2@aar")
|
||||
|
||||
kapt(Libs.Dagger.compiler)
|
||||
kapt(Libs.Dagger.androidProcessor)
|
||||
}
|
|
@ -0,0 +1,301 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import app.aaps.shared.tests.TestBase
|
||||
import com.garmin.android.apps.connectmobile.connectiq.IConnectIQService
|
||||
import com.garmin.android.connectiq.ConnectIQ
|
||||
import com.garmin.android.connectiq.IQApp
|
||||
import com.garmin.android.connectiq.IQDevice
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.Mockito.timeout
|
||||
import org.mockito.Mockito.times
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.kotlin.argThat
|
||||
import org.mockito.kotlin.atLeastOnce
|
||||
import org.mockito.kotlin.doAnswer
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.verifyNoMoreInteractions
|
||||
import org.mockito.kotlin.whenever
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class GarminDeviceClientTest: TestBase() {
|
||||
private val serviceDescriptor = "com.garmin.android.apps.connectmobile.connectiq.IConnectIQService"
|
||||
private lateinit var client: GarminDeviceClient
|
||||
private lateinit var serviceConnection: ServiceConnection
|
||||
private lateinit var device: GarminDevice
|
||||
private val packageName = "TestPackage"
|
||||
private val actions = mutableMapOf<String, BroadcastReceiver>()
|
||||
// Maps app ids to intent actions.
|
||||
private val receivers = mutableMapOf<String, String>()
|
||||
|
||||
private val receiver = mock<GarminReceiver>()
|
||||
private val binder = mock<IBinder>() {
|
||||
on { isBinderAlive } doReturn true
|
||||
}
|
||||
private val ciqService = mock<IConnectIQService>() {
|
||||
on { asBinder() } doReturn binder
|
||||
on { connectedDevices } doReturn listOf(IQDevice(1L, "TDevice"))
|
||||
on { registerApp(any(), any(), any()) }.doAnswer { i ->
|
||||
receivers[i.getArgument<IQApp>(0).applicationId] = i.getArgument(1)
|
||||
}
|
||||
}
|
||||
private val context = mock<Context>() {
|
||||
on { packageName } doReturn this@GarminDeviceClientTest.packageName
|
||||
on { registerReceiver(any<BroadcastReceiver>(), any()) } doAnswer { i ->
|
||||
actions[i.getArgument<IntentFilter>(1).getAction(0)] = i.getArgument(0)
|
||||
Intent()
|
||||
}
|
||||
on { unregisterReceiver(any()) } doAnswer { i ->
|
||||
val keys = actions.entries.filter {(_, br) -> br == i.getArgument(0) }.map { (k, _) -> k }
|
||||
keys.forEach { k -> actions.remove(k) }
|
||||
}
|
||||
on { bindService(any(), eq(Context.BIND_AUTO_CREATE), any(), any()) }. doAnswer { i ->
|
||||
serviceConnection = i.getArgument(3)
|
||||
i.getArgument<Executor>(2).execute {
|
||||
serviceConnection.onServiceConnected(
|
||||
GarminDeviceClient.CONNECTIQ_SERVICE_COMPONENT,
|
||||
Binder().apply { attachInterface(ciqService, serviceDescriptor) })
|
||||
}
|
||||
true
|
||||
}
|
||||
on { bindService(any(), any(), eq(Context.BIND_AUTO_CREATE)) }. doAnswer { i ->
|
||||
serviceConnection = i.getArgument(1)
|
||||
serviceConnection.onServiceConnected(
|
||||
GarminDeviceClient.CONNECTIQ_SERVICE_COMPONENT,
|
||||
Binder().apply { attachInterface(ciqService, serviceDescriptor) })
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
client = GarminDeviceClient(aapsLogger, context, receiver, retryWaitFactor = 0L)
|
||||
device = GarminDevice(client, 1L, "TDevice")
|
||||
verify(receiver, timeout(2_000L)).onConnect(client)
|
||||
}
|
||||
|
||||
@After
|
||||
fun shutdown() {
|
||||
if (::client.isInitialized) client.dispose()
|
||||
assertEquals(0, actions.size) // make sure all broadcastReceivers were unregistered
|
||||
verify(context).unbindService(serviceConnection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connect() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disconnect() {
|
||||
serviceConnection.onServiceDisconnected(GarminDeviceClient.CONNECTIQ_SERVICE_COMPONENT)
|
||||
verify(receiver).onDisconnect(client)
|
||||
assertEquals(0, actions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectedDevices() {
|
||||
assertEquals(listOf(device), client.connectedDevices)
|
||||
verify(ciqService).connectedDevices
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconnectDeadBinder() {
|
||||
whenever(binder.isBinderAlive).thenReturn(false, true)
|
||||
assertEquals(listOf(device), client.connectedDevices)
|
||||
|
||||
verify(ciqService).connectedDevices
|
||||
verify(ciqService, times(2)).asBinder()
|
||||
verify(context, times(2))
|
||||
.bindService(any(), eq(Context.BIND_AUTO_CREATE), any(), any())
|
||||
|
||||
verifyNoMoreInteractions(ciqService)
|
||||
verifyNoMoreInteractions(receiver)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessage() {
|
||||
val appId = "APPID1"
|
||||
val data = "Hello, World!".toByteArray()
|
||||
|
||||
client.sendMessage(GarminApplication(device, appId, "$appId-name"), data)
|
||||
verify(ciqService).sendMessage(
|
||||
argThat { iqMsg -> data.contentEquals(iqMsg.messageData)
|
||||
&& iqMsg.notificationPackage == packageName
|
||||
&& iqMsg.notificationAction == client.sendMessageAction },
|
||||
argThat { iqDevice -> iqDevice.deviceIdentifier == device.id },
|
||||
argThat { iqApp -> iqApp?.applicationId == appId })
|
||||
|
||||
val intent = Intent().apply {
|
||||
putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal)
|
||||
putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice())
|
||||
putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId)
|
||||
}
|
||||
actions[client.sendMessageAction]!!.onReceive(context, intent)
|
||||
actions[client.sendMessageAction]!!.onReceive(context, intent) // extra on receive will be ignored
|
||||
verify(receiver).onSendMessage(client, device.id, appId, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessage_failNoRetry() {
|
||||
val appId = "APPID1"
|
||||
val data = "Hello, World!".toByteArray()
|
||||
|
||||
client.sendMessage(GarminApplication(device, appId, "$appId-name"), data)
|
||||
verify(ciqService).sendMessage(
|
||||
argThat { iqMsg -> data.contentEquals(iqMsg.messageData)
|
||||
&& iqMsg.notificationPackage == packageName
|
||||
&& iqMsg.notificationAction == client.sendMessageAction },
|
||||
argThat {iqDevice -> iqDevice.deviceIdentifier == device.id },
|
||||
argThat { iqApp -> iqApp?.applicationId == appId })
|
||||
|
||||
val intent = Intent().apply {
|
||||
putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.FAILURE_MESSAGE_TOO_LARGE.ordinal)
|
||||
putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice())
|
||||
putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId)
|
||||
}
|
||||
actions[client.sendMessageAction]!!.onReceive(context, intent)
|
||||
verify(receiver).onSendMessage(client, device.id, appId, "error FAILURE_MESSAGE_TOO_LARGE")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessage_failRetry() {
|
||||
val appId = "APPID1"
|
||||
val data = "Hello, World!".toByteArray()
|
||||
|
||||
client.sendMessage(GarminApplication(device, appId, "$appId-name"), data)
|
||||
verify(ciqService).sendMessage(
|
||||
argThat { iqMsg -> data.contentEquals(iqMsg.messageData)
|
||||
&& iqMsg.notificationPackage == packageName
|
||||
&& iqMsg.notificationAction == client.sendMessageAction },
|
||||
argThat {iqDevice -> iqDevice.deviceIdentifier == device.id },
|
||||
argThat { iqApp -> iqApp?.applicationId == appId })
|
||||
|
||||
val intent = Intent().apply {
|
||||
putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.FAILURE_DURING_TRANSFER.ordinal)
|
||||
putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice())
|
||||
putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId)
|
||||
}
|
||||
actions[client.sendMessageAction]!!.onReceive(context, intent)
|
||||
verifyNoMoreInteractions(receiver)
|
||||
|
||||
// Verify retry ...
|
||||
verify(ciqService, timeout(10_000L).times( 2)).sendMessage(
|
||||
argThat { iqMsg -> data.contentEquals(iqMsg.messageData)
|
||||
&& iqMsg.notificationPackage == packageName
|
||||
&& iqMsg.notificationAction == client.sendMessageAction },
|
||||
argThat {iqDevice -> iqDevice.deviceIdentifier == device.id },
|
||||
argThat { iqApp -> iqApp?.applicationId == appId })
|
||||
|
||||
intent.putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal)
|
||||
actions[client.sendMessageAction]!!.onReceive(context, intent)
|
||||
verify(receiver).onSendMessage(client, device.id, appId, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessage_2toSameApp() {
|
||||
val appId = "APPID1"
|
||||
val data1 = "m1".toByteArray()
|
||||
val data2 = "m2".toByteArray()
|
||||
|
||||
client.sendMessage(GarminApplication(device, appId, "$appId-name"), data1)
|
||||
client.sendMessage(GarminApplication(device, appId, "$appId-name"), data2)
|
||||
verify(ciqService).sendMessage(
|
||||
argThat { iqMsg -> data1.contentEquals(iqMsg.messageData)
|
||||
&& iqMsg.notificationPackage == packageName
|
||||
&& iqMsg.notificationAction == client.sendMessageAction },
|
||||
argThat {iqDevice -> iqDevice.deviceIdentifier == device.id },
|
||||
argThat { iqApp -> iqApp?.applicationId == appId })
|
||||
verify(ciqService, atLeastOnce()).asBinder()
|
||||
verifyNoMoreInteractions(ciqService)
|
||||
|
||||
val intent = Intent().apply {
|
||||
putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal)
|
||||
putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice())
|
||||
putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId)
|
||||
}
|
||||
actions[client.sendMessageAction]!!.onReceive(context, intent)
|
||||
verify(receiver).onSendMessage(client, device.id, appId, null)
|
||||
|
||||
verify(ciqService, timeout(5000L)).sendMessage(
|
||||
argThat { iqMsg -> data2.contentEquals(iqMsg.messageData)
|
||||
&& iqMsg.notificationPackage == packageName
|
||||
&& iqMsg.notificationAction == client.sendMessageAction },
|
||||
argThat {iqDevice -> iqDevice.deviceIdentifier == device.id },
|
||||
argThat { iqApp -> iqApp?.applicationId == appId })
|
||||
|
||||
actions[client.sendMessageAction]!!.onReceive(context, intent)
|
||||
verify(receiver, times(2)).onSendMessage(client, device.id, appId, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessage_2to2Apps() {
|
||||
val appId1 = "APPID1"
|
||||
val appId2 = "APPID2"
|
||||
val data1 = "m1".toByteArray()
|
||||
val data2 = "m2".toByteArray()
|
||||
|
||||
client.sendMessage(GarminApplication(device, appId1, "$appId1-name"), data1)
|
||||
client.sendMessage(GarminApplication(device, appId2, "$appId2-name"), data2)
|
||||
verify(ciqService).sendMessage(
|
||||
argThat { iqMsg -> data1.contentEquals(iqMsg.messageData)
|
||||
&& iqMsg.notificationPackage == packageName
|
||||
&& iqMsg.notificationAction == client.sendMessageAction },
|
||||
argThat {iqDevice -> iqDevice.deviceIdentifier == device.id },
|
||||
argThat { iqApp -> iqApp?.applicationId == appId1 })
|
||||
verify(ciqService, timeout(5000L)).sendMessage(
|
||||
argThat { iqMsg -> data2.contentEquals(iqMsg.messageData)
|
||||
&& iqMsg.notificationPackage == packageName
|
||||
&& iqMsg.notificationAction == client.sendMessageAction },
|
||||
argThat {iqDevice -> iqDevice.deviceIdentifier == device.id },
|
||||
argThat { iqApp -> iqApp?.applicationId == appId2 })
|
||||
|
||||
val intent1 = Intent().apply {
|
||||
putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal)
|
||||
putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice())
|
||||
putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId1)
|
||||
}
|
||||
actions[client.sendMessageAction]!!.onReceive(context, intent1)
|
||||
verify(receiver).onSendMessage(client, device.id, appId1, null)
|
||||
|
||||
val intent2 = Intent().apply {
|
||||
putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal)
|
||||
putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice())
|
||||
putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId2)
|
||||
}
|
||||
actions[client.sendMessageAction]!!.onReceive(context, intent2)
|
||||
verify(receiver).onSendMessage(client, device.id, appId2, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun receiveMessage() {
|
||||
val app = GarminApplication(GarminDevice(client, 1L, "D1"), "APPID1", "N1")
|
||||
client.registerForMessages(app)
|
||||
assertTrue(receivers.contains(app.id))
|
||||
val intent = Intent().apply {
|
||||
putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, app.device.toIQDevice())
|
||||
putExtra(GarminDeviceClient.EXTRA_PAYLOAD, "foo".toByteArray())
|
||||
}
|
||||
actions[receivers[app.id]]!!.onReceive(context, intent)
|
||||
verify(receiver).onReceiveMessage(
|
||||
eq(client),
|
||||
eq(app.device.id),
|
||||
eq(app.id),
|
||||
argThat { payload -> "foo" == String(payload) })
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
data class GarminApplication(
|
||||
val device: GarminDevice,
|
||||
val id: String,
|
||||
val name: String?) {
|
||||
|
||||
val client get() = device.client
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as GarminApplication
|
||||
|
||||
if (client != other.client) return false
|
||||
if (device != other.device) return false
|
||||
if (id != other.id) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = client.hashCode()
|
||||
result = 31 * result + device.hashCode()
|
||||
result = 31 * result + id.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString() = "A[$device:$id:$name]"
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
|
||||
interface GarminClient: Disposable {
|
||||
/** Name of the client. */
|
||||
val name: String
|
||||
|
||||
val connectedDevices: List<GarminDevice>
|
||||
|
||||
/** Register to receive messages from the given up. */
|
||||
fun registerForMessages(app: GarminApplication)
|
||||
|
||||
/** Asynchronously sends a message to an application. */
|
||||
fun sendMessage(app: GarminApplication, data: ByteArray)
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import com.garmin.android.connectiq.IQDevice
|
||||
|
||||
data class GarminDevice(
|
||||
val client: GarminClient,
|
||||
val id: Long,
|
||||
var name: String) {
|
||||
|
||||
constructor(client: GarminClient, iqDevice: IQDevice): this(
|
||||
client,
|
||||
iqDevice.deviceIdentifier,
|
||||
iqDevice.friendlyName) {}
|
||||
|
||||
override fun toString(): String = "D[$name/$id]"
|
||||
|
||||
fun toIQDevice() = IQDevice(id, name)
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as GarminDevice
|
||||
|
||||
if (client != other.client) return false
|
||||
if (id != other.id) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = client.hashCode()
|
||||
result = 31 * result + id.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -0,0 +1,292 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import app.aaps.core.interfaces.logging.AAPSLogger
|
||||
import app.aaps.core.interfaces.logging.LTag
|
||||
import app.aaps.core.utils.waitMillis
|
||||
import com.garmin.android.apps.connectmobile.connectiq.IConnectIQService
|
||||
import com.garmin.android.connectiq.ConnectIQ.IQMessageStatus
|
||||
import com.garmin.android.connectiq.IQApp
|
||||
import com.garmin.android.connectiq.IQDevice
|
||||
import com.garmin.android.connectiq.IQMessage
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.jetbrains.annotations.VisibleForTesting
|
||||
import java.lang.Thread.UncaughtExceptionHandler
|
||||
import java.time.Instant
|
||||
import java.util.LinkedList
|
||||
import java.util.Queue
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
/** GarminClient that talks via the ConnectIQ app to a physical device. */
|
||||
class GarminDeviceClient(
|
||||
private val aapsLogger: AAPSLogger,
|
||||
private val context: Context,
|
||||
private val receiver: GarminReceiver,
|
||||
private val retryWaitFactor: Long = 5L): Disposable, GarminClient {
|
||||
|
||||
override val name = "Device"
|
||||
private var executor = Executors.newSingleThreadExecutor { r ->
|
||||
Thread(r).apply {
|
||||
name = "Garmin callback"
|
||||
isDaemon = true
|
||||
uncaughtExceptionHandler = UncaughtExceptionHandler { _, e ->
|
||||
aapsLogger.error(LTag.GARMIN, "ConnectIQ callback failed", e) }
|
||||
}
|
||||
}
|
||||
private var bindLock = Object()
|
||||
private var ciqService: IConnectIQService? = null
|
||||
get() {
|
||||
synchronized (bindLock) {
|
||||
if (field?.asBinder()?.isBinderAlive != true) {
|
||||
field = null
|
||||
if (state !in arrayOf(State.BINDING, State.RECONNECTING)) {
|
||||
aapsLogger.info(LTag.GARMIN, "reconnecting to ConnectIQ service")
|
||||
state = State.RECONNECTING
|
||||
bindService()
|
||||
}
|
||||
bindLock.waitMillis(2_000L)
|
||||
if (field?.asBinder()?.isBinderAlive != true) {
|
||||
field = null
|
||||
// The [serviceConnection] didn't have a chance to reassign ciqService,
|
||||
// i.e. the wait timed out. Give up.
|
||||
aapsLogger.warn(LTag.GARMIN, "no ciqservice $this")
|
||||
}
|
||||
}
|
||||
return field
|
||||
}
|
||||
}
|
||||
|
||||
private val registeredActions = mutableSetOf<String>()
|
||||
private val broadcastReceiver = mutableListOf<BroadcastReceiver>()
|
||||
private var state = State.DISCONNECTED
|
||||
private val serviceIntent get() = Intent(CONNECTIQ_SERVICE_ACTION).apply {
|
||||
component = CONNECTIQ_SERVICE_COMPONENT }
|
||||
|
||||
@VisibleForTesting
|
||||
val sendMessageAction = createAction("SEND_MESSAGE")
|
||||
|
||||
private enum class State {
|
||||
BINDING,
|
||||
CONNECTED,
|
||||
DISCONNECTED,
|
||||
DISPOSED,
|
||||
RECONNECTING,
|
||||
}
|
||||
|
||||
private val ciqServiceConnection = object: ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
var notifyReceiver: Boolean
|
||||
val ciq: IConnectIQService
|
||||
synchronized(bindLock) {
|
||||
aapsLogger.info(LTag.GARMIN, "ConnectIQ App connected")
|
||||
ciq = IConnectIQService.Stub.asInterface(service)
|
||||
notifyReceiver = state != State.RECONNECTING
|
||||
state = State.CONNECTED
|
||||
ciqService = ciq
|
||||
bindLock.notifyAll()
|
||||
}
|
||||
if (notifyReceiver) receiver.onConnect(this@GarminDeviceClient)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
synchronized(bindLock) {
|
||||
aapsLogger.info(LTag.GARMIN, "ConnectIQ App disconnected")
|
||||
ciqService = null
|
||||
if (state != State.DISPOSED) state = State.DISCONNECTED
|
||||
}
|
||||
broadcastReceiver.forEach { br -> context.unregisterReceiver(br) }
|
||||
broadcastReceiver.clear()
|
||||
registeredActions.clear()
|
||||
receiver.onDisconnect(this@GarminDeviceClient)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
aapsLogger.info(LTag.GARMIN, "binding to ConnectIQ service")
|
||||
registerReceiver(sendMessageAction, ::onSendMessage)
|
||||
state = State.BINDING
|
||||
bindService()
|
||||
}
|
||||
|
||||
private fun bindService() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
context.bindService(serviceIntent, Context.BIND_AUTO_CREATE, executor, ciqServiceConnection)
|
||||
} else {
|
||||
context.bindService(serviceIntent, ciqServiceConnection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
override val connectedDevices: List<GarminDevice>
|
||||
get() = ciqService?.connectedDevices?.map { iqDevice -> GarminDevice(this, iqDevice) }
|
||||
?: emptyList()
|
||||
|
||||
override fun isDisposed() = state == State.DISPOSED
|
||||
override fun dispose() {
|
||||
broadcastReceiver.forEach { context.unregisterReceiver(it) }
|
||||
broadcastReceiver.clear()
|
||||
registeredActions.clear()
|
||||
try {
|
||||
context.unbindService(ciqServiceConnection)
|
||||
} catch (e: Exception) {
|
||||
aapsLogger.warn(LTag.GARMIN, "unbind CIQ failed ${e.message}")
|
||||
}
|
||||
state = State.DISPOSED
|
||||
}
|
||||
|
||||
/** Creates a unique action name for ConnectIQ callbacks. */
|
||||
private fun createAction(action: String) = "${javaClass.`package`!!.name}.$action"
|
||||
|
||||
/** Registers a callback [BroadcastReceiver] under the given action that will
|
||||
* used by the ConnectIQ app for callbacks.*/
|
||||
private fun registerReceiver(action: String, receive: (intent: Intent) -> Unit) {
|
||||
val recv = object: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent) { receive(intent) }
|
||||
}
|
||||
broadcastReceiver.add(recv)
|
||||
context.registerReceiver(recv, IntentFilter(action))
|
||||
}
|
||||
|
||||
override fun registerForMessages(app: GarminApplication) {
|
||||
aapsLogger.info(LTag.GARMIN, "registerForMessage $name $app")
|
||||
val action = createAction("ON_MESSAGE_${app.device.id}_${app.id}")
|
||||
val iqApp = IQApp(app.id)
|
||||
synchronized (registeredActions) {
|
||||
if (!registeredActions.contains(action)) {
|
||||
registerReceiver(action) { intent: Intent -> onReceiveMessage(iqApp, intent) }
|
||||
ciqService?.registerApp(iqApp, action, context.packageName)
|
||||
registeredActions.add(action)
|
||||
} else {
|
||||
aapsLogger.info(LTag.GARMIN, "registerForMessage $action already registered")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private fun onReceiveMessage(iqApp: IQApp, intent: Intent) {
|
||||
val iqDevice = intent.getParcelableExtra(EXTRA_REMOTE_DEVICE) as IQDevice?
|
||||
val data = intent.getByteArrayExtra(EXTRA_PAYLOAD)
|
||||
if (iqDevice != null && data != null)
|
||||
receiver.onReceiveMessage(this, iqDevice.deviceIdentifier, iqApp.applicationId, data)
|
||||
}
|
||||
|
||||
/** Receives callback from ConnectIQ about message transfers. */
|
||||
private fun onSendMessage(intent: Intent) {
|
||||
val statusOrd = intent.getIntExtra(EXTRA_STATUS, IQMessageStatus.FAILURE_UNKNOWN.ordinal)
|
||||
val status = IQMessageStatus.values().firstOrNull { s -> s.ordinal == statusOrd } ?: IQMessageStatus.FAILURE_UNKNOWN
|
||||
val deviceId = getDevice(intent)
|
||||
val appId = intent.getStringExtra(EXTRA_APPLICATION_ID)?.uppercase()
|
||||
if (deviceId == null || appId == null) {
|
||||
aapsLogger.warn(LTag.GARMIN, "onSendMessage device='$deviceId' app='$appId'")
|
||||
} else {
|
||||
synchronized (messageQueues) {
|
||||
val queue = messageQueues[deviceId to appId]
|
||||
val msg = queue?.peek()
|
||||
if (queue == null || msg == null) {
|
||||
aapsLogger.warn(LTag.GARMIN, "onSendMessage unknown message $deviceId, $appId, $status")
|
||||
return
|
||||
}
|
||||
|
||||
var errorMessage: String? = null
|
||||
when (status) {
|
||||
IQMessageStatus.SUCCESS -> {}
|
||||
IQMessageStatus.FAILURE_DEVICE_NOT_CONNECTED,
|
||||
IQMessageStatus.FAILURE_DURING_TRANSFER -> {
|
||||
if (msg.attempt < MAX_RETRIES) {
|
||||
val delaySec = retryWaitFactor * msg.attempt
|
||||
Schedulers.io().scheduleDirect({ retryMessage(deviceId, appId) }, delaySec, TimeUnit.SECONDS)
|
||||
return
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
errorMessage = "error $status"
|
||||
}
|
||||
}
|
||||
queue.poll()
|
||||
receiver.onSendMessage(this, msg.app.device.id, msg.app.id, errorMessage)
|
||||
if (queue.isNotEmpty()) {
|
||||
Schedulers.io().scheduleDirect { retryMessage(deviceId, appId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private fun getDevice(intent: Intent): Long? {
|
||||
val rawDevice = intent.extras?.get(EXTRA_REMOTE_DEVICE)
|
||||
return if (rawDevice is Long) rawDevice else (rawDevice as IQDevice?)?.deviceIdentifier
|
||||
?: return null
|
||||
}
|
||||
|
||||
private class Message(
|
||||
val app: GarminApplication,
|
||||
val data: ByteArray) {
|
||||
var attempt: Int = 0
|
||||
val creation = Instant.now()
|
||||
var lastAttempt: Instant? = null
|
||||
val iqApp get() = IQApp(app.id, app.name, 0)
|
||||
val iqDevice get() = app.device.toIQDevice()
|
||||
}
|
||||
|
||||
private val messageQueues = mutableMapOf<Pair<Long, String>, Queue<Message>> ()
|
||||
|
||||
override fun sendMessage(app: GarminApplication, data: ByteArray) {
|
||||
val msg = synchronized (messageQueues) {
|
||||
val msg = Message(app, data)
|
||||
val oldMessageCutOff = Instant.now().minusSeconds(30)
|
||||
val queue = messageQueues.getOrPut(app.device.id to app.id) { LinkedList() }
|
||||
while (true) {
|
||||
val oldMsg = queue.peek() ?: break
|
||||
if ((oldMsg.lastAttempt ?: oldMsg.creation).isBefore(oldMessageCutOff)) {
|
||||
aapsLogger.warn(LTag.GARMIN, "remove old msg ${msg.app}")
|
||||
queue.poll()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
queue.add(msg)
|
||||
// Make sure we have only one outstanding message per app, so we ensure
|
||||
// that always the first message in the queue is currently send.
|
||||
if (queue.size == 1) msg else null
|
||||
}
|
||||
if (msg != null) sendMessage(msg)
|
||||
}
|
||||
|
||||
private fun retryMessage(deviceId: Long, appId: String) {
|
||||
val msg = synchronized (messageQueues) {
|
||||
messageQueues[deviceId to appId]?.peek() ?: return
|
||||
}
|
||||
sendMessage(msg)
|
||||
}
|
||||
|
||||
private fun sendMessage(msg: Message) {
|
||||
msg.attempt++
|
||||
msg.lastAttempt = Instant.now()
|
||||
val iqMsg = IQMessage(msg.data, context.packageName, sendMessageAction)
|
||||
ciqService?.sendMessage(iqMsg, msg.iqDevice, msg.iqApp)
|
||||
}
|
||||
|
||||
override fun toString() = "$name[$state]"
|
||||
|
||||
companion object {
|
||||
const val CONNECTIQ_SERVICE_ACTION = "com.garmin.android.apps.connectmobile.CONNECTIQ_SERVICE_ACTION"
|
||||
const val EXTRA_APPLICATION_ID = "com.garmin.android.connectiq.EXTRA_APPLICATION_ID"
|
||||
const val EXTRA_REMOTE_DEVICE = "com.garmin.android.connectiq.EXTRA_REMOTE_DEVICE"
|
||||
const val EXTRA_PAYLOAD = "com.garmin.android.connectiq.EXTRA_PAYLOAD"
|
||||
const val EXTRA_STATUS = "com.garmin.android.connectiq.EXTRA_STATUS"
|
||||
val CONNECTIQ_SERVICE_COMPONENT = ComponentName(
|
||||
"com.garmin.android.apps.connectmobile",
|
||||
"com.garmin.android.apps.connectmobile.connectiq.ConnectIQService")
|
||||
|
||||
const val MAX_RETRIES = 10
|
||||
}
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import android.content.Context
|
||||
import app.aaps.core.interfaces.logging.AAPSLogger
|
||||
import app.aaps.core.interfaces.logging.LTag
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
|
||||
class GarminMessenger(
|
||||
private val aapsLogger: AAPSLogger,
|
||||
private val context: Context,
|
||||
applicationIdNames: Map<String, String>,
|
||||
private val messageCallback: (app: GarminApplication, msg: Any) -> Unit,
|
||||
enableConnectIq: Boolean,
|
||||
enableSimulator: Boolean): Disposable, GarminReceiver {
|
||||
|
||||
private var disposed: Boolean = false
|
||||
/** All devices that where connected since this instance was created. */
|
||||
private val devices = mutableMapOf<Long, GarminDevice>()
|
||||
private val clients = mutableListOf<GarminClient>()
|
||||
private val appIdNames = mutableMapOf<String, String>()
|
||||
init {
|
||||
aapsLogger.info(LTag.GARMIN, "init CIQ debug=$enableSimulator")
|
||||
appIdNames.putAll(applicationIdNames)
|
||||
if (enableConnectIq) startDeviceClient()
|
||||
if (enableSimulator) {
|
||||
appIdNames["SimApp"] = "SimulatorApp"
|
||||
GarminSimulatorClient(aapsLogger, this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDevice(client: GarminClient, deviceId: Long): GarminDevice {
|
||||
synchronized (devices) {
|
||||
return devices.getOrPut(deviceId) {
|
||||
client.connectedDevices.firstOrNull { d -> d.id == deviceId } ?:
|
||||
GarminDevice(client, deviceId, "unknown") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getApplication(client: GarminClient, deviceId: Long, appId: String): GarminApplication {
|
||||
return GarminApplication(getDevice(client, deviceId), appId, appIdNames[appId])
|
||||
}
|
||||
|
||||
private fun startDeviceClient() {
|
||||
GarminDeviceClient(aapsLogger, context, this)
|
||||
}
|
||||
|
||||
override fun onConnect(client: GarminClient) {
|
||||
aapsLogger.info(LTag.GARMIN, "onConnect $client")
|
||||
clients.add(client)
|
||||
}
|
||||
|
||||
override fun onDisconnect(client: GarminClient) {
|
||||
aapsLogger.info(LTag.GARMIN, "onDisconnect ${client.name}")
|
||||
clients.remove(client)
|
||||
synchronized (devices) {
|
||||
val deviceIds = devices.filter { (_, d) -> d.client == client }.map { (id, _) -> id }
|
||||
deviceIds.forEach { id -> devices.remove(id) }
|
||||
}
|
||||
client.dispose()
|
||||
when (client) {
|
||||
is GarminDeviceClient -> startDeviceClient()
|
||||
is GarminSimulatorClient -> GarminSimulatorClient(aapsLogger, this)
|
||||
else -> aapsLogger.warn(LTag.GARMIN, "onDisconnect unknown client $client")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceiveMessage(client: GarminClient, deviceId: Long, appId: String, data: ByteArray) {
|
||||
val app = getApplication(client, deviceId, appId)
|
||||
val msg = GarminSerializer.deserialize(data)
|
||||
if (msg == null) {
|
||||
aapsLogger.warn(LTag.GARMIN, "receive NULL msg")
|
||||
} else {
|
||||
aapsLogger.info(LTag.GARMIN, "receive ${data.size} bytes")
|
||||
messageCallback(app, msg)
|
||||
}
|
||||
}
|
||||
|
||||
/** Receives status notifications for a sent message. */
|
||||
override fun onSendMessage(client: GarminClient, deviceId: Long, appId: String, errorMessage: String?) {
|
||||
val app = getApplication(client, deviceId, appId)
|
||||
aapsLogger.info(LTag.GARMIN, "onSendMessage $app ${errorMessage ?: "OK"}")
|
||||
}
|
||||
|
||||
fun sendMessage(device: GarminDevice, msg: Any) {
|
||||
appIdNames.forEach { (appId, _) ->
|
||||
sendMessage(getApplication(device.client, device.id, appId), msg)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a message to all applications on all devices. */
|
||||
fun sendMessage(msg: Any) {
|
||||
clients.forEach { cl -> cl.connectedDevices.forEach { d -> sendMessage(d, msg) }}
|
||||
}
|
||||
|
||||
private fun sendMessage(app: GarminApplication, msg: Any) {
|
||||
// Convert msg to string for logging.
|
||||
val s = when (msg) {
|
||||
is Map<*,*> ->
|
||||
msg.entries.joinToString(", ", "(", ")") { (k, v) -> "$k=$v" }
|
||||
is List<*> ->
|
||||
msg.joinToString(", ", "(", ")")
|
||||
else ->
|
||||
msg.toString()
|
||||
}
|
||||
val data = GarminSerializer.serialize(msg)
|
||||
aapsLogger.info(LTag.GARMIN, "sendMessage $app ${data.size} bytes $s")
|
||||
try {
|
||||
app.client.sendMessage(app, data)
|
||||
} catch (e: IllegalStateException) {
|
||||
aapsLogger.error(LTag.GARMIN, "${app.client} not connected", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
if (!disposed) {
|
||||
clients.forEach { c -> c.dispose() }
|
||||
disposed = true
|
||||
}
|
||||
clients.clear()
|
||||
}
|
||||
|
||||
override fun isDisposed() = disposed
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import app.aaps.core.interfaces.db.GlucoseUnit
|
||||
import app.aaps.core.interfaces.logging.AAPSLogger
|
||||
|
@ -22,6 +23,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
|
|||
import java.math.BigDecimal
|
||||
import java.math.MathContext
|
||||
import java.math.RoundingMode
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketAddress
|
||||
import java.net.URI
|
||||
import java.time.Clock
|
||||
|
@ -46,6 +48,7 @@ class GarminPlugin @Inject constructor(
|
|||
injector: HasAndroidInjector,
|
||||
aapsLogger: AAPSLogger,
|
||||
resourceHelper: ResourceHelper,
|
||||
private val context: Context,
|
||||
private val loopHub: LoopHub,
|
||||
private val rxBus: RxBus,
|
||||
private val sp: SP,
|
||||
|
@ -61,7 +64,19 @@ class GarminPlugin @Inject constructor(
|
|||
) {
|
||||
/** HTTP Server for local HTTP server communication (device app requests values) .*/
|
||||
private var server: HttpServer? = null
|
||||
var garminMessenger: GarminMessenger? = null
|
||||
|
||||
/** Garmin ConnectIQ application id for native communication. Phone pushes values. */
|
||||
private val glucoseAppIds = mapOf(
|
||||
"C9E90EE7E6924829A8B45E7DAFFF5CB4" to "GlucoseWatch_Dev",
|
||||
"1107CA6C2D5644B998D4BCB3793F2B7C" to "GlucoseDataField_Dev",
|
||||
"928FE19A4D3A4259B50CB6F9DDAF0F4A" to "GlucoseWidget_Dev",
|
||||
"662DFCF7F5A147DE8BD37F09574ADB11" to "GlucoseWatch",
|
||||
"815C7328C21248C493AD9AC4682FE6B3" to "GlucoseDataField",
|
||||
"4BDDCC1740084A1FAB83A3B2E2FCF55B" to "GlucoseWidget",
|
||||
)
|
||||
|
||||
@VisibleForTesting
|
||||
private val disposable = CompositeDisposable()
|
||||
|
||||
@VisibleForTesting
|
||||
|
@ -72,10 +87,24 @@ class GarminPlugin @Inject constructor(
|
|||
var newValue: Condition = valueLock.newCondition()
|
||||
private var lastGlucoseValueTimestamp: Long? = null
|
||||
private val glucoseUnitStr get() = if (loopHub.glucoseUnit == GlucoseUnit.MGDL) "mgdl" else "mmoll"
|
||||
private val garminAapsKey get() = sp.getString("garmin_aaps_key", "")
|
||||
|
||||
private fun onPreferenceChange(event: EventPreferenceChange) {
|
||||
aapsLogger.info(LTag.GARMIN, "preferences change ${event.changedKey}")
|
||||
setupHttpServer()
|
||||
when (event.changedKey) {
|
||||
"communication_debug_mode" -> setupGarminMessenger()
|
||||
"communication_http", "communication_http_port" -> setupHttpServer()
|
||||
"garmin_aaps_key" -> sendPhoneAppMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupGarminMessenger() {
|
||||
val enableDebug = sp.getBoolean("communication_ciq_debug_mode", false)
|
||||
garminMessenger?.dispose()
|
||||
garminMessenger = null
|
||||
aapsLogger.info(LTag.GARMIN, "initialize IQ messenger in debug=$enableDebug")
|
||||
garminMessenger = GarminMessenger(
|
||||
aapsLogger, context, glucoseAppIds, {_, _ -> },
|
||||
true, enableDebug).also { disposable.add(it) }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
|
@ -87,20 +116,34 @@ class GarminPlugin @Inject constructor(
|
|||
.observeOn(Schedulers.io())
|
||||
.subscribe(::onPreferenceChange)
|
||||
)
|
||||
disposable.add(
|
||||
rxBus
|
||||
.toObservable(EventNewBG::class.java)
|
||||
.observeOn(Schedulers.io())
|
||||
.subscribe(::onNewBloodGlucose)
|
||||
)
|
||||
setupHttpServer()
|
||||
if (garminAapsKey.isNotEmpty())
|
||||
setupGarminMessenger()
|
||||
}
|
||||
|
||||
private fun setupHttpServer() {
|
||||
setupHttpServer(Duration.ZERO)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun setupHttpServer(wait: Duration) {
|
||||
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)
|
||||
registerEndpoint("/carbs", ::onPostCarbs)
|
||||
registerEndpoint("/connect", ::onConnectPump)
|
||||
registerEndpoint("/sgv.json", ::onSgv)
|
||||
registerEndpoint("/get", requestHandler(::onGetBloodGlucose))
|
||||
registerEndpoint("/carbs", requestHandler(::onPostCarbs))
|
||||
registerEndpoint("/connect", requestHandler(::onConnectPump))
|
||||
registerEndpoint("/connect", requestHandler(::onSgv))
|
||||
awaitReady(wait)
|
||||
}
|
||||
} else if (server != null) {
|
||||
aapsLogger.info(LTag.GARMIN, "stopping HTTP server")
|
||||
|
@ -109,7 +152,7 @@ class GarminPlugin @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
public override fun onStop() {
|
||||
disposable.clear()
|
||||
aapsLogger.info(LTag.GARMIN, "Stop")
|
||||
server?.close()
|
||||
|
@ -133,6 +176,36 @@ class GarminPlugin @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun onConnectDevice(device: GarminDevice) {
|
||||
if (garminAapsKey.isNotEmpty()) {
|
||||
aapsLogger.info(LTag.GARMIN, "onConnectDevice $device sending glucose")
|
||||
sendPhoneAppMessage(device)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendPhoneAppMessage(device: GarminDevice) {
|
||||
garminMessenger?.sendMessage(device, getGlucoseMessage())
|
||||
}
|
||||
|
||||
private fun sendPhoneAppMessage() {
|
||||
garminMessenger?.sendMessage(getGlucoseMessage())
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun getGlucoseMessage() = mapOf<String, Any>(
|
||||
"key" to garminAapsKey,
|
||||
"command" to "glucose",
|
||||
"profile" to loopHub.currentProfileName.first().toString(),
|
||||
"encodedGlucose" to encodedGlucose(getGlucoseValues()),
|
||||
"remainingInsulin" to loopHub.insulinOnboard,
|
||||
"glucoseUnit" to glucoseUnitStr,
|
||||
"temporaryBasalRate" to
|
||||
(loopHub.temporaryBasal.takeIf(java.lang.Double::isFinite) ?: 1.0),
|
||||
"connected" to loopHub.isConnected,
|
||||
"timestamp" to clock.instant().epochSecond
|
||||
)
|
||||
|
||||
/** Gets the last 2+ hours of glucose values. */
|
||||
@VisibleForTesting
|
||||
fun getGlucoseValues(): List<GlucoseValue> {
|
||||
|
@ -166,21 +239,33 @@ class GarminPlugin @Inject constructor(
|
|||
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()
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun requestHandler(action: (URI) -> CharSequence) = {
|
||||
caller: SocketAddress, uri: URI, _: String? ->
|
||||
val key = garminAapsKey
|
||||
val deviceKey = getQueryParameter(uri, "key")
|
||||
if (key.isNotEmpty() && key != deviceKey) {
|
||||
aapsLogger.warn(LTag.GARMIN, "Invalid AAPS Key from $caller, got '$deviceKey' want '$key' $uri")
|
||||
sendPhoneAppMessage()
|
||||
Thread.sleep(1000L)
|
||||
HttpURLConnection.HTTP_UNAUTHORIZED to "{}"
|
||||
} else {
|
||||
aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri")
|
||||
HttpURLConnection.HTTP_OK to action(uri).also {
|
||||
aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri, result: $it")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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")
|
||||
fun onGetBloodGlucose(uri: URI): CharSequence {
|
||||
receiveHeartRate(uri)
|
||||
val profileName = loopHub.currentProfileName
|
||||
val waitSec = getQueryParameter(uri, "wait", 0L)
|
||||
|
@ -194,9 +279,7 @@ class GarminPlugin @Inject constructor(
|
|||
}
|
||||
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")
|
||||
}
|
||||
return jo.toString()
|
||||
}
|
||||
|
||||
private fun getQueryParameter(uri: URI, name: String) = (uri.query ?: "")
|
||||
|
@ -228,6 +311,19 @@ class GarminPlugin @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun toLong(v: Any?) = (v as? Number?)?.toLong() ?: 0L
|
||||
|
||||
@VisibleForTesting
|
||||
fun receiveHeartRate(msg: Map<String, Any>, test: Boolean) {
|
||||
val avg: Int = msg.getOrDefault("hr", 0) as Int
|
||||
val samplingStartSec: Long = toLong(msg["hrStart"])
|
||||
val samplingEndSec: Long = toLong(msg["hrEnd"])
|
||||
val device: String? = msg["device"] as String?
|
||||
receiveHeartRate(
|
||||
Instant.ofEpochSecond(samplingStartSec), Instant.ofEpochSecond(samplingEndSec),
|
||||
avg, device, test)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun receiveHeartRate(uri: URI) {
|
||||
val avg: Int = getQueryParameter(uri, "hr", 0L).toInt()
|
||||
|
@ -242,20 +338,18 @@ class GarminPlugin @Inject constructor(
|
|||
private fun receiveHeartRate(
|
||||
samplingStart: Instant, samplingEnd: Instant,
|
||||
avg: Int, device: String?, test: Boolean) {
|
||||
aapsLogger.info(LTag.GARMIN, "average heart rate $avg BPM test=$test")
|
||||
aapsLogger.info(LTag.GARMIN, "average heart rate $avg BPM $samplingStart to $samplingEnd")
|
||||
if (test) return
|
||||
if (avg > 10 && samplingStart > Instant.ofEpochMilli(0L) && samplingEnd > samplingStart) {
|
||||
loopHub.storeHeartRate(samplingStart, samplingEnd, avg, device)
|
||||
} else {
|
||||
} else if (avg > 0) {
|
||||
aapsLogger.warn(LTag.GARMIN, "Skip saving invalid HR $avg $samplingStart..$samplingEnd")
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles carb notification from the device. */
|
||||
@VisibleForTesting
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun onPostCarbs(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
|
||||
aapsLogger.info(LTag.GARMIN, "carbs from $caller, req: $uri")
|
||||
fun onPostCarbs(uri: URI): CharSequence {
|
||||
postCarbs(getQueryParameter(uri, "carbs", 0L).toInt())
|
||||
return ""
|
||||
}
|
||||
|
@ -268,9 +362,7 @@ class GarminPlugin @Inject constructor(
|
|||
|
||||
/** Handles pump connected notification that the user entered on the Garmin device. */
|
||||
@VisibleForTesting
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun onConnectPump(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
|
||||
aapsLogger.info(LTag.GARMIN, "connect from $caller, req: $uri")
|
||||
fun onConnectPump(uri: URI): CharSequence {
|
||||
val minutes = getQueryParameter(uri, "disconnectMinutes", 0L).toInt()
|
||||
if (minutes > 0) {
|
||||
loopHub.disconnectPump(minutes)
|
||||
|
@ -289,8 +381,7 @@ class GarminPlugin @Inject constructor(
|
|||
|
||||
/** Returns glucose values in Nightscout/Xdrip format. */
|
||||
@VisibleForTesting
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun onSgv(call: SocketAddress, uri: URI, requestBody: String?): CharSequence {
|
||||
fun onSgv(uri: URI): CharSequence {
|
||||
val count = getQueryParameter(uri,"count", 24L)
|
||||
.toInt().coerceAtMost(1000).coerceAtLeast(1)
|
||||
val briefMode = getQueryParameter(uri, "brief_mode", false)
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
/**
|
||||
* Callback interface for a @see ConnectIqClient.
|
||||
*/
|
||||
interface GarminReceiver {
|
||||
/**
|
||||
* Notifies that the client is ready, i.e. the app client as bound to the Garmin
|
||||
* Android app.
|
||||
*/
|
||||
fun onConnect(client: GarminClient)
|
||||
fun onDisconnect(client: GarminClient)
|
||||
|
||||
/**
|
||||
* Delivers received device app messages.
|
||||
*/
|
||||
fun onReceiveMessage(client: GarminClient, deviceId: Long, appId: String, data: ByteArray)
|
||||
|
||||
/**
|
||||
* Delivers status of @see ConnectIqClient#sendMessage requests.
|
||||
*/
|
||||
fun onSendMessage(client: GarminClient, deviceId: Long, appId: String, errorMessage: String?)
|
||||
}
|
|
@ -0,0 +1,254 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.ArrayDeque
|
||||
import java.util.Queue
|
||||
|
||||
/**
|
||||
* Serialize and Deserialize objects in Garmin format.
|
||||
*
|
||||
* Format is as follows:
|
||||
* <STRS_MARKER><STRS_LEN><STRINGS><OBJS_MARKER><OBJS_LENGTH><OBJ><OBJ>...
|
||||
*
|
||||
* Serialized data starts with an optional string block. The string block is preceded with the STRS_MARKER,
|
||||
* followed by the total length of the reminder (4 bytes). Then foreach string, the string length
|
||||
* (2 bytes), followed by the string bytes, followed by a \0 byte.
|
||||
*
|
||||
* Objects are stored starting with OBJS_MARKER, followed by the total length (4 bytes), followed
|
||||
* by a flat list of objects. Each object starts with its type (1 byte), followed by the data
|
||||
* for numbers in Boolean. Strings a represented by an index into the string block. Arrays only have
|
||||
* the length, the actual objects will be in the list of objects. Similarly, maps only have the
|
||||
* length and the entries are represented by 2 objects (key + val) in the list of objects.
|
||||
*/
|
||||
object GarminSerializer {
|
||||
private const val NULL = 0
|
||||
private const val INT = 1
|
||||
private const val FLOAT = 2
|
||||
private const val STRING = 3
|
||||
private const val ARRAY = 5
|
||||
private const val BOOLEAN = 9
|
||||
private const val MAP = 11
|
||||
private const val LONG = 14
|
||||
private const val DOUBLE = 15
|
||||
private const val CHAR = 19
|
||||
|
||||
private const val STRINGS_MARKER = -1412584499
|
||||
private const val OBJECTS_MARKER = -629482886
|
||||
// ArrayDeque doesn't like null so we use this instead.
|
||||
private val NULL_MARKER = object {}
|
||||
|
||||
private interface Container {
|
||||
fun read(buf: ByteBuffer, strings: Map<Int, String>, container: Queue<Container>)
|
||||
}
|
||||
|
||||
private class ListContainer(
|
||||
val size: Int,
|
||||
val list: MutableList<Any?>
|
||||
) : Container {
|
||||
|
||||
override fun read(buf: ByteBuffer, strings: Map<Int, String>, container: Queue<Container>) {
|
||||
for (i in 0 until size) {
|
||||
list.add(readObject(buf, strings, container))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class MapContainer(
|
||||
val size: Int,
|
||||
val map: MutableMap<Any, Any?>
|
||||
) : Container {
|
||||
|
||||
override fun read(buf: ByteBuffer, strings: Map<Int, String>, container: Queue<Container>) {
|
||||
for (i in 0 until size) {
|
||||
val k = readObject(buf, strings, container)
|
||||
val v = readObject(buf, strings, container)
|
||||
map[k!!] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun serialize(obj: Any?): ByteArray {
|
||||
val strsOut = ByteArrayOutputStream()
|
||||
val strsDataOut = DataOutputStream(strsOut)
|
||||
val objsOut = ByteArrayOutputStream()
|
||||
val strings = mutableMapOf<String, Int>()
|
||||
val q = ArrayDeque<Any?>()
|
||||
|
||||
q.add(obj ?: NULL_MARKER)
|
||||
while (!q.isEmpty()) {
|
||||
serialize(q.poll(), strsDataOut, DataOutputStream(objsOut), strings, q)
|
||||
}
|
||||
|
||||
var bufLen = 8 + objsOut.size()
|
||||
if (strsOut.size() > 0) {
|
||||
bufLen += 8 + strsOut.size()
|
||||
}
|
||||
|
||||
val buf = ByteBuffer.allocate(bufLen)
|
||||
if (strsOut.size() > 0) {
|
||||
buf.putInt(STRINGS_MARKER)
|
||||
buf.putInt(strsOut.size())
|
||||
buf.put(strsOut.toByteArray(), 0, strsOut.size())
|
||||
}
|
||||
buf.putInt(OBJECTS_MARKER)
|
||||
buf.putInt(objsOut.size())
|
||||
buf.put(objsOut.toByteArray(), 0, objsOut.size())
|
||||
return buf.array()
|
||||
}
|
||||
|
||||
private fun serialize(
|
||||
obj: Any?,
|
||||
strOut: DataOutputStream,
|
||||
objOut: DataOutputStream,
|
||||
strings: MutableMap<String, Int>,
|
||||
q: Queue<Any?>
|
||||
) {
|
||||
when (obj) {
|
||||
NULL_MARKER -> objOut.writeByte(NULL)
|
||||
|
||||
is Int -> {
|
||||
objOut.writeByte(INT)
|
||||
objOut.writeInt(obj)
|
||||
}
|
||||
|
||||
is Float -> {
|
||||
objOut.writeByte(FLOAT)
|
||||
objOut.writeFloat(obj)
|
||||
}
|
||||
|
||||
is String -> {
|
||||
objOut.writeByte(STRING)
|
||||
val offset = strings[obj]
|
||||
if (offset == null) {
|
||||
strings[obj] = strOut.size()
|
||||
val bytes = obj.toByteArray(Charsets.UTF_8)
|
||||
strOut.writeShort(bytes.size + 1)
|
||||
strOut.write(bytes)
|
||||
strOut.write(0)
|
||||
}
|
||||
objOut.writeInt(strings[obj]!!)
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
objOut.writeByte(ARRAY)
|
||||
objOut.writeInt(obj.size)
|
||||
obj.forEach { o -> q.add(o ?: NULL_MARKER) }
|
||||
}
|
||||
|
||||
is Boolean -> {
|
||||
objOut.writeByte(BOOLEAN)
|
||||
objOut.writeByte(if (obj) 1 else 0)
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
objOut.writeByte(MAP)
|
||||
objOut.writeInt(obj.size)
|
||||
obj.entries.forEach { (k, v) ->
|
||||
q.add(k ?: NULL_MARKER); q.add(v ?: NULL_MARKER) }
|
||||
}
|
||||
|
||||
is Long -> {
|
||||
objOut.writeByte(LONG)
|
||||
objOut.writeLong(obj)
|
||||
}
|
||||
|
||||
is Double -> {
|
||||
objOut.writeByte(DOUBLE)
|
||||
objOut.writeDouble(obj)
|
||||
}
|
||||
|
||||
is Char -> {
|
||||
objOut.writeByte(CHAR)
|
||||
objOut.writeInt(obj.code)
|
||||
}
|
||||
|
||||
else ->
|
||||
throw IllegalArgumentException("Unsupported type ${obj?.javaClass} '$obj'")
|
||||
}
|
||||
}
|
||||
|
||||
fun deserialize(data: ByteArray): Any? {
|
||||
val buf = ByteBuffer.wrap(data)
|
||||
val marker1 = buf.getInt(0)
|
||||
val strings = if (marker1 == STRINGS_MARKER) {
|
||||
buf.int // swallow the marker
|
||||
readStrings(buf)
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
val marker2 = buf.int // swallow the marker
|
||||
if (marker2 != OBJECTS_MARKER) {
|
||||
throw IllegalArgumentException("expected data marker, got $marker2")
|
||||
}
|
||||
return readObjects(buf, strings)
|
||||
}
|
||||
|
||||
private fun readStrings(buf: ByteBuffer): Map<Int, String> {
|
||||
val strings = mutableMapOf<Int, String>()
|
||||
val strBufferLen = buf.int
|
||||
val offset = buf.position()
|
||||
while (buf.position() - offset < strBufferLen) {
|
||||
val pos = buf.position() - offset
|
||||
val strLen = buf.short.toInt() - 1 // ignore \0 byte
|
||||
val strBytes = ByteArray(strLen)
|
||||
buf.get(strBytes)
|
||||
strings[pos] = String(strBytes, Charsets.UTF_8)
|
||||
buf.get() // swallow \0 byte
|
||||
}
|
||||
return strings
|
||||
}
|
||||
|
||||
private fun readObjects(buf: ByteBuffer, strings: Map<Int, String>): Any? {
|
||||
val objBufferLen = buf.int
|
||||
if (objBufferLen > buf.remaining()) {
|
||||
throw IllegalArgumentException("expect $objBufferLen bytes got ${buf.remaining()}")
|
||||
}
|
||||
|
||||
val container = ArrayDeque<Container>()
|
||||
val r = readObject(buf, strings, container)
|
||||
while (container.isNotEmpty()) {
|
||||
container.pollFirst()?.read(buf, strings, container)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
private fun readObject(buf: ByteBuffer, strings: Map<Int, String>, q: Queue<Container>): Any? {
|
||||
when (buf.get().toInt()) {
|
||||
NULL -> return null
|
||||
INT -> return buf.int
|
||||
FLOAT -> return buf.float
|
||||
|
||||
STRING -> {
|
||||
val offset = buf.int
|
||||
return strings[offset]!!
|
||||
}
|
||||
|
||||
ARRAY -> {
|
||||
val arraySize = buf.int
|
||||
val array = mutableListOf<Any?>()
|
||||
// We will populate the array with arraySize objects from the object list later,
|
||||
// when we take the ListContainer from the queue.
|
||||
q.add(ListContainer(arraySize, array))
|
||||
return array
|
||||
}
|
||||
|
||||
BOOLEAN -> return buf.get() > 0
|
||||
|
||||
MAP -> {
|
||||
val mapSize = buf.int
|
||||
val map = mutableMapOf<Any, Any?>()
|
||||
q.add(MapContainer(mapSize, map))
|
||||
return map
|
||||
}
|
||||
|
||||
LONG -> return buf.long
|
||||
DOUBLE -> return buf.double
|
||||
CHAR -> return Char(buf.int)
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,181 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import app.aaps.core.interfaces.logging.AAPSLogger
|
||||
import app.aaps.core.interfaces.logging.LTag
|
||||
import com.garmin.android.connectiq.IQApp
|
||||
import com.garmin.android.connectiq.IQApp.IQAppStatus
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import org.jetbrains.annotations.VisibleForTesting
|
||||
import java.io.InputStream
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.net.SocketException
|
||||
import java.time.Duration
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
/** [GarminClient] that talks to the ConnectIQ simulator via HTTP.
|
||||
*
|
||||
* This is needed for Garmin device app development. */
|
||||
class GarminSimulatorClient(
|
||||
private val aapsLogger: AAPSLogger,
|
||||
private val receiver: GarminReceiver,
|
||||
var port: Int = 7381
|
||||
): Disposable, GarminClient {
|
||||
|
||||
override val name = "Sim"
|
||||
private val executor: ExecutorService = Executors.newCachedThreadPool()
|
||||
private val serverSocket = ServerSocket()
|
||||
private val connections: MutableList<Connection> = Collections.synchronizedList(mutableListOf())
|
||||
private var nextDeviceId = AtomicLong(1)
|
||||
@VisibleForTesting
|
||||
val iqApp = IQApp("SimApp", IQAppStatus.INSTALLED, "Simulator", 1)
|
||||
private val readyLock = ReentrantLock()
|
||||
private val readyCond = readyLock.newCondition()
|
||||
override val connectedDevices: List<GarminDevice> get() = connections.map { c -> c.device }
|
||||
|
||||
override fun registerForMessages(app: GarminApplication) {
|
||||
}
|
||||
|
||||
private inner class Connection(private val socket: Socket): Disposable {
|
||||
val device = GarminDevice(
|
||||
this@GarminSimulatorClient,
|
||||
nextDeviceId.getAndAdd(1L),
|
||||
"Sim@${socket.remoteSocketAddress}")
|
||||
|
||||
fun start() {
|
||||
executor.execute {
|
||||
try {
|
||||
run()
|
||||
} catch (e: Throwable) {
|
||||
aapsLogger.error(LTag.GARMIN, "$device failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun send(data: ByteArray) {
|
||||
if (socket.isConnected && !socket.isOutputShutdown) {
|
||||
aapsLogger.info(LTag.GARMIN, "sending ${data.size} bytes to $device")
|
||||
socket.outputStream.write(data)
|
||||
socket.outputStream.flush()
|
||||
} else {
|
||||
aapsLogger.warn(LTag.GARMIN, "socket closed, cannot send $device")
|
||||
}
|
||||
}
|
||||
|
||||
private fun run() {
|
||||
socket.soTimeout = 0
|
||||
socket.isInputShutdown
|
||||
while (!socket.isClosed && socket.isConnected) {
|
||||
try {
|
||||
val data = readAvailable(socket.inputStream) ?: break
|
||||
if (data.isNotEmpty()) {
|
||||
kotlin.runCatching {
|
||||
receiver.onReceiveMessage(this@GarminSimulatorClient, device.id, iqApp.applicationId, data)
|
||||
}
|
||||
}
|
||||
} catch (e: SocketException) {
|
||||
aapsLogger.warn(LTag.GARMIN, "socket read failed ${e.message}")
|
||||
break
|
||||
}
|
||||
}
|
||||
aapsLogger.info(LTag.GARMIN, "disconnect ${device.name}" )
|
||||
connections.remove(this)
|
||||
}
|
||||
|
||||
private fun readAvailable(input: InputStream): ByteArray? {
|
||||
val buffer = ByteArray(1 shl 14)
|
||||
aapsLogger.info(LTag.GARMIN, "$device reading")
|
||||
val len = input.read(buffer)
|
||||
aapsLogger.info(LTag.GARMIN, "$device read $len bytes")
|
||||
if (len < 0) {
|
||||
return null
|
||||
}
|
||||
val data = ByteArray(len)
|
||||
System.arraycopy(buffer, 0, data, 0, data.size)
|
||||
return data
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
aapsLogger.info(LTag.GARMIN, "close $device")
|
||||
|
||||
@Suppress("EmptyCatchBlock")
|
||||
try {
|
||||
socket.close()
|
||||
} catch (e: SocketException) {
|
||||
aapsLogger.warn(LTag.GARMIN, "closing socket failed ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun isDisposed() = socket.isClosed
|
||||
}
|
||||
|
||||
init {
|
||||
executor.execute {
|
||||
runCatching(::listen).exceptionOrNull()?.let { e->
|
||||
aapsLogger.error(LTag.GARMIN, "listen failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun listen() {
|
||||
val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1))
|
||||
aapsLogger.info(LTag.GARMIN, "bind to $ip:$port")
|
||||
serverSocket.bind(InetSocketAddress(ip, port))
|
||||
port = serverSocket.localPort
|
||||
receiver.onConnect(this@GarminSimulatorClient)
|
||||
while (!serverSocket.isClosed) {
|
||||
val s = serverSocket.accept()
|
||||
aapsLogger.info(LTag.GARMIN, "accept " + s.remoteSocketAddress)
|
||||
connections.add(Connection(s))
|
||||
connections.last().start()
|
||||
}
|
||||
receiver.onDisconnect(this@GarminSimulatorClient)
|
||||
}
|
||||
|
||||
/** Wait for the server to start listing to requests. */
|
||||
fun awaitReady(wait: Duration): Boolean {
|
||||
var waitNanos = wait.toNanos()
|
||||
readyLock.withLock {
|
||||
while (!serverSocket.isBound && waitNanos > 0L) {
|
||||
waitNanos = readyCond.awaitNanos(waitNanos)
|
||||
}
|
||||
}
|
||||
return serverSocket.isBound
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
connections.forEach { c -> c.dispose() }
|
||||
connections.clear()
|
||||
serverSocket.close()
|
||||
executor.awaitTermination(10, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
override fun isDisposed() = serverSocket.isClosed
|
||||
|
||||
private fun getConnection(device: GarminDevice): Connection? {
|
||||
return connections.firstOrNull { c -> c.device.id == device.id }
|
||||
}
|
||||
|
||||
override fun sendMessage(app: GarminApplication, data: ByteArray) {
|
||||
val c = getConnection(app.device) ?: return
|
||||
try {
|
||||
c.send(data)
|
||||
receiver.onSendMessage(this, app.device.id, app.id, null)
|
||||
} catch (e: SocketException) {
|
||||
val errorMessage = "sending failed '${e.message}'"
|
||||
receiver.onSendMessage(this, app.device.id, app.id, errorMessage)
|
||||
c.dispose()
|
||||
connections.remove(c)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString() = name
|
||||
}
|
|
@ -34,7 +34,7 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po
|
|||
|
||||
private val serverThread: Thread
|
||||
private val workerExecutor: Executor = Executors.newCachedThreadPool()
|
||||
private val endpoints: MutableMap<String, (SocketAddress, URI, String?) -> CharSequence> =
|
||||
private val endpoints: MutableMap<String, (SocketAddress, URI, String?) -> Pair<Int, CharSequence>> =
|
||||
ConcurrentHashMap()
|
||||
private var serverSocket: ServerSocket? = null
|
||||
private val readyLock = ReentrantLock()
|
||||
|
@ -76,7 +76,7 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po
|
|||
}
|
||||
|
||||
/** Register an endpoint (path) to handle requests. */
|
||||
fun registerEndpoint(path: String, endpoint: (SocketAddress, URI, String?) -> CharSequence) {
|
||||
fun registerEndpoint(path: String, endpoint: (SocketAddress, URI, String?) -> Pair<Int, CharSequence>) {
|
||||
aapsLogger.info(LTag.GARMIN, "Register: '$path'")
|
||||
endpoints[path] = endpoint
|
||||
}
|
||||
|
@ -127,8 +127,8 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po
|
|||
respond(HttpURLConnection.HTTP_NOT_FOUND, out)
|
||||
} else {
|
||||
try {
|
||||
val body = endpoint(s.remoteSocketAddress, uri, reqBody)
|
||||
respond(HttpURLConnection.HTTP_OK, body, "application/json", out)
|
||||
val (code, body) = endpoint(s.remoteSocketAddress, uri, reqBody)
|
||||
respond(code, body, "application/json", out)
|
||||
} catch (e: Exception) {
|
||||
aapsLogger.error(LTag.GARMIN, "endpoint " + uri.path + " failed", e)
|
||||
respond(HttpURLConnection.HTTP_INTERNAL_ERROR, out)
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import android.content.Context
|
||||
import app.aaps.shared.tests.TestBase
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
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.kotlin.any
|
||||
import org.mockito.kotlin.doAnswer
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.stub
|
||||
import org.mockito.kotlin.verify
|
||||
|
||||
class GarminMessengerTest: TestBase() {
|
||||
private val context = mock<Context>()
|
||||
|
||||
private var appId1 = "appId1"
|
||||
private val appId2 = "appId2"
|
||||
|
||||
private val apps = mapOf(appId1 to "$appId1-name", appId2 to "$appId2-name")
|
||||
private val outMessages = mutableListOf<Pair<GarminApplication, ByteArray>>()
|
||||
private val inMessages = mutableListOf<Pair<GarminApplication, Any>>()
|
||||
private var messenger = GarminMessenger(
|
||||
aapsLogger, context, apps, { app, msg -> inMessages.add(app to msg) },
|
||||
enableConnectIq = false, enableSimulator = false)
|
||||
private val client1 = mock<GarminClient>() {
|
||||
on { name } doReturn "Mock1"
|
||||
on { sendMessage(any(), any()) } doAnswer { a ->
|
||||
outMessages.add(a.getArgument<GarminApplication>(0) to a.getArgument(1))
|
||||
Unit
|
||||
}
|
||||
}
|
||||
private val client2 = mock<GarminClient>() {
|
||||
on { name } doReturn "Mock2"
|
||||
on { sendMessage(any(), any()) } doAnswer { a ->
|
||||
outMessages.add(a.getArgument<GarminApplication>(0) to a.getArgument(1))
|
||||
Unit
|
||||
}
|
||||
}
|
||||
private val device1 = GarminDevice(client1, 11L, "dev1-name")
|
||||
private val device2 = GarminDevice(client2, 12L, "dev2-name")
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
messenger.onConnect(client1)
|
||||
messenger.onConnect(client2)
|
||||
client1.stub {
|
||||
on { connectedDevices } doReturn listOf(device1)
|
||||
}
|
||||
client2.stub {
|
||||
on { connectedDevices } doReturn listOf(device2)
|
||||
}
|
||||
}
|
||||
@AfterEach
|
||||
fun cleanup() {
|
||||
messenger.dispose()
|
||||
verify(client1).dispose()
|
||||
verify(client2).dispose()
|
||||
assertTrue(messenger.isDisposed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onDisconnect() {
|
||||
messenger.onDisconnect(client1)
|
||||
val msg = "foo"
|
||||
messenger.sendMessage(msg)
|
||||
outMessages.forEach { (app, payload) ->
|
||||
assertEquals(client2, app.device.client)
|
||||
assertEquals(msg, GarminSerializer.deserialize(payload))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onReceiveMessage() {
|
||||
val data = GarminSerializer.serialize("foo")
|
||||
messenger.onReceiveMessage(client1, device1.id, appId1, data)
|
||||
val (app, payload) = inMessages.removeAt(0)
|
||||
assertEquals(appId1, app.id)
|
||||
assertEquals("foo", payload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessageDevice() {
|
||||
messenger.sendMessage(device1, "foo")
|
||||
assertEquals(2, outMessages.size)
|
||||
val msg1 = outMessages.first { (app, _) -> app.id == appId1 }.second
|
||||
val msg2 = outMessages.first { (app, _) -> app.id == appId2 }.second
|
||||
assertEquals("foo", GarminSerializer.deserialize(msg1))
|
||||
assertEquals("foo", GarminSerializer.deserialize(msg2))
|
||||
messenger.onSendMessage(client1, device1.id, appId1, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onSendMessageAll() {
|
||||
messenger.sendMessage(listOf("foo"))
|
||||
assertEquals(4, outMessages.size)
|
||||
val msg11 = outMessages.first { (app, _) -> app.device == device1 && app.id == appId1 }.second
|
||||
val msg12 = outMessages.first { (app, _) -> app.device == device1 && app.id == appId2 }.second
|
||||
val msg21 = outMessages.first { (app, _) -> app.device == device2 && app.id == appId1 }.second
|
||||
val msg22 = outMessages.first { (app, _) -> app.device == device2 && app.id == appId2 }.second
|
||||
assertEquals(listOf("foo"), GarminSerializer.deserialize(msg11))
|
||||
assertEquals(listOf("foo"), GarminSerializer.deserialize(msg12))
|
||||
assertEquals(listOf("foo"), GarminSerializer.deserialize(msg21))
|
||||
assertEquals(listOf("foo"), GarminSerializer.deserialize(msg22))
|
||||
messenger.onSendMessage(client1, device1.id, appId1, null)
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import android.content.Context
|
||||
import app.aaps.core.interfaces.db.GlucoseUnit
|
||||
import app.aaps.core.interfaces.resources.ResourceHelper
|
||||
import app.aaps.core.interfaces.rx.events.EventNewBG
|
||||
|
@ -11,9 +12,14 @@ import dagger.android.HasAndroidInjector
|
|||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.ArgumentMatchers.anyBoolean
|
||||
import org.mockito.ArgumentMatchers.anyInt
|
||||
import org.mockito.ArgumentMatchers.anyLong
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import org.mockito.Mock
|
||||
import org.mockito.Mockito.atMost
|
||||
import org.mockito.Mockito.mock
|
||||
|
@ -25,9 +31,12 @@ import org.mockito.kotlin.any
|
|||
import org.mockito.kotlin.atLeastOnce
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.whenever
|
||||
import java.net.ConnectException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketAddress
|
||||
import java.net.URI
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
@ -39,6 +48,7 @@ class GarminPluginTest: TestBase() {
|
|||
|
||||
@Mock private lateinit var rh: ResourceHelper
|
||||
@Mock private lateinit var sp: SP
|
||||
@Mock private lateinit var context: Context
|
||||
@Mock private lateinit var loopHub: LoopHub
|
||||
private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC"))
|
||||
|
||||
|
@ -49,9 +59,14 @@ class GarminPluginTest: TestBase() {
|
|||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
gp = GarminPlugin(injector, aapsLogger, rh, loopHub, rxBus, sp)
|
||||
gp = GarminPlugin(injector, aapsLogger, rh, context, loopHub, rxBus, sp)
|
||||
gp.clock = clock
|
||||
`when`(loopHub.currentProfileName).thenReturn("Default")
|
||||
`when`(sp.getBoolean(anyString(), anyBoolean())).thenAnswer { i -> i.arguments[1] }
|
||||
`when`(sp.getString(anyString(), anyString())).thenAnswer { i -> i.arguments[1] }
|
||||
`when`(sp.getInt(anyString(), anyInt())).thenAnswer { i -> i.arguments[1] }
|
||||
`when`(sp.getInt(eq("communication_http_port"), anyInt()))
|
||||
.thenReturn(28890)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
@ -86,6 +101,17 @@ class GarminPluginTest: TestBase() {
|
|||
sourceSensor = GlucoseValue.SourceSensor.RANDOM
|
||||
)
|
||||
|
||||
@Test
|
||||
fun testReceiveHeartRateMap() {
|
||||
val hr = createHeartRate(80)
|
||||
gp.receiveHeartRate(hr, false)
|
||||
verify(loopHub).storeHeartRate(
|
||||
Instant.ofEpochSecond(hr["hrStart"] as Long),
|
||||
Instant.ofEpochSecond(hr["hrEnd"] as Long),
|
||||
80,
|
||||
hr["device"] as String)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReceiveHeartRateUri() {
|
||||
val hr = createHeartRate(99)
|
||||
|
@ -129,6 +155,132 @@ class GarminPluginTest: TestBase() {
|
|||
verify(loopHub).getGlucoseValues(from, true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setupHttpServer_enabled() {
|
||||
`when`(sp.getBoolean("communication_http", false)).thenReturn(true)
|
||||
`when`(sp.getInt("communication_http_port", 28891)).thenReturn(28892)
|
||||
gp.setupHttpServer(Duration.ofSeconds(10))
|
||||
val reqUri = URI("http://127.0.0.1:28892/get")
|
||||
val resp = reqUri.toURL().openConnection() as HttpURLConnection
|
||||
assertEquals(200, resp.responseCode)
|
||||
|
||||
// Change port
|
||||
`when`(sp.getInt("communication_http_port", 28891)).thenReturn(28893)
|
||||
gp.setupHttpServer(Duration.ofSeconds(10))
|
||||
val reqUri2 = URI("http://127.0.0.1:28893/get")
|
||||
val resp2 = reqUri2.toURL().openConnection() as HttpURLConnection
|
||||
assertEquals(200, resp2.responseCode)
|
||||
|
||||
`when`(sp.getBoolean("communication_http", false)).thenReturn(false)
|
||||
gp.setupHttpServer(Duration.ofSeconds(10))
|
||||
assertThrows(ConnectException::class.java) {
|
||||
(reqUri2.toURL().openConnection() as HttpURLConnection).responseCode
|
||||
}
|
||||
gp.onStop()
|
||||
|
||||
verify(loopHub, times(2)).getGlucoseValues(anyObject(), eq(true))
|
||||
verify(loopHub, times(2)).insulinOnboard
|
||||
verify(loopHub, times(2)).temporaryBasal
|
||||
verify(loopHub, times(2)).isConnected
|
||||
verify(loopHub, times(2)).glucoseUnit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setupHttpServer_disabled() {
|
||||
gp.setupHttpServer(Duration.ofSeconds(10))
|
||||
val reqUri = URI("http://127.0.0.1:28890/get")
|
||||
assertThrows(ConnectException::class.java) {
|
||||
(reqUri.toURL().openConnection() as HttpURLConnection).responseCode
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestHandler_NoKey() {
|
||||
val uri = createUri(emptyMap())
|
||||
val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" }
|
||||
assertEquals(
|
||||
HttpURLConnection.HTTP_OK to "OK",
|
||||
handler(mock(SocketAddress::class.java), uri, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestHandler_KeyProvided() {
|
||||
val uri = createUri(mapOf("key" to "foo"))
|
||||
val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" }
|
||||
assertEquals(
|
||||
HttpURLConnection.HTTP_OK to "OK",
|
||||
handler(mock(SocketAddress::class.java), uri, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestHandler_KeyRequiredAndProvided() {
|
||||
`when`(sp.getString("garmin_aaps_key", "")).thenReturn("foo")
|
||||
val uri = createUri(mapOf("key" to "foo"))
|
||||
val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" }
|
||||
assertEquals(
|
||||
HttpURLConnection.HTTP_OK to "OK",
|
||||
handler(mock(SocketAddress::class.java), uri, null))
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestHandler_KeyRequired() {
|
||||
gp.garminMessenger = mock(GarminMessenger::class.java)
|
||||
|
||||
`when`(sp.getString("garmin_aaps_key", "")).thenReturn("foo")
|
||||
val uri = createUri(emptyMap())
|
||||
val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" }
|
||||
assertEquals(
|
||||
HttpURLConnection.HTTP_UNAUTHORIZED to "{}",
|
||||
handler(mock(SocketAddress::class.java), uri, null))
|
||||
|
||||
val captor = ArgumentCaptor.forClass(Any::class.java)
|
||||
verify(gp.garminMessenger)!!.sendMessage(captor.capture() ?: "")
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val r = captor.value as Map<String, Any>
|
||||
assertEquals("foo", r["key"])
|
||||
assertEquals("glucose", r["command"])
|
||||
assertEquals("D", r["profile"])
|
||||
assertEquals("", r["encodedGlucose"])
|
||||
assertEquals(0.0, r["remainingInsulin"])
|
||||
assertEquals("mmoll", r["glucoseUnit"])
|
||||
assertEquals(0.0, r["temporaryBasalRate"])
|
||||
assertEquals(false, r["connected"])
|
||||
assertEquals(clock.instant().epochSecond, r["timestamp"])
|
||||
verify(loopHub).getGlucoseValues(getGlucoseValuesFrom, true)
|
||||
verify(loopHub).insulinOnboard
|
||||
verify(loopHub).temporaryBasal
|
||||
verify(loopHub).isConnected
|
||||
verify(loopHub).glucoseUnit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onConnectDevice() {
|
||||
gp.garminMessenger = mock(GarminMessenger::class.java)
|
||||
`when`(sp.getString("garmin_aaps_key", "")).thenReturn("foo")
|
||||
val device = GarminDevice(mock(),1, "Edge")
|
||||
gp.onConnectDevice(device)
|
||||
|
||||
val captor = ArgumentCaptor.forClass(Any::class.java)
|
||||
verify(gp.garminMessenger)!!.sendMessage(eq(device), captor.capture() ?: "")
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val r = captor.value as Map<String, Any>
|
||||
assertEquals("foo", r["key"])
|
||||
assertEquals("glucose", r["command"])
|
||||
assertEquals("D", r["profile"])
|
||||
assertEquals("", r["encodedGlucose"])
|
||||
assertEquals(0.0, r["remainingInsulin"])
|
||||
assertEquals("mmoll", r["glucoseUnit"])
|
||||
assertEquals(0.0, r["temporaryBasalRate"])
|
||||
assertEquals(false, r["connected"])
|
||||
assertEquals(clock.instant().epochSecond, r["timestamp"])
|
||||
verify(loopHub).getGlucoseValues(getGlucoseValuesFrom, true)
|
||||
verify(loopHub).insulinOnboard
|
||||
verify(loopHub).temporaryBasal
|
||||
verify(loopHub).isConnected
|
||||
verify(loopHub).glucoseUnit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnGetBloodGlucose() {
|
||||
`when`(loopHub.isConnected).thenReturn(true)
|
||||
|
@ -139,7 +291,7 @@ class GarminPluginTest: TestBase() {
|
|||
listOf(createGlucoseValue(Instant.ofEpochSecond(1_000))))
|
||||
val hr = createHeartRate(99)
|
||||
val uri = createUri(hr)
|
||||
val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null)
|
||||
val result = gp.onGetBloodGlucose(uri)
|
||||
assertEquals(
|
||||
"{\"encodedGlucose\":\"0A+6AQ==\"," +
|
||||
"\"remainingInsulin\":3.14," +
|
||||
|
@ -171,7 +323,7 @@ class GarminPluginTest: TestBase() {
|
|||
params["wait"] = 10
|
||||
val uri = createUri(params)
|
||||
gp.newValue = mock(Condition::class.java)
|
||||
val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null)
|
||||
val result = gp.onGetBloodGlucose(uri)
|
||||
assertEquals(
|
||||
"{\"encodedGlucose\":\"/wS6AQ==\"," +
|
||||
"\"remainingInsulin\":3.14," +
|
||||
|
@ -194,7 +346,7 @@ class GarminPluginTest: TestBase() {
|
|||
@Test
|
||||
fun testOnPostCarbs() {
|
||||
val uri = createUri(mapOf("carbs" to "12"))
|
||||
assertEquals("", gp.onPostCarbs(mock(SocketAddress::class.java), uri, null))
|
||||
assertEquals("", gp.onPostCarbs(uri))
|
||||
verify(loopHub).postCarbs(12)
|
||||
}
|
||||
|
||||
|
@ -202,7 +354,7 @@ class GarminPluginTest: TestBase() {
|
|||
fun testOnConnectPump_Disconnect() {
|
||||
val uri = createUri(mapOf("disconnectMinutes" to "20"))
|
||||
`when`(loopHub.isConnected).thenReturn(false)
|
||||
assertEquals("{\"connected\":false}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null))
|
||||
assertEquals("{\"connected\":false}", gp.onConnectPump(uri))
|
||||
verify(loopHub).disconnectPump(20)
|
||||
verify(loopHub).isConnected
|
||||
}
|
||||
|
@ -211,7 +363,7 @@ class GarminPluginTest: TestBase() {
|
|||
fun testOnConnectPump_Connect() {
|
||||
val uri = createUri(mapOf("disconnectMinutes" to "0"))
|
||||
`when`(loopHub.isConnected).thenReturn(true)
|
||||
assertEquals("{\"connected\":true}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null))
|
||||
assertEquals("{\"connected\":true}", gp.onConnectPump(uri))
|
||||
verify(loopHub).connectPump()
|
||||
verify(loopHub).isConnected
|
||||
}
|
||||
|
@ -220,7 +372,7 @@ class GarminPluginTest: TestBase() {
|
|||
fun onSgv_NoGlucose() {
|
||||
whenever(loopHub.glucoseUnit).thenReturn(GlucoseUnit.MMOL)
|
||||
whenever(loopHub.getGlucoseValues(any(), eq(false))).thenReturn(emptyList())
|
||||
assertEquals("[]", gp.onSgv(mock(), createUri(mapOf()), null))
|
||||
assertEquals("[]", gp.onSgv(createUri(mapOf())))
|
||||
verify(loopHub).getGlucoseValues(clock.instant().minusSeconds(25L * 300L), false)
|
||||
}
|
||||
|
||||
|
@ -236,7 +388,7 @@ fun onSgv_NoDelta() {
|
|||
clock.instant().minusSeconds(100L), 99.3)))
|
||||
assertEquals(
|
||||
"""[{"_id":"-900000","device":"RANDOM","deviceString":"1969-12-31T23:58:30Z","sysTime":"1969-12-31T23:58:30Z","unfiltered":90.0,"date":-90000,"sgv":99,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7}]""",
|
||||
gp.onSgv(mock(), createUri(mapOf()), null))
|
||||
gp.onSgv(createUri(mapOf())))
|
||||
verify(loopHub).getGlucoseValues(clock.instant().minusSeconds(25L * 300L), false)
|
||||
verify(loopHub).glucoseUnit
|
||||
}
|
||||
|
@ -255,7 +407,7 @@ fun onSgv_NoDelta() {
|
|||
.mapIndexed { idx, ts -> createGlucoseValue(ts, 100.0+(10 * idx)) }.reversed()}
|
||||
assertEquals(
|
||||
"""[{"_id":"100000","device":"RANDOM","deviceString":"1970-01-01T00:00:10Z","sysTime":"1970-01-01T00:00:10Z","unfiltered":90.0,"date":10000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7}]""",
|
||||
gp.onSgv(mock(), createUri(mapOf("count" to "1")), null))
|
||||
gp.onSgv(createUri(mapOf("count" to "1"))))
|
||||
verify(loopHub).getGlucoseValues(
|
||||
clock.instant().minusSeconds(600L), false)
|
||||
|
||||
|
@ -263,14 +415,14 @@ fun onSgv_NoDelta() {
|
|||
assertEquals(
|
||||
"""[{"_id":"100000","device":"RANDOM","deviceString":"1970-01-01T00:00:10Z","sysTime":"1970-01-01T00:00:10Z","unfiltered":90.0,"date":10000,"sgv":130,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7},""" +
|
||||
"""{"_id":"-2900000","device":"RANDOM","deviceString":"1969-12-31T23:55:10Z","sysTime":"1969-12-31T23:55:10Z","unfiltered":90.0,"date":-290000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5}]""",
|
||||
gp.onSgv(mock(), createUri(mapOf("count" to "2")), null))
|
||||
gp.onSgv(createUri(mapOf("count" to "2"))))
|
||||
verify(loopHub).getGlucoseValues(
|
||||
clock.instant().minusSeconds(900L), false)
|
||||
|
||||
assertEquals(
|
||||
"""[{"date":10000,"sgv":130,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7},""" +
|
||||
"""{"date":-290000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5}]""",
|
||||
gp.onSgv(mock(), createUri(mapOf("count" to "2", "brief_mode" to "true")), null))
|
||||
gp.onSgv(createUri(mapOf("count" to "2", "brief_mode" to "true"))))
|
||||
verify(loopHub, times(2)).getGlucoseValues(
|
||||
clock.instant().minusSeconds(900L), false)
|
||||
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
|
||||
class GarminSerializerTest {
|
||||
|
||||
@Test fun testSerializeDeserializeString() {
|
||||
val o = "Hello, world!"
|
||||
val data = GarminSerializer.serialize(o)
|
||||
assertContentEquals(
|
||||
byteArrayOf(
|
||||
-85, -51, -85, -51, 0, 0, 0, 16, 0, 14, 72, 101, 108, 108, 111, 44, 32, 119, 111,
|
||||
114, 108, 100, 33, 0, -38, 122, -38, 122, 0, 0, 0, 5, 3, 0,0, 0, 0),
|
||||
data)
|
||||
assertEquals(o, GarminSerializer.deserialize(data))
|
||||
}
|
||||
|
||||
@Test fun testSerializeDeserializeInteger() {
|
||||
val o = 3
|
||||
val data = GarminSerializer.serialize(o)
|
||||
assertContentEquals(
|
||||
byteArrayOf(-38, 122, -38, 122, 0, 0, 0, 5, 1, 0, 0, 0, 3),
|
||||
data)
|
||||
assertEquals(o, GarminSerializer.deserialize(data))
|
||||
}
|
||||
|
||||
@Test fun tesSerializeDeserializeArray() {
|
||||
val o = listOf("a", "b", true, 3, 3.4F, listOf(5L, 9), 42)
|
||||
val data = GarminSerializer.serialize(o)
|
||||
assertContentEquals(
|
||||
byteArrayOf(
|
||||
-85, -51, -85, -51, 0, 0, 0, 8, 0, 2, 97, 0, 0, 2, 98, 0, -38, 122, -38, 122, 0, 0,
|
||||
0, 55, 5, 0, 0, 0, 7, 3, 0, 0, 0, 0, 3, 0, 0, 0, 4, 9, 1, 1, 0, 0, 0, 3, 2, 64, 89,
|
||||
-103, -102, 5, 0, 0, 0, 2, 1, 0, 0, 0, 42, 14, 0, 0, 0, 0, 0, 0, 0, 5, 14, 0, 0, 0,
|
||||
0, 0, 0, 0, 9),
|
||||
data)
|
||||
assertEquals(o, GarminSerializer.deserialize(data))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSerializeDeserializeMap() {
|
||||
val o = mapOf("a" to "abc", "c" to 3, "d" to listOf(4, 9, "abc"), true to null)
|
||||
val data = GarminSerializer.serialize(o)
|
||||
assertContentEquals(
|
||||
byteArrayOf(
|
||||
-85, -51, -85, -51, 0, 0, 0, 18, 0, 2, 97, 0, 0, 4, 97, 98, 99, 0, 0, 2, 99, 0, 0,
|
||||
2, 100, 0, -38, 122, -38, 122, 0, 0, 0, 53, 11, 0, 0, 0, 4, 3, 0, 0, 0, 0, 3, 0, 0,
|
||||
0, 4, 3, 0, 0, 0, 10, 1, 0, 0, 0, 3, 3, 0, 0, 0, 14, 5, 0, 0, 0, 3, 9, 1, 0, 1, 0, 0,
|
||||
0, 4, 1, 0, 0, 0, 9, 3, 0, 0, 0, 4),
|
||||
data)
|
||||
assertEquals(o, GarminSerializer.deserialize(data))
|
||||
}
|
||||
|
||||
@Test fun testSerializeDeserializeNull() {
|
||||
val o = null
|
||||
val data = GarminSerializer.serialize(o)
|
||||
assertContentEquals(
|
||||
byteArrayOf(-38, 122, -38, 122, 0, 0, 0, 1, 0),
|
||||
data)
|
||||
assertEquals(o, GarminSerializer.deserialize(data))
|
||||
assertEquals(o, GarminSerializer.deserialize(data))
|
||||
}
|
||||
|
||||
@Test fun testSerializeDeserializeAllPrimitiveTypes() {
|
||||
val o = listOf(1, 1.2F, 1.3, "A", true, 2L, 'X', null)
|
||||
val data = GarminSerializer.serialize(o)
|
||||
assertContentEquals(
|
||||
byteArrayOf(
|
||||
-85, -51, -85, -51, 0, 0, 0, 4, 0, 2, 65, 0, -38, 122, -38, 122, 0, 0, 0, 46, 5, 0,
|
||||
0, 0, 8, 1, 0, 0, 0, 1, 2, 63, -103, -103, -102, 15, 63, -12, -52, -52, -52, -52,
|
||||
-52, -51, 3, 0, 0, 0, 0, 9, 1, 14, 0, 0, 0, 0, 0, 0, 0, 2, 19, 0, 0, 0, 88, 0),
|
||||
data)
|
||||
assertEquals(o, GarminSerializer.deserialize(data))
|
||||
assertEquals(o, GarminSerializer.deserialize(data))
|
||||
}
|
||||
|
||||
@Test fun testSerializeDeserializeMapNested() {
|
||||
val o = mapOf("a" to "abc", "c" to 3, "d" to listOf(4, 9, "abc"))
|
||||
val data = GarminSerializer.serialize(o)
|
||||
assertContentEquals(
|
||||
byteArrayOf(
|
||||
-85, -51, -85, -51, 0, 0, 0, 18, 0, 2, 97, 0, 0, 4, 97, 98, 99, 0, 0, 2, 99, 0, 0,
|
||||
2, 100, 0, -38, 122, -38, 122, 0, 0, 0, 50, 11, 0, 0, 0, 3, 3, 0, 0, 0, 0, 3, 0, 0,
|
||||
0, 4, 3, 0, 0, 0, 10, 1, 0, 0, 0, 3, 3, 0, 0, 0, 14, 5, 0, 0, 0, 3, 1, 0, 0, 0, 4,
|
||||
1, 0, 0, 0, 9, 3, 0, 0, 0, 4),
|
||||
data)
|
||||
assertEquals(o, GarminSerializer.deserialize(data))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
package app.aaps.plugins.sync.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.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.isNull
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.timeout
|
||||
import org.mockito.kotlin.verify
|
||||
import java.net.Inet4Address
|
||||
import java.net.Socket
|
||||
import java.time.Duration
|
||||
|
||||
class GarminSimulatorClientTest: TestBase() {
|
||||
|
||||
private lateinit var client: GarminSimulatorClient
|
||||
private val receiver: GarminReceiver = mock()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
client = GarminSimulatorClient(aapsLogger, receiver, 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun receiveMessage() {
|
||||
val payload = "foo".toByteArray()
|
||||
assertTrue(client.awaitReady(Duration.ofSeconds(10)))
|
||||
val port = client.port
|
||||
val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1))
|
||||
Socket(ip, port).use { socket ->
|
||||
assertTrue(socket.isConnected)
|
||||
socket.getOutputStream().write(payload)
|
||||
socket.getOutputStream().flush()
|
||||
verify(receiver).onConnect(client)
|
||||
}
|
||||
assertEquals(1, client.connectedDevices.size)
|
||||
val device: GarminDevice = client.connectedDevices.first()
|
||||
verify(receiver, timeout(1_000))
|
||||
.onReceiveMessage(eq(client), eq(device.id), eq("SIMAPP"), eq(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessage() {
|
||||
val payload = "foo".toByteArray()
|
||||
assertTrue(client.awaitReady(Duration.ofSeconds(10)))
|
||||
val port = client.port
|
||||
val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1))
|
||||
val device: GarminDevice
|
||||
val app: GarminApplication
|
||||
Socket(ip, port).use { socket ->
|
||||
assertTrue(socket.isConnected)
|
||||
verify(receiver).onConnect(client)
|
||||
assertEquals(1, client.connectedDevices.size)
|
||||
device = client.connectedDevices.first()
|
||||
app = GarminApplication(device, "SIMAPP", "T")
|
||||
client.sendMessage(app, payload)
|
||||
}
|
||||
verify(receiver, timeout(1_000)).onSendMessage(eq(client), eq(device.id), eq(app.id), isNull())
|
||||
}
|
||||
}
|
|
@ -77,7 +77,7 @@ internal class HttpServerTest: TestBase() {
|
|||
HttpServer(aapsLogger, port).use { server ->
|
||||
server.registerEndpoint("/foo") { _: SocketAddress, uri: URI, _: String? ->
|
||||
assertEquals(URI("/foo"), uri)
|
||||
"test"
|
||||
HttpURLConnection.HTTP_OK to "test"
|
||||
}
|
||||
assertTrue(server.awaitReady(Duration.ofSeconds(10)))
|
||||
val resp = reqUri.toURL().openConnection() as HttpURLConnection
|
||||
|
|
Loading…
Reference in a new issue