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
|
@ -152,8 +152,11 @@ object Libs {
|
||||||
}
|
}
|
||||||
|
|
||||||
object Mockito {
|
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"
|
const val kotlin = "org.mockito.kotlin:mockito-kotlin:5.1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,9 @@ dependencies {
|
||||||
androidTestImplementation(Libs.AndroidX.Test.rules)
|
androidTestImplementation(Libs.AndroidX.Test.rules)
|
||||||
androidTestImplementation(Libs.Google.truth)
|
androidTestImplementation(Libs.Google.truth)
|
||||||
androidTestImplementation(Libs.AndroidX.Test.uiAutomator)
|
androidTestImplementation(Libs.AndroidX.Test.uiAutomator)
|
||||||
|
androidTestImplementation(Libs.Mockito.core)
|
||||||
|
androidTestImplementation(Libs.Mockito.android)
|
||||||
|
androidTestImplementation(Libs.Mockito.kotlin)
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<Test> {
|
tasks.withType<Test> {
|
||||||
|
|
|
@ -30,6 +30,7 @@ dependencies {
|
||||||
testImplementation(project(":shared:tests"))
|
testImplementation(project(":shared:tests"))
|
||||||
testImplementation(project(":implementation"))
|
testImplementation(project(":implementation"))
|
||||||
testImplementation(project(":plugins:aps"))
|
testImplementation(project(":plugins:aps"))
|
||||||
|
androidTestImplementation(project(":shared:tests"))
|
||||||
|
|
||||||
// OpenHuman
|
// OpenHuman
|
||||||
api(Libs.Squareup.Okhttp3.okhttp)
|
api(Libs.Squareup.Okhttp3.okhttp)
|
||||||
|
@ -49,6 +50,10 @@ dependencies {
|
||||||
// DataLayerListenerService
|
// DataLayerListenerService
|
||||||
api(Libs.Google.Android.PlayServices.wearable)
|
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.compiler)
|
||||||
kapt(Libs.Dagger.androidProcessor)
|
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
|
package app.aaps.plugins.sync.garmin
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import app.aaps.core.interfaces.db.GlucoseUnit
|
import app.aaps.core.interfaces.db.GlucoseUnit
|
||||||
import app.aaps.core.interfaces.logging.AAPSLogger
|
import app.aaps.core.interfaces.logging.AAPSLogger
|
||||||
|
@ -22,6 +23,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.MathContext
|
import java.math.MathContext
|
||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
|
import java.net.HttpURLConnection
|
||||||
import java.net.SocketAddress
|
import java.net.SocketAddress
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
|
@ -46,6 +48,7 @@ class GarminPlugin @Inject constructor(
|
||||||
injector: HasAndroidInjector,
|
injector: HasAndroidInjector,
|
||||||
aapsLogger: AAPSLogger,
|
aapsLogger: AAPSLogger,
|
||||||
resourceHelper: ResourceHelper,
|
resourceHelper: ResourceHelper,
|
||||||
|
private val context: Context,
|
||||||
private val loopHub: LoopHub,
|
private val loopHub: LoopHub,
|
||||||
private val rxBus: RxBus,
|
private val rxBus: RxBus,
|
||||||
private val sp: SP,
|
private val sp: SP,
|
||||||
|
@ -61,7 +64,19 @@ class GarminPlugin @Inject constructor(
|
||||||
) {
|
) {
|
||||||
/** HTTP Server for local HTTP server communication (device app requests values) .*/
|
/** HTTP Server for local HTTP server communication (device app requests values) .*/
|
||||||
private var server: HttpServer? = null
|
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()
|
private val disposable = CompositeDisposable()
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
|
@ -72,10 +87,24 @@ class GarminPlugin @Inject constructor(
|
||||||
var newValue: Condition = valueLock.newCondition()
|
var newValue: Condition = valueLock.newCondition()
|
||||||
private var lastGlucoseValueTimestamp: Long? = null
|
private var lastGlucoseValueTimestamp: Long? = null
|
||||||
private val glucoseUnitStr get() = if (loopHub.glucoseUnit == GlucoseUnit.MGDL) "mgdl" else "mmoll"
|
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) {
|
private fun onPreferenceChange(event: EventPreferenceChange) {
|
||||||
aapsLogger.info(LTag.GARMIN, "preferences change ${event.changedKey}")
|
when (event.changedKey) {
|
||||||
setupHttpServer()
|
"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() {
|
override fun onStart() {
|
||||||
|
@ -87,20 +116,34 @@ class GarminPlugin @Inject constructor(
|
||||||
.observeOn(Schedulers.io())
|
.observeOn(Schedulers.io())
|
||||||
.subscribe(::onPreferenceChange)
|
.subscribe(::onPreferenceChange)
|
||||||
)
|
)
|
||||||
|
disposable.add(
|
||||||
|
rxBus
|
||||||
|
.toObservable(EventNewBG::class.java)
|
||||||
|
.observeOn(Schedulers.io())
|
||||||
|
.subscribe(::onNewBloodGlucose)
|
||||||
|
)
|
||||||
setupHttpServer()
|
setupHttpServer()
|
||||||
|
if (garminAapsKey.isNotEmpty())
|
||||||
|
setupGarminMessenger()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupHttpServer() {
|
private fun setupHttpServer() {
|
||||||
|
setupHttpServer(Duration.ZERO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun setupHttpServer(wait: Duration) {
|
||||||
if (sp.getBoolean("communication_http", false)) {
|
if (sp.getBoolean("communication_http", false)) {
|
||||||
val port = sp.getInt("communication_http_port", 28891)
|
val port = sp.getInt("communication_http_port", 28891)
|
||||||
if (server != null && server?.port == port) return
|
if (server != null && server?.port == port) return
|
||||||
aapsLogger.info(LTag.GARMIN, "starting HTTP server on $port")
|
aapsLogger.info(LTag.GARMIN, "starting HTTP server on $port")
|
||||||
server?.close()
|
server?.close()
|
||||||
server = HttpServer(aapsLogger, port).apply {
|
server = HttpServer(aapsLogger, port).apply {
|
||||||
registerEndpoint("/get", ::onGetBloodGlucose)
|
registerEndpoint("/get", requestHandler(::onGetBloodGlucose))
|
||||||
registerEndpoint("/carbs", ::onPostCarbs)
|
registerEndpoint("/carbs", requestHandler(::onPostCarbs))
|
||||||
registerEndpoint("/connect", ::onConnectPump)
|
registerEndpoint("/connect", requestHandler(::onConnectPump))
|
||||||
registerEndpoint("/sgv.json", ::onSgv)
|
registerEndpoint("/connect", requestHandler(::onSgv))
|
||||||
|
awaitReady(wait)
|
||||||
}
|
}
|
||||||
} else if (server != null) {
|
} else if (server != null) {
|
||||||
aapsLogger.info(LTag.GARMIN, "stopping HTTP server")
|
aapsLogger.info(LTag.GARMIN, "stopping HTTP server")
|
||||||
|
@ -109,7 +152,7 @@ class GarminPlugin @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
public override fun onStop() {
|
||||||
disposable.clear()
|
disposable.clear()
|
||||||
aapsLogger.info(LTag.GARMIN, "Stop")
|
aapsLogger.info(LTag.GARMIN, "Stop")
|
||||||
server?.close()
|
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. */
|
/** Gets the last 2+ hours of glucose values. */
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
fun getGlucoseValues(): List<GlucoseValue> {
|
fun getGlucoseValues(): List<GlucoseValue> {
|
||||||
|
@ -166,21 +239,33 @@ class GarminPlugin @Inject constructor(
|
||||||
val glucoseMgDl: Int = glucose.value.roundToInt()
|
val glucoseMgDl: Int = glucose.value.roundToInt()
|
||||||
encodedGlucose.add(timeSec, glucoseMgDl)
|
encodedGlucose.add(timeSec, glucoseMgDl)
|
||||||
}
|
}
|
||||||
aapsLogger.info(
|
|
||||||
LTag.GARMIN,
|
|
||||||
"retrieved ${glucoseValues.size} last ${Date(glucoseValues.lastOrNull()?.timestamp ?: 0L)} ${encodedGlucose.size}"
|
|
||||||
)
|
|
||||||
return encodedGlucose.encodedBase64()
|
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.
|
/** Responses to get glucose value request by the device.
|
||||||
*
|
*
|
||||||
* Also, gets the heart rate readings from the device.
|
* Also, gets the heart rate readings from the device.
|
||||||
*/
|
*/
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
@Suppress("UNUSED_PARAMETER")
|
fun onGetBloodGlucose(uri: URI): CharSequence {
|
||||||
fun onGetBloodGlucose(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
|
|
||||||
aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri")
|
|
||||||
receiveHeartRate(uri)
|
receiveHeartRate(uri)
|
||||||
val profileName = loopHub.currentProfileName
|
val profileName = loopHub.currentProfileName
|
||||||
val waitSec = getQueryParameter(uri, "wait", 0L)
|
val waitSec = getQueryParameter(uri, "wait", 0L)
|
||||||
|
@ -194,9 +279,7 @@ class GarminPlugin @Inject constructor(
|
||||||
}
|
}
|
||||||
jo.addProperty("profile", profileName.first().toString())
|
jo.addProperty("profile", profileName.first().toString())
|
||||||
jo.addProperty("connected", loopHub.isConnected)
|
jo.addProperty("connected", loopHub.isConnected)
|
||||||
return jo.toString().also {
|
return jo.toString()
|
||||||
aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri, result: $it")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getQueryParameter(uri: URI, name: String) = (uri.query ?: "")
|
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
|
@VisibleForTesting
|
||||||
fun receiveHeartRate(uri: URI) {
|
fun receiveHeartRate(uri: URI) {
|
||||||
val avg: Int = getQueryParameter(uri, "hr", 0L).toInt()
|
val avg: Int = getQueryParameter(uri, "hr", 0L).toInt()
|
||||||
|
@ -242,20 +338,18 @@ class GarminPlugin @Inject constructor(
|
||||||
private fun receiveHeartRate(
|
private fun receiveHeartRate(
|
||||||
samplingStart: Instant, samplingEnd: Instant,
|
samplingStart: Instant, samplingEnd: Instant,
|
||||||
avg: Int, device: String?, test: Boolean) {
|
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 (test) return
|
||||||
if (avg > 10 && samplingStart > Instant.ofEpochMilli(0L) && samplingEnd > samplingStart) {
|
if (avg > 10 && samplingStart > Instant.ofEpochMilli(0L) && samplingEnd > samplingStart) {
|
||||||
loopHub.storeHeartRate(samplingStart, samplingEnd, avg, device)
|
loopHub.storeHeartRate(samplingStart, samplingEnd, avg, device)
|
||||||
} else {
|
} else if (avg > 0) {
|
||||||
aapsLogger.warn(LTag.GARMIN, "Skip saving invalid HR $avg $samplingStart..$samplingEnd")
|
aapsLogger.warn(LTag.GARMIN, "Skip saving invalid HR $avg $samplingStart..$samplingEnd")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handles carb notification from the device. */
|
/** Handles carb notification from the device. */
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
@Suppress("UNUSED_PARAMETER")
|
fun onPostCarbs(uri: URI): CharSequence {
|
||||||
fun onPostCarbs(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
|
|
||||||
aapsLogger.info(LTag.GARMIN, "carbs from $caller, req: $uri")
|
|
||||||
postCarbs(getQueryParameter(uri, "carbs", 0L).toInt())
|
postCarbs(getQueryParameter(uri, "carbs", 0L).toInt())
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
@ -268,9 +362,7 @@ class GarminPlugin @Inject constructor(
|
||||||
|
|
||||||
/** Handles pump connected notification that the user entered on the Garmin device. */
|
/** Handles pump connected notification that the user entered on the Garmin device. */
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
@Suppress("UNUSED_PARAMETER")
|
fun onConnectPump(uri: URI): CharSequence {
|
||||||
fun onConnectPump(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
|
|
||||||
aapsLogger.info(LTag.GARMIN, "connect from $caller, req: $uri")
|
|
||||||
val minutes = getQueryParameter(uri, "disconnectMinutes", 0L).toInt()
|
val minutes = getQueryParameter(uri, "disconnectMinutes", 0L).toInt()
|
||||||
if (minutes > 0) {
|
if (minutes > 0) {
|
||||||
loopHub.disconnectPump(minutes)
|
loopHub.disconnectPump(minutes)
|
||||||
|
@ -289,8 +381,7 @@ class GarminPlugin @Inject constructor(
|
||||||
|
|
||||||
/** Returns glucose values in Nightscout/Xdrip format. */
|
/** Returns glucose values in Nightscout/Xdrip format. */
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
@Suppress("UNUSED_PARAMETER")
|
fun onSgv(uri: URI): CharSequence {
|
||||||
fun onSgv(call: SocketAddress, uri: URI, requestBody: String?): CharSequence {
|
|
||||||
val count = getQueryParameter(uri,"count", 24L)
|
val count = getQueryParameter(uri,"count", 24L)
|
||||||
.toInt().coerceAtMost(1000).coerceAtLeast(1)
|
.toInt().coerceAtMost(1000).coerceAtLeast(1)
|
||||||
val briefMode = getQueryParameter(uri, "brief_mode", false)
|
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 serverThread: Thread
|
||||||
private val workerExecutor: Executor = Executors.newCachedThreadPool()
|
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()
|
ConcurrentHashMap()
|
||||||
private var serverSocket: ServerSocket? = null
|
private var serverSocket: ServerSocket? = null
|
||||||
private val readyLock = ReentrantLock()
|
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. */
|
/** 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'")
|
aapsLogger.info(LTag.GARMIN, "Register: '$path'")
|
||||||
endpoints[path] = endpoint
|
endpoints[path] = endpoint
|
||||||
}
|
}
|
||||||
|
@ -127,8 +127,8 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po
|
||||||
respond(HttpURLConnection.HTTP_NOT_FOUND, out)
|
respond(HttpURLConnection.HTTP_NOT_FOUND, out)
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
val body = endpoint(s.remoteSocketAddress, uri, reqBody)
|
val (code, body) = endpoint(s.remoteSocketAddress, uri, reqBody)
|
||||||
respond(HttpURLConnection.HTTP_OK, body, "application/json", out)
|
respond(code, body, "application/json", out)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
aapsLogger.error(LTag.GARMIN, "endpoint " + uri.path + " failed", e)
|
aapsLogger.error(LTag.GARMIN, "endpoint " + uri.path + " failed", e)
|
||||||
respond(HttpURLConnection.HTTP_INTERNAL_ERROR, out)
|
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
|
package app.aaps.plugins.sync.garmin
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import app.aaps.core.interfaces.db.GlucoseUnit
|
import app.aaps.core.interfaces.db.GlucoseUnit
|
||||||
import app.aaps.core.interfaces.resources.ResourceHelper
|
import app.aaps.core.interfaces.resources.ResourceHelper
|
||||||
import app.aaps.core.interfaces.rx.events.EventNewBG
|
import app.aaps.core.interfaces.rx.events.EventNewBG
|
||||||
|
@ -11,9 +12,14 @@ import dagger.android.HasAndroidInjector
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
import org.junit.jupiter.api.BeforeEach
|
import org.junit.jupiter.api.BeforeEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.ArgumentCaptor
|
||||||
|
import org.mockito.ArgumentMatchers.anyBoolean
|
||||||
|
import org.mockito.ArgumentMatchers.anyInt
|
||||||
import org.mockito.ArgumentMatchers.anyLong
|
import org.mockito.ArgumentMatchers.anyLong
|
||||||
|
import org.mockito.ArgumentMatchers.anyString
|
||||||
import org.mockito.Mock
|
import org.mockito.Mock
|
||||||
import org.mockito.Mockito.atMost
|
import org.mockito.Mockito.atMost
|
||||||
import org.mockito.Mockito.mock
|
import org.mockito.Mockito.mock
|
||||||
|
@ -25,9 +31,12 @@ import org.mockito.kotlin.any
|
||||||
import org.mockito.kotlin.atLeastOnce
|
import org.mockito.kotlin.atLeastOnce
|
||||||
import org.mockito.kotlin.eq
|
import org.mockito.kotlin.eq
|
||||||
import org.mockito.kotlin.whenever
|
import org.mockito.kotlin.whenever
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.HttpURLConnection
|
||||||
import java.net.SocketAddress
|
import java.net.SocketAddress
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
@ -39,6 +48,7 @@ class GarminPluginTest: TestBase() {
|
||||||
|
|
||||||
@Mock private lateinit var rh: ResourceHelper
|
@Mock private lateinit var rh: ResourceHelper
|
||||||
@Mock private lateinit var sp: SP
|
@Mock private lateinit var sp: SP
|
||||||
|
@Mock private lateinit var context: Context
|
||||||
@Mock private lateinit var loopHub: LoopHub
|
@Mock private lateinit var loopHub: LoopHub
|
||||||
private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC"))
|
private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC"))
|
||||||
|
|
||||||
|
@ -49,9 +59,14 @@ class GarminPluginTest: TestBase() {
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
fun setup() {
|
fun setup() {
|
||||||
gp = GarminPlugin(injector, aapsLogger, rh, loopHub, rxBus, sp)
|
gp = GarminPlugin(injector, aapsLogger, rh, context, loopHub, rxBus, sp)
|
||||||
gp.clock = clock
|
gp.clock = clock
|
||||||
`when`(loopHub.currentProfileName).thenReturn("Default")
|
`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
|
@AfterEach
|
||||||
|
@ -86,6 +101,17 @@ class GarminPluginTest: TestBase() {
|
||||||
sourceSensor = GlucoseValue.SourceSensor.RANDOM
|
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
|
@Test
|
||||||
fun testReceiveHeartRateUri() {
|
fun testReceiveHeartRateUri() {
|
||||||
val hr = createHeartRate(99)
|
val hr = createHeartRate(99)
|
||||||
|
@ -129,6 +155,132 @@ class GarminPluginTest: TestBase() {
|
||||||
verify(loopHub).getGlucoseValues(from, true)
|
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
|
@Test
|
||||||
fun testOnGetBloodGlucose() {
|
fun testOnGetBloodGlucose() {
|
||||||
`when`(loopHub.isConnected).thenReturn(true)
|
`when`(loopHub.isConnected).thenReturn(true)
|
||||||
|
@ -139,7 +291,7 @@ class GarminPluginTest: TestBase() {
|
||||||
listOf(createGlucoseValue(Instant.ofEpochSecond(1_000))))
|
listOf(createGlucoseValue(Instant.ofEpochSecond(1_000))))
|
||||||
val hr = createHeartRate(99)
|
val hr = createHeartRate(99)
|
||||||
val uri = createUri(hr)
|
val uri = createUri(hr)
|
||||||
val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null)
|
val result = gp.onGetBloodGlucose(uri)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"{\"encodedGlucose\":\"0A+6AQ==\"," +
|
"{\"encodedGlucose\":\"0A+6AQ==\"," +
|
||||||
"\"remainingInsulin\":3.14," +
|
"\"remainingInsulin\":3.14," +
|
||||||
|
@ -171,7 +323,7 @@ class GarminPluginTest: TestBase() {
|
||||||
params["wait"] = 10
|
params["wait"] = 10
|
||||||
val uri = createUri(params)
|
val uri = createUri(params)
|
||||||
gp.newValue = mock(Condition::class.java)
|
gp.newValue = mock(Condition::class.java)
|
||||||
val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null)
|
val result = gp.onGetBloodGlucose(uri)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"{\"encodedGlucose\":\"/wS6AQ==\"," +
|
"{\"encodedGlucose\":\"/wS6AQ==\"," +
|
||||||
"\"remainingInsulin\":3.14," +
|
"\"remainingInsulin\":3.14," +
|
||||||
|
@ -194,7 +346,7 @@ class GarminPluginTest: TestBase() {
|
||||||
@Test
|
@Test
|
||||||
fun testOnPostCarbs() {
|
fun testOnPostCarbs() {
|
||||||
val uri = createUri(mapOf("carbs" to "12"))
|
val uri = createUri(mapOf("carbs" to "12"))
|
||||||
assertEquals("", gp.onPostCarbs(mock(SocketAddress::class.java), uri, null))
|
assertEquals("", gp.onPostCarbs(uri))
|
||||||
verify(loopHub).postCarbs(12)
|
verify(loopHub).postCarbs(12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -202,7 +354,7 @@ class GarminPluginTest: TestBase() {
|
||||||
fun testOnConnectPump_Disconnect() {
|
fun testOnConnectPump_Disconnect() {
|
||||||
val uri = createUri(mapOf("disconnectMinutes" to "20"))
|
val uri = createUri(mapOf("disconnectMinutes" to "20"))
|
||||||
`when`(loopHub.isConnected).thenReturn(false)
|
`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).disconnectPump(20)
|
||||||
verify(loopHub).isConnected
|
verify(loopHub).isConnected
|
||||||
}
|
}
|
||||||
|
@ -211,7 +363,7 @@ class GarminPluginTest: TestBase() {
|
||||||
fun testOnConnectPump_Connect() {
|
fun testOnConnectPump_Connect() {
|
||||||
val uri = createUri(mapOf("disconnectMinutes" to "0"))
|
val uri = createUri(mapOf("disconnectMinutes" to "0"))
|
||||||
`when`(loopHub.isConnected).thenReturn(true)
|
`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).connectPump()
|
||||||
verify(loopHub).isConnected
|
verify(loopHub).isConnected
|
||||||
}
|
}
|
||||||
|
@ -220,7 +372,7 @@ class GarminPluginTest: TestBase() {
|
||||||
fun onSgv_NoGlucose() {
|
fun onSgv_NoGlucose() {
|
||||||
whenever(loopHub.glucoseUnit).thenReturn(GlucoseUnit.MMOL)
|
whenever(loopHub.glucoseUnit).thenReturn(GlucoseUnit.MMOL)
|
||||||
whenever(loopHub.getGlucoseValues(any(), eq(false))).thenReturn(emptyList())
|
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)
|
verify(loopHub).getGlucoseValues(clock.instant().minusSeconds(25L * 300L), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -236,7 +388,7 @@ fun onSgv_NoDelta() {
|
||||||
clock.instant().minusSeconds(100L), 99.3)))
|
clock.instant().minusSeconds(100L), 99.3)))
|
||||||
assertEquals(
|
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}]""",
|
"""[{"_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).getGlucoseValues(clock.instant().minusSeconds(25L * 300L), false)
|
||||||
verify(loopHub).glucoseUnit
|
verify(loopHub).glucoseUnit
|
||||||
}
|
}
|
||||||
|
@ -255,7 +407,7 @@ fun onSgv_NoDelta() {
|
||||||
.mapIndexed { idx, ts -> createGlucoseValue(ts, 100.0+(10 * idx)) }.reversed()}
|
.mapIndexed { idx, ts -> createGlucoseValue(ts, 100.0+(10 * idx)) }.reversed()}
|
||||||
assertEquals(
|
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}]""",
|
"""[{"_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(
|
verify(loopHub).getGlucoseValues(
|
||||||
clock.instant().minusSeconds(600L), false)
|
clock.instant().minusSeconds(600L), false)
|
||||||
|
|
||||||
|
@ -263,14 +415,14 @@ fun onSgv_NoDelta() {
|
||||||
assertEquals(
|
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":"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}]""",
|
"""{"_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(
|
verify(loopHub).getGlucoseValues(
|
||||||
clock.instant().minusSeconds(900L), false)
|
clock.instant().minusSeconds(900L), false)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"""[{"date":10000,"sgv":130,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7},""" +
|
"""[{"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}]""",
|
"""{"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(
|
verify(loopHub, times(2)).getGlucoseValues(
|
||||||
clock.instant().minusSeconds(900L), false)
|
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 ->
|
HttpServer(aapsLogger, port).use { server ->
|
||||||
server.registerEndpoint("/foo") { _: SocketAddress, uri: URI, _: String? ->
|
server.registerEndpoint("/foo") { _: SocketAddress, uri: URI, _: String? ->
|
||||||
assertEquals(URI("/foo"), uri)
|
assertEquals(URI("/foo"), uri)
|
||||||
"test"
|
HttpURLConnection.HTTP_OK to "test"
|
||||||
}
|
}
|
||||||
assertTrue(server.awaitReady(Duration.ofSeconds(10)))
|
assertTrue(server.awaitReady(Duration.ofSeconds(10)))
|
||||||
val resp = reqUri.toURL().openConnection() as HttpURLConnection
|
val resp = reqUri.toURL().openConnection() as HttpURLConnection
|
||||||
|
|
Loading…
Reference in a new issue