Support automatic exchange of communication key for HTTP via connectiq native comm (usually unreliable but could enough for that).
This commit is contained in:
parent
fa78a8214e
commit
07cc0e19c7
20 changed files with 1694 additions and 34 deletions
|
@ -9,6 +9,9 @@ plugins {
|
|||
|
||||
android {
|
||||
namespace = "app.aaps.plugins.sync"
|
||||
buildFeatures {
|
||||
aidl = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Copyright (C) 2014 Garmin International Ltd.
|
||||
* Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement.
|
||||
*/
|
||||
//IConnectIQService
|
||||
package com.garmin.android.apps.connectmobile.connectiq;
|
||||
|
||||
import com.garmin.android.connectiq.IQDevice;
|
||||
import com.garmin.android.connectiq.IQApp;
|
||||
import com.garmin.android.connectiq.IQMessage;
|
||||
|
||||
interface IConnectIQService {
|
||||
boolean openStore(String applicationID);
|
||||
List<IQDevice> getConnectedDevices();
|
||||
List<IQDevice> getKnownDevices();
|
||||
|
||||
// Remote device methods
|
||||
int getStatus(in IQDevice device);
|
||||
|
||||
// Messages and Commands
|
||||
oneway void getApplicationInfo(String notificationPackage, String notificationAction, in IQDevice device, String applicationID);
|
||||
oneway void openApplication(String notificationPackage, String notificationAction, in IQDevice device, in IQApp app);
|
||||
|
||||
// Pending intent will be fired to let the sdk know a message has been transferred.
|
||||
oneway void sendMessage(in IQMessage message, in IQDevice device, in IQApp app);
|
||||
oneway void sendImage(in IQMessage image, in IQDevice device, in IQApp app);
|
||||
|
||||
// registers a companion app with the remote service so that it can receive messages from remote device.
|
||||
oneway void registerApp(in IQApp app, String notificationAction, String notificationPackage);
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Copyright (C) 2014 Garmin International Ltd.
|
||||
* Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement.
|
||||
*/
|
||||
package com.garmin.android.connectiq;
|
||||
|
||||
parcelable IQApp {
|
||||
String applicationID;
|
||||
int status;
|
||||
String displayName;
|
||||
int version;
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Copyright (C) 2014 Garmin International Ltd.
|
||||
* Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement.
|
||||
*/
|
||||
package com.garmin.android.connectiq;
|
||||
|
||||
parcelable IQDevice {
|
||||
long deviceIdentifier;
|
||||
String friendlyName;
|
||||
int status;
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Copyright (C) 2014 Garmin International Ltd.
|
||||
* Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement.
|
||||
*/
|
||||
package com.garmin.android.connectiq;
|
||||
|
||||
parcelable IQMessage {
|
||||
const int SUCCESS = 0;
|
||||
const int FAILURE_UNKNOWN = 1;
|
||||
const int FAILURE_INVALID_FORMAT = 2;
|
||||
const int FAILURE_MESSAGE_TOO_LARGE = 3;
|
||||
const int FAILURE_UNSUPPORTED_TYPE = 4;
|
||||
const int FAILURE_DURING_TRANSFER = 5;
|
||||
const int FAILURE_INVALID_DEVICE = 6;
|
||||
const int FAILURE_DEVICE_NOT_CONNECTED = 7;
|
||||
|
||||
byte[] messageData;
|
||||
String notificationPackage;
|
||||
String notificationAction;
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
data class GarminApplication(
|
||||
val client: GarminClient,
|
||||
val device: GarminDevice,
|
||||
val id: String,
|
||||
val name: String?) {
|
||||
|
||||
enum class Status {
|
||||
@Suppress("UNUSED")
|
||||
UNKNOWN,
|
||||
INSTALLED,
|
||||
@Suppress("UNUSED")
|
||||
NOT_INSTALLED,
|
||||
@Suppress("UNUSED")
|
||||
NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
|
||||
interface GarminClient: Disposable {
|
||||
/** Name of the client. */
|
||||
val name: String
|
||||
|
||||
/** Asynchronously retrieves status information for the given application. */
|
||||
fun retrieveApplicationInfo(device: GarminDevice, appId: String, appName: String)
|
||||
|
||||
/** Asynchronously sends a message to an application. */
|
||||
fun sendMessage(app: GarminApplication, data: ByteArray)
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import com.garmin.android.connectiq.IQDevice
|
||||
|
||||
data class GarminDevice(
|
||||
val client: GarminClient,
|
||||
val id: Long,
|
||||
var name: String,
|
||||
var status: Status = Status.UNKNOWN) {
|
||||
|
||||
constructor(client: GarminClient, iqDevice: IQDevice): this(
|
||||
client,
|
||||
iqDevice.deviceIdentifier,
|
||||
iqDevice.friendlyName,
|
||||
Status.from(iqDevice.status)) {}
|
||||
|
||||
enum class Status {
|
||||
NOT_PAIRED,
|
||||
NOT_CONNECTED,
|
||||
CONNECTED,
|
||||
UNKNOWN;
|
||||
|
||||
companion object {
|
||||
fun from(ordinal: Int?): Status =
|
||||
values().firstOrNull { s -> s.ordinal == ordinal } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun toString(): String = "D[$name/$id]"
|
||||
|
||||
fun toIQDevice() = IQDevice().apply {
|
||||
deviceIdentifier = id
|
||||
friendlyName = name
|
||||
status = Status.UNKNOWN.ordinal }
|
||||
|
||||
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,286 @@
|
|||
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.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.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.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.LinkedList
|
||||
import java.util.Locale
|
||||
import java.util.Queue
|
||||
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 bindLock = Object()
|
||||
private var ciqService: IConnectIQService? = null
|
||||
get() {
|
||||
val waitUntil = Instant.now().plusSeconds(2)
|
||||
synchronized (bindLock) {
|
||||
while(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
|
||||
context.bindService(serviceIntent, ciqServiceConnection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
// Wait for the connection, that is the call to onServiceConnected.
|
||||
val wait = Duration.between(Instant.now(), waitUntil)
|
||||
if (wait > Duration.ZERO) bindLock.waitMillis(wait.toMillis())
|
||||
if (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 null
|
||||
}
|
||||
}
|
||||
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)
|
||||
ciq.connectedDevices?.forEach { d ->
|
||||
receiver.onConnectDevice(this@GarminDeviceClient, d.deviceIdentifier, d.friendlyName) }
|
||||
}
|
||||
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
|
||||
context.bindService(serviceIntent, ciqServiceConnection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
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 retrieveApplicationInfo(device: GarminDevice, appId: String, appName: String) {
|
||||
val action = createAction("APPLICATION_INFO_${device.id}_$appId")
|
||||
synchronized (registeredActions) {
|
||||
if (!registeredActions.contains(action)) {
|
||||
registerReceiver(action) { intent -> onApplicationInfo(appId, device, intent) }
|
||||
}
|
||||
registeredActions.add(action)
|
||||
}
|
||||
ciqService?.getApplicationInfo(context.packageName, action, device.toIQDevice(), appId)
|
||||
}
|
||||
|
||||
/** Receives application info callbacks from ConnectIQ app.*/
|
||||
private fun onApplicationInfo(appId: String, device: GarminDevice, intent: Intent) {
|
||||
val receivedAppId = intent.getStringExtra(EXTRA_APPLICATION_ID)?.lowercase(Locale.getDefault())
|
||||
val version = intent.getIntExtra(EXTRA_APPLICATION_VERSION, -1)
|
||||
val isInstalled = receivedAppId != null && version >= 0 && version != 65535
|
||||
|
||||
if (isInstalled) registerForMessages(device.id, appId)
|
||||
receiver.onApplicationInfo(device, appId, isInstalled)
|
||||
}
|
||||
|
||||
private fun registerForMessages(deviceId: Long, appId: String) {
|
||||
aapsLogger.info(LTag.GARMIN, "registerForMessage $name $appId")
|
||||
val action = createAction("ON_MESSAGE_${deviceId}_$appId")
|
||||
val app = IQApp().apply { applicationID = appId; displayName = "" }
|
||||
synchronized (registeredActions) {
|
||||
if (!registeredActions.contains(action)) {
|
||||
registerReceiver(action) { intent: Intent -> onReceiveMessage(app, intent) }
|
||||
ciqService?.registerApp(app, 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 status = intent.getIntExtra(EXTRA_STATUS, 0)
|
||||
val deviceId = getDevice(intent)
|
||||
val appId = intent.getStringExtra(EXTRA_APPLICATION_ID)?.lowercase()
|
||||
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
|
||||
}
|
||||
|
||||
when (status) {
|
||||
IQMessage.FAILURE_DEVICE_NOT_CONNECTED,
|
||||
IQMessage.FAILURE_DURING_TRANSFER -> {
|
||||
if (msg.attempt < MAX_RETRIES) {
|
||||
val delaySec = retryWaitFactor * msg.attempt
|
||||
Schedulers.io().scheduleDirect({ retryMessage(deviceId, appId) }, delaySec, TimeUnit.SECONDS)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
queue.remove(msg)
|
||||
val errorMessage = status
|
||||
.takeUnless { it == IQMessage.SUCCESS }?.let { s -> "error $s" }
|
||||
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
|
||||
var lastAttempt: Instant? = null
|
||||
val iqApp get() = IQApp().apply { applicationID = app.id; displayName = app.name }
|
||||
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 queue = messageQueues.getOrPut(app.device.id to app.id) { LinkedList() }
|
||||
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().apply {
|
||||
messageData = msg.data
|
||||
notificationPackage = context.packageName
|
||||
notificationAction = 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_APPLICATION_VERSION = "com.garmin.android.connectiq.EXTRA_APPLICATION_VERSION"
|
||||
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,159 @@
|
|||
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
|
||||
import org.jetbrains.annotations.VisibleForTesting
|
||||
|
||||
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>()
|
||||
@VisibleForTesting
|
||||
val liveApplications = mutableSetOf<GarminApplication>()
|
||||
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["SimAp"] = "SimulatorApp"
|
||||
GarminSimulatorClient(aapsLogger, this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDevice(client: GarminClient, deviceId: Long): GarminDevice {
|
||||
synchronized (devices) {
|
||||
return devices.getOrPut(deviceId) { GarminDevice(client, deviceId, "unknown") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getApplication(client: GarminClient, deviceId: Long, appId: String): GarminApplication {
|
||||
synchronized (liveApplications) {
|
||||
var app = liveApplications.firstOrNull { app ->
|
||||
app.client == client && app.device.id == deviceId && app.id == appId }
|
||||
if (app == null) {
|
||||
app = GarminApplication(client, getDevice(client, deviceId), appId, appIdNames[appId])
|
||||
liveApplications.add(app)
|
||||
}
|
||||
return app
|
||||
}
|
||||
}
|
||||
|
||||
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 (liveApplications) {
|
||||
liveApplications.removeIf { app -> app.client == client }
|
||||
}
|
||||
client.dispose()
|
||||
when (client.name) {
|
||||
"Device" -> startDeviceClient()
|
||||
"Sim"-> GarminSimulatorClient(aapsLogger, this)
|
||||
else -> aapsLogger.warn(LTag.GARMIN, "onDisconnect unknown client $client")
|
||||
}
|
||||
}
|
||||
|
||||
/** Receives notifications that a device has connected.
|
||||
*
|
||||
* It will retrieve status information for all applications we care about (in [appIdNames]). */
|
||||
override fun onConnectDevice(client: GarminClient, deviceId: Long, deviceName: String) {
|
||||
val device = getDevice(client, deviceId).apply { name = deviceName }
|
||||
aapsLogger.info(LTag.GARMIN, "onConnectDevice $device")
|
||||
appIdNames.forEach { (id, name) -> client.retrieveApplicationInfo(device, id, name) }
|
||||
}
|
||||
|
||||
/** Receives notifications about disconnection of a device. */
|
||||
override fun onDisconnectDevice(client: GarminClient, deviceId: Long) {
|
||||
val device = getDevice(client, deviceId)
|
||||
aapsLogger.info(LTag.GARMIN,"onDisconnectDevice $device")
|
||||
synchronized (liveApplications) {
|
||||
liveApplications.removeIf { app -> app.device == device }
|
||||
}
|
||||
}
|
||||
|
||||
/** Receives notification about applications that are installed/uninstalled
|
||||
* on a device from the client. */
|
||||
override fun onApplicationInfo(device: GarminDevice, appId: String, isInstalled: Boolean) {
|
||||
val app = getApplication(device.client, device.id, appId)
|
||||
aapsLogger.info(LTag.GARMIN, "onApplicationInfo add $app ${if (isInstalled) "" else "un"}installed")
|
||||
if (!isInstalled) {
|
||||
synchronized (liveApplications) { liveApplications.remove(app) }
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
liveApplications
|
||||
.filter { a -> a.device.id == device.id }
|
||||
.forEach { a -> sendMessage(a, msg) }
|
||||
}
|
||||
|
||||
/** Sends a message to all applications on all devices. */
|
||||
fun sendMessage(msg: Any) {
|
||||
liveApplications.forEach { app -> sendMessage(app, 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 $app ${data.size} bytes $s")
|
||||
try {
|
||||
app.client.sendMessage(app, data)
|
||||
} catch (e: IllegalStateException) {
|
||||
aapsLogger.error(LTag.GARMIN, "${app.client} not connected", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
if (!disposed) {
|
||||
clients.forEach { c -> c.dispose() }
|
||||
disposed = true
|
||||
}
|
||||
clients.clear()
|
||||
}
|
||||
|
||||
override fun isDisposed() = disposed
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import app.aaps.core.interfaces.db.GlucoseUnit
|
||||
import app.aaps.core.interfaces.logging.AAPSLogger
|
||||
|
@ -18,6 +19,7 @@ import com.google.gson.JsonObject
|
|||
import dagger.android.HasAndroidInjector
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketAddress
|
||||
import java.net.URI
|
||||
import java.time.Clock
|
||||
|
@ -42,6 +44,7 @@ class GarminPlugin @Inject constructor(
|
|||
injector: HasAndroidInjector,
|
||||
aapsLogger: AAPSLogger,
|
||||
resourceHelper: ResourceHelper,
|
||||
private val context: Context,
|
||||
private val loopHub: LoopHub,
|
||||
private val rxBus: RxBus,
|
||||
private val sp: SP,
|
||||
|
@ -57,7 +60,19 @@ class GarminPlugin @Inject constructor(
|
|||
) {
|
||||
/** HTTP Server for local HTTP server communication (device app requests values) .*/
|
||||
private var server: HttpServer? = null
|
||||
var garminMessenger: GarminMessenger? = null
|
||||
|
||||
/** Garmin ConnectIQ application id for native communication. Phone pushes values. */
|
||||
private val glucoseAppIds = mapOf(
|
||||
"c9e90ee7e6924829a8b45e7dafff5cb4" to "GlucoseWatch_Dev",
|
||||
"1107ca6c2d5644b998d4bcb3793f2b7c" to "GlucoseDataField_Dev",
|
||||
"928fe19a4d3a4259b50cb6f9ddaf0f4a" to "GlucoseWidget_Dev",
|
||||
"662dfcf7f5a147de8bd37f09574adb11" to "GlucoseWatch",
|
||||
"815c7328c21248c493ad9ac4682fe6b3" to "GlucoseDataField",
|
||||
"4bddcc1740084a1fab83a3b2e2fcf55b" to "GlucoseWidget",
|
||||
)
|
||||
|
||||
@VisibleForTesting
|
||||
private val disposable = CompositeDisposable()
|
||||
|
||||
@VisibleForTesting
|
||||
|
@ -68,10 +83,25 @@ class GarminPlugin @Inject constructor(
|
|||
var newValue: Condition = valueLock.newCondition()
|
||||
private var lastGlucoseValueTimestamp: Long? = null
|
||||
private val glucoseUnitStr get() = if (loopHub.glucoseUnit == GlucoseUnit.MGDL) "mgdl" else "mmoll"
|
||||
private val garminAapsKey get() = sp.getString("garmin_aaps_key", "")
|
||||
|
||||
private fun onPreferenceChange(event: EventPreferenceChange) {
|
||||
aapsLogger.info(LTag.GARMIN, "preferences change ${event.changedKey}")
|
||||
setupHttpServer()
|
||||
when (event.changedKey) {
|
||||
"communication_debug_mode" -> setupGarminMessenger()
|
||||
"communication_http", "communication_http_port" -> setupHttpServer()
|
||||
"garmin_aaps_key" -> sendPhoneAppMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupGarminMessenger() {
|
||||
val enableDebug = sp.getBoolean("communication_ciq_debug_mode", false)
|
||||
garminMessenger?.dispose()
|
||||
garminMessenger = null
|
||||
aapsLogger.info(LTag.GARMIN, "initialize IQ messenger in debug=$enableDebug")
|
||||
garminMessenger = GarminMessenger(
|
||||
aapsLogger, context, glucoseAppIds, {_, _ -> },
|
||||
true, enableDebug).also { disposable.add(it) }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
|
@ -83,19 +113,26 @@ class GarminPlugin @Inject constructor(
|
|||
.observeOn(Schedulers.io())
|
||||
.subscribe(::onPreferenceChange)
|
||||
)
|
||||
disposable.add(
|
||||
rxBus
|
||||
.toObservable(EventNewBG::class.java)
|
||||
.observeOn(Schedulers.io())
|
||||
.subscribe(::onNewBloodGlucose)
|
||||
)
|
||||
setupHttpServer()
|
||||
setupGarminMessenger()
|
||||
}
|
||||
|
||||
private fun setupHttpServer() {
|
||||
fun setupHttpServer() {
|
||||
if (sp.getBoolean("communication_http", false)) {
|
||||
val port = sp.getInt("communication_http_port", 28891)
|
||||
if (server != null && server?.port == port) return
|
||||
aapsLogger.info(LTag.GARMIN, "starting HTTP server on $port")
|
||||
server?.close()
|
||||
server = HttpServer(aapsLogger, port).apply {
|
||||
registerEndpoint("/get", ::onGetBloodGlucose)
|
||||
registerEndpoint("/carbs", ::onPostCarbs)
|
||||
registerEndpoint("/connect", ::onConnectPump)
|
||||
registerEndpoint("/get", requestHandler(::onGetBloodGlucose))
|
||||
registerEndpoint("/carbs", requestHandler(::onPostCarbs))
|
||||
registerEndpoint("/connect", requestHandler(::onConnectPump))
|
||||
}
|
||||
} else if (server != null) {
|
||||
aapsLogger.info(LTag.GARMIN, "stopping HTTP server")
|
||||
|
@ -104,7 +141,7 @@ class GarminPlugin @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
public override fun onStop() {
|
||||
disposable.clear()
|
||||
aapsLogger.info(LTag.GARMIN, "Stop")
|
||||
server?.close()
|
||||
|
@ -128,6 +165,34 @@ class GarminPlugin @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun onConnectDevice(device: GarminDevice) {
|
||||
aapsLogger.info(LTag.GARMIN, "onConnectDevice $device sending glucose")
|
||||
if (garminAapsKey.isNotEmpty()) sendPhoneAppMessage(device)
|
||||
}
|
||||
|
||||
private fun sendPhoneAppMessage(device: GarminDevice) {
|
||||
garminMessenger?.sendMessage(device, getGlucoseMessage())
|
||||
}
|
||||
|
||||
private fun sendPhoneAppMessage() {
|
||||
garminMessenger?.sendMessage(getGlucoseMessage())
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun getGlucoseMessage() = mapOf<String, Any>(
|
||||
"key" to garminAapsKey,
|
||||
"command" to "glucose",
|
||||
"profile" to loopHub.currentProfileName.first().toString(),
|
||||
"encodedGlucose" to encodedGlucose(getGlucoseValues()),
|
||||
"remainingInsulin" to loopHub.insulinOnboard,
|
||||
"glucoseUnit" to glucoseUnitStr,
|
||||
"temporaryBasalRate" to
|
||||
(loopHub.temporaryBasal.takeIf(java.lang.Double::isFinite) ?: 1.0),
|
||||
"connected" to loopHub.isConnected,
|
||||
"timestamp" to clock.instant().epochSecond
|
||||
)
|
||||
|
||||
/** Gets the last 2+ hours of glucose values. */
|
||||
@VisibleForTesting
|
||||
fun getGlucoseValues(): List<GlucoseValue> {
|
||||
|
@ -161,21 +226,33 @@ class GarminPlugin @Inject constructor(
|
|||
val glucoseMgDl: Int = glucose.value.roundToInt()
|
||||
encodedGlucose.add(timeSec, glucoseMgDl)
|
||||
}
|
||||
aapsLogger.info(
|
||||
LTag.GARMIN,
|
||||
"retrieved ${glucoseValues.size} last ${Date(glucoseValues.lastOrNull()?.timestamp ?: 0L)} ${encodedGlucose.size}"
|
||||
)
|
||||
return encodedGlucose.encodedBase64()
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun requestHandler(action: (URI) -> CharSequence) = {
|
||||
caller: SocketAddress, uri: URI, _: String? ->
|
||||
val key = garminAapsKey
|
||||
val deviceKey = getQueryParameter(uri, "key")
|
||||
if (key.isNotEmpty() && key != deviceKey) {
|
||||
aapsLogger.warn(LTag.GARMIN, "Invalid AAPS Key from $caller, got '$deviceKey' want '$key' $uri")
|
||||
sendPhoneAppMessage()
|
||||
Thread.sleep(1000L)
|
||||
HttpURLConnection.HTTP_UNAUTHORIZED to "{}"
|
||||
} else {
|
||||
aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri")
|
||||
HttpURLConnection.HTTP_OK to action(uri).also {
|
||||
aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri, result: $it")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Responses to get glucose value request by the device.
|
||||
*
|
||||
* Also, gets the heart rate readings from the device.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun onGetBloodGlucose(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
|
||||
aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri")
|
||||
fun onGetBloodGlucose(uri: URI): CharSequence {
|
||||
receiveHeartRate(uri)
|
||||
val profileName = loopHub.currentProfileName
|
||||
val waitSec = getQueryParameter(uri, "wait", 0L)
|
||||
|
@ -189,9 +266,7 @@ class GarminPlugin @Inject constructor(
|
|||
}
|
||||
jo.addProperty("profile", profileName.first().toString())
|
||||
jo.addProperty("connected", loopHub.isConnected)
|
||||
return jo.toString().also {
|
||||
aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri, result: $it")
|
||||
}
|
||||
return jo.toString()
|
||||
}
|
||||
|
||||
private fun getQueryParameter(uri: URI, name: String) = (uri.query ?: "")
|
||||
|
@ -223,6 +298,19 @@ class GarminPlugin @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun toLong(v: Any?) = (v as? Number?)?.toLong() ?: 0L
|
||||
|
||||
@VisibleForTesting
|
||||
fun receiveHeartRate(msg: Map<String, Any>, test: Boolean) {
|
||||
val avg: Int = msg.getOrDefault("hr", 0) as Int
|
||||
val samplingStartSec: Long = toLong(msg["hrStart"])
|
||||
val samplingEndSec: Long = toLong(msg["hrEnd"])
|
||||
val device: String? = msg["device"] as String?
|
||||
receiveHeartRate(
|
||||
Instant.ofEpochSecond(samplingStartSec), Instant.ofEpochSecond(samplingEndSec),
|
||||
avg, device, test)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun receiveHeartRate(uri: URI) {
|
||||
val avg: Int = getQueryParameter(uri, "hr", 0L).toInt()
|
||||
|
@ -237,7 +325,7 @@ class GarminPlugin @Inject constructor(
|
|||
private fun receiveHeartRate(
|
||||
samplingStart: Instant, samplingEnd: Instant,
|
||||
avg: Int, device: String?, test: Boolean) {
|
||||
aapsLogger.info(LTag.GARMIN, "average heart rate $avg BPM test=$test")
|
||||
aapsLogger.info(LTag.GARMIN, "average heart rate $avg BPM $samplingStart to $samplingEnd")
|
||||
if (test) return
|
||||
if (avg > 10 && samplingStart > Instant.ofEpochMilli(0L) && samplingEnd > samplingStart) {
|
||||
loopHub.storeHeartRate(samplingStart, samplingEnd, avg, device)
|
||||
|
@ -248,9 +336,7 @@ class GarminPlugin @Inject constructor(
|
|||
|
||||
/** Handles carb notification from the device. */
|
||||
@VisibleForTesting
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun onPostCarbs(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
|
||||
aapsLogger.info(LTag.GARMIN, "carbs from $caller, req: $uri")
|
||||
fun onPostCarbs(uri: URI): CharSequence {
|
||||
postCarbs(getQueryParameter(uri, "carbs", 0L).toInt())
|
||||
return ""
|
||||
}
|
||||
|
@ -263,9 +349,7 @@ class GarminPlugin @Inject constructor(
|
|||
|
||||
/** Handles pump connected notification that the user entered on the Garmin device. */
|
||||
@VisibleForTesting
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun onConnectPump(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
|
||||
aapsLogger.info(LTag.GARMIN, "connect from $caller, req: $uri")
|
||||
fun onConnectPump(uri: URI): CharSequence {
|
||||
val minutes = getQueryParameter(uri, "disconnectMinutes", 0L).toInt()
|
||||
if (minutes > 0) {
|
||||
loopHub.disconnectPump(minutes)
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
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)
|
||||
|
||||
/**
|
||||
* Notifies that a device is connected. This will be called for all connected devices
|
||||
* initially.
|
||||
*/
|
||||
fun onConnectDevice(client: GarminClient, deviceId: Long, deviceName: String)
|
||||
fun onDisconnectDevice(client: GarminClient, deviceId: Long)
|
||||
|
||||
/**
|
||||
* Provides application info after a call to
|
||||
* {@link ConnectIqClient#retrieveApplicationInfo retrieveApplicationInfo}.
|
||||
*/
|
||||
fun onApplicationInfo(device: GarminDevice, appId: String, isInstalled: Boolean)
|
||||
|
||||
/**
|
||||
* 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,186 @@
|
|||
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 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().apply {
|
||||
applicationID = "SimApp"
|
||||
status = GarminApplication.Status.INSTALLED.ordinal
|
||||
displayName = "Simulator"
|
||||
version = 1 }
|
||||
private val readyLock = ReentrantLock()
|
||||
private val readyCond = readyLock.newCondition()
|
||||
|
||||
private inner class Connection(private val socket: Socket): Disposable {
|
||||
val device = GarminDevice(
|
||||
this@GarminSimulatorClient,
|
||||
nextDeviceId.getAndAdd(1L),
|
||||
"Sim@${socket.remoteSocketAddress}",
|
||||
GarminDevice.Status.CONNECTED)
|
||||
|
||||
fun start() {
|
||||
executor.execute {
|
||||
try {
|
||||
receiver.onConnectDevice(this@GarminSimulatorClient, device.id, device.name)
|
||||
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}")
|
||||
}
|
||||
}
|
||||
aapsLogger.info(LTag.GARMIN, "disconnect ${device.name}" )
|
||||
connections.remove(this)
|
||||
receiver.onDisconnectDevice(this@GarminSimulatorClient, device.id)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
override fun retrieveApplicationInfo(device: GarminDevice, appId: String, appName: String) {
|
||||
receiver.onApplicationInfo(device, appId, true)
|
||||
}
|
||||
|
||||
private fun getConnection(device: GarminDevice): Connection? {
|
||||
return connections.firstOrNull { c -> c.device.id == device.id }
|
||||
}
|
||||
|
||||
override fun sendMessage(app: GarminApplication, data: ByteArray) {
|
||||
val c = getConnection(app.device) ?: return
|
||||
try {
|
||||
c.send(data)
|
||||
receiver.onSendMessage(this, app.device.id, app.id, null)
|
||||
} catch (e: SocketException) {
|
||||
val errorMessage = "sending failed '${e.message}'"
|
||||
receiver.onSendMessage(this, app.device.id, app.id, errorMessage)
|
||||
c.dispose()
|
||||
connections.remove(c)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString() = name
|
||||
}
|
|
@ -34,7 +34,7 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po
|
|||
|
||||
private val serverThread: Thread
|
||||
private val workerExecutor: Executor = Executors.newCachedThreadPool()
|
||||
private val endpoints: MutableMap<String, (SocketAddress, URI, String?) -> CharSequence> =
|
||||
private val endpoints: MutableMap<String, (SocketAddress, URI, String?) -> Pair<Int, CharSequence>> =
|
||||
ConcurrentHashMap()
|
||||
private var serverSocket: ServerSocket? = null
|
||||
private val readyLock = ReentrantLock()
|
||||
|
@ -76,7 +76,7 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po
|
|||
}
|
||||
|
||||
/** Register an endpoint (path) to handle requests. */
|
||||
fun registerEndpoint(path: String, endpoint: (SocketAddress, URI, String?) -> CharSequence) {
|
||||
fun registerEndpoint(path: String, endpoint: (SocketAddress, URI, String?) -> Pair<Int, CharSequence>) {
|
||||
aapsLogger.info(LTag.GARMIN, "Register: '$path'")
|
||||
endpoints[path] = endpoint
|
||||
}
|
||||
|
@ -127,8 +127,8 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po
|
|||
respond(HttpURLConnection.HTTP_NOT_FOUND, out)
|
||||
} else {
|
||||
try {
|
||||
val body = endpoint(s.remoteSocketAddress, uri, reqBody)
|
||||
respond(HttpURLConnection.HTTP_OK, body, "application/json", out)
|
||||
val (code, body) = endpoint(s.remoteSocketAddress, uri, reqBody)
|
||||
respond(code, body, "application/json", out)
|
||||
} catch (e: Exception) {
|
||||
aapsLogger.error(LTag.GARMIN, "endpoint " + uri.path + " failed", e)
|
||||
respond(HttpURLConnection.HTTP_INTERNAL_ERROR, out)
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
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.doReturn
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.whenever
|
||||
import java.util.LinkedList
|
||||
import java.util.Queue
|
||||
|
||||
class GarminMessengerTest: TestBase() {
|
||||
private val context = mock<Context>()
|
||||
private val client1 = mock<GarminClient>() {
|
||||
on { name } doReturn "Mock1"
|
||||
}
|
||||
private val client2 = mock<GarminClient>() {
|
||||
on { name } doReturn "Mock2"
|
||||
}
|
||||
private var appId1 = "appId1"
|
||||
private val appId2 = "appId2"
|
||||
|
||||
private val apps = mapOf(appId1 to "$appId1-name", appId2 to "$appId2-name")
|
||||
private val msgs: Queue<Pair<GarminApplication, Any>> = LinkedList()
|
||||
private var messenger = GarminMessenger(
|
||||
aapsLogger, context, apps, { app, msg -> msgs.add(app to msg) }, false, false)
|
||||
private val deviceId = 11L
|
||||
private val deviceName = "$deviceId-name"
|
||||
private val device = GarminDevice(client1, deviceId, deviceName)
|
||||
private val device2 = GarminDevice(client2, 12L, "dev2-name")
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
messenger.onConnect(client1)
|
||||
messenger.onConnect(client2)
|
||||
}
|
||||
@AfterEach
|
||||
fun cleanup() {
|
||||
messenger.dispose()
|
||||
assertTrue(messenger.isDisposed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onConnectDevice() {
|
||||
messenger.onConnectDevice(client1, deviceId, deviceName)
|
||||
verify(client1).retrieveApplicationInfo(device, appId1, apps[appId1]!!)
|
||||
verify(client1).retrieveApplicationInfo(device, appId2, apps[appId2]!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onApplicationInfo() {
|
||||
messenger.onApplicationInfo(device, appId1, true)
|
||||
val app = messenger.liveApplications.first()
|
||||
assertEquals(device, app.device)
|
||||
assertEquals(appId1, app.id)
|
||||
assertEquals(apps[appId1], app.name)
|
||||
|
||||
messenger.onApplicationInfo(device, appId1, false)
|
||||
assertEquals(0, messenger.liveApplications.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onDisconnectDevice() {
|
||||
messenger.onConnectDevice(client1, deviceId, deviceName)
|
||||
messenger.onApplicationInfo(device, appId1, true)
|
||||
messenger.onApplicationInfo(device2, appId1, true)
|
||||
assertEquals(2, messenger.liveApplications.size)
|
||||
messenger.onDisconnectDevice(client1, device2.id)
|
||||
assertEquals(1, messenger.liveApplications.size)
|
||||
assertEquals(appId1, messenger.liveApplications.first().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onDisconnect() {
|
||||
messenger.onApplicationInfo(device, appId1, true)
|
||||
messenger.onApplicationInfo(device2, appId2, true)
|
||||
assertEquals(2, messenger.liveApplications.size)
|
||||
messenger.onDisconnect(client1)
|
||||
assertEquals(1, messenger.liveApplications.size)
|
||||
val app = messenger.liveApplications.first()
|
||||
assertEquals(device2, app.device)
|
||||
assertEquals(appId2, app.id)
|
||||
assertEquals(apps[appId2], app.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onReceiveMessage() {
|
||||
val data = GarminSerializer.serialize("foo")
|
||||
messenger.onReceiveMessage(client1, device.id, appId1, data)
|
||||
val (app, payload) = msgs.remove()
|
||||
assertEquals(appId1, app.id)
|
||||
assertEquals("foo", payload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessageDevice() {
|
||||
messenger.onApplicationInfo(device, appId1, true)
|
||||
messenger.onApplicationInfo(device, appId2, true)
|
||||
|
||||
val msgs = mutableListOf<Pair<GarminApplication, ByteArray>>()
|
||||
whenever(client1.sendMessage(any(), any())).thenAnswer { i ->
|
||||
msgs.add(i.getArgument<GarminApplication>(0) to i.getArgument(1))
|
||||
}
|
||||
|
||||
messenger.sendMessage(device, "foo")
|
||||
assertEquals(2, msgs.size)
|
||||
val msg1 = msgs.first { (app, _) -> app.id == appId1 }.second
|
||||
val msg2 = msgs.first { (app, _) -> app.id == appId2 }.second
|
||||
assertEquals("foo", GarminSerializer.deserialize(msg1))
|
||||
assertEquals("foo", GarminSerializer.deserialize(msg2))
|
||||
messenger.onSendMessage(client1, device.id, appId1, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onSendMessageAll() {
|
||||
messenger.onApplicationInfo(device, appId1, true)
|
||||
messenger.onApplicationInfo(device2, appId2, true)
|
||||
assertEquals(2, messenger.liveApplications.size)
|
||||
|
||||
val msgs = mutableListOf<Pair<GarminApplication, ByteArray>>()
|
||||
whenever(client1.sendMessage(any(), any())).thenAnswer { i ->
|
||||
msgs.add(i.getArgument<GarminApplication>(0) to i.getArgument(1))
|
||||
}
|
||||
whenever(client2.sendMessage(any(), any())).thenAnswer { i ->
|
||||
msgs.add(i.getArgument<GarminApplication>(0) to i.getArgument(1))
|
||||
}
|
||||
|
||||
messenger.sendMessage(listOf("foo"))
|
||||
assertEquals(2, msgs.size)
|
||||
val msg1 = msgs.first { (app, _) -> app.id == appId1 }.second
|
||||
val msg2 = msgs.first { (app, _) -> app.id == appId2 }.second
|
||||
assertEquals(listOf("foo"), GarminSerializer.deserialize(msg1))
|
||||
assertEquals(listOf("foo"), GarminSerializer.deserialize(msg2))
|
||||
messenger.onSendMessage(client1, device.id, appId1, null)
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import android.content.Context
|
||||
import app.aaps.core.interfaces.db.GlucoseUnit
|
||||
import app.aaps.core.interfaces.resources.ResourceHelper
|
||||
import app.aaps.core.interfaces.rx.events.EventNewBG
|
||||
|
@ -11,9 +12,15 @@ import dagger.android.HasAndroidInjector
|
|||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.ArgumentMatchers.anyBoolean
|
||||
import org.mockito.ArgumentMatchers.anyInt
|
||||
import org.mockito.ArgumentMatchers.anyLong
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import org.mockito.ArgumentMatchers.eq
|
||||
import org.mockito.Mock
|
||||
import org.mockito.Mockito.atMost
|
||||
import org.mockito.Mockito.mock
|
||||
|
@ -21,6 +28,8 @@ import org.mockito.Mockito.times
|
|||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.verifyNoMoreInteractions
|
||||
import org.mockito.Mockito.`when`
|
||||
import java.net.ConnectException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketAddress
|
||||
import java.net.URI
|
||||
import java.time.Clock
|
||||
|
@ -34,6 +43,7 @@ class GarminPluginTest: TestBase() {
|
|||
|
||||
@Mock private lateinit var rh: ResourceHelper
|
||||
@Mock private lateinit var sp: SP
|
||||
@Mock private lateinit var context: Context
|
||||
@Mock private lateinit var loopHub: LoopHub
|
||||
private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC"))
|
||||
|
||||
|
@ -44,9 +54,14 @@ class GarminPluginTest: TestBase() {
|
|||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
gp = GarminPlugin(injector, aapsLogger, rh, loopHub, rxBus, sp)
|
||||
gp = GarminPlugin(injector, aapsLogger, rh, context, loopHub, rxBus, sp)
|
||||
gp.clock = clock
|
||||
`when`(loopHub.currentProfileName).thenReturn("Default")
|
||||
`when`(sp.getBoolean(anyString(), anyBoolean())).thenAnswer { i -> i.arguments[1] }
|
||||
`when`(sp.getString(anyString(), anyString())).thenAnswer { i -> i.arguments[1] }
|
||||
`when`(sp.getInt(anyString(), anyInt())).thenAnswer { i -> i.arguments[1] }
|
||||
`when`(sp.getInt(eq("communication_http_port") ?: "", anyInt()))
|
||||
.thenReturn(28890)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
@ -76,6 +91,17 @@ class GarminPluginTest: TestBase() {
|
|||
sourceSensor = GlucoseValue.SourceSensor.RANDOM
|
||||
)
|
||||
|
||||
@Test
|
||||
fun testReceiveHeartRateMap() {
|
||||
val hr = createHeartRate(80)
|
||||
gp.receiveHeartRate(hr, false)
|
||||
verify(loopHub).storeHeartRate(
|
||||
Instant.ofEpochSecond(hr["hrStart"] as Long),
|
||||
Instant.ofEpochSecond(hr["hrEnd"] as Long),
|
||||
80,
|
||||
hr["device"] as String)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReceiveHeartRateUri() {
|
||||
val hr = createHeartRate(99)
|
||||
|
@ -119,6 +145,132 @@ class GarminPluginTest: TestBase() {
|
|||
verify(loopHub).getGlucoseValues(from, true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setupHttpServer_enabled() {
|
||||
`when`(sp.getBoolean("communication_http", false)).thenReturn(true)
|
||||
`when`(sp.getInt("communication_http_port", 28891)).thenReturn(28892)
|
||||
gp.setupHttpServer()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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) ?: device, captor.capture() ?: "")
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val r = captor.value as Map<String, Any>
|
||||
assertEquals("foo", r["key"])
|
||||
assertEquals("glucose", r["command"])
|
||||
assertEquals("D", r["profile"])
|
||||
assertEquals("", r["encodedGlucose"])
|
||||
assertEquals(0.0, r["remainingInsulin"])
|
||||
assertEquals("mmoll", r["glucoseUnit"])
|
||||
assertEquals(0.0, r["temporaryBasalRate"])
|
||||
assertEquals(false, r["connected"])
|
||||
assertEquals(clock.instant().epochSecond, r["timestamp"])
|
||||
verify(loopHub).getGlucoseValues(getGlucoseValuesFrom, true)
|
||||
verify(loopHub).insulinOnboard
|
||||
verify(loopHub).temporaryBasal
|
||||
verify(loopHub).isConnected
|
||||
verify(loopHub).glucoseUnit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnGetBloodGlucose() {
|
||||
`when`(loopHub.isConnected).thenReturn(true)
|
||||
|
@ -129,7 +281,7 @@ class GarminPluginTest: TestBase() {
|
|||
listOf(createGlucoseValue(Instant.ofEpochSecond(1_000))))
|
||||
val hr = createHeartRate(99)
|
||||
val uri = createUri(hr)
|
||||
val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null)
|
||||
val result = gp.onGetBloodGlucose(uri)
|
||||
assertEquals(
|
||||
"{\"encodedGlucose\":\"0A+6AQ==\"," +
|
||||
"\"remainingInsulin\":3.14," +
|
||||
|
@ -161,7 +313,7 @@ class GarminPluginTest: TestBase() {
|
|||
params["wait"] = 10
|
||||
val uri = createUri(params)
|
||||
gp.newValue = mock(Condition::class.java)
|
||||
val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null)
|
||||
val result = gp.onGetBloodGlucose(uri)
|
||||
assertEquals(
|
||||
"{\"encodedGlucose\":\"/wS6AQ==\"," +
|
||||
"\"remainingInsulin\":3.14," +
|
||||
|
@ -184,7 +336,7 @@ class GarminPluginTest: TestBase() {
|
|||
@Test
|
||||
fun testOnPostCarbs() {
|
||||
val uri = createUri(mapOf("carbs" to "12"))
|
||||
assertEquals("", gp.onPostCarbs(mock(SocketAddress::class.java), uri, null))
|
||||
assertEquals("", gp.onPostCarbs(uri))
|
||||
verify(loopHub).postCarbs(12)
|
||||
}
|
||||
|
||||
|
@ -192,7 +344,7 @@ class GarminPluginTest: TestBase() {
|
|||
fun testOnConnectPump_Disconnect() {
|
||||
val uri = createUri(mapOf("disconnectMinutes" to "20"))
|
||||
`when`(loopHub.isConnected).thenReturn(false)
|
||||
assertEquals("{\"connected\":false}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null))
|
||||
assertEquals("{\"connected\":false}", gp.onConnectPump(uri))
|
||||
verify(loopHub).disconnectPump(20)
|
||||
verify(loopHub).isConnected
|
||||
}
|
||||
|
@ -201,7 +353,7 @@ class GarminPluginTest: TestBase() {
|
|||
fun testOnConnectPump_Connect() {
|
||||
val uri = createUri(mapOf("disconnectMinutes" to "0"))
|
||||
`when`(loopHub.isConnected).thenReturn(true)
|
||||
assertEquals("{\"connected\":true}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null))
|
||||
assertEquals("{\"connected\":true}", gp.onConnectPump(uri))
|
||||
verify(loopHub).connectPump()
|
||||
verify(loopHub).isConnected
|
||||
}
|
||||
|
|
|
@ -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,86 @@
|
|||
package app.aaps.plugins.sync.garmin
|
||||
|
||||
import app.aaps.shared.tests.TestBase
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
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.argThat
|
||||
import org.mockito.kotlin.doAnswer
|
||||
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 var device: GarminDevice? = null
|
||||
private var app: GarminApplication? = null
|
||||
private lateinit var client: GarminSimulatorClient
|
||||
private val receiver: GarminReceiver = mock() {
|
||||
on { onConnectDevice(any(), any(), any()) }.doAnswer { i ->
|
||||
device = GarminDevice(client, i.getArgument(1), i.getArgument(2))
|
||||
app = GarminApplication(
|
||||
client, device!!, client.iqApp.applicationID, client.iqApp.displayName)
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
client = GarminSimulatorClient(aapsLogger, receiver, 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retrieveApplicationInfo() {
|
||||
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)
|
||||
verify(receiver).onConnect(client)
|
||||
verify(receiver, timeout(1_000)).onConnectDevice(eq(client), any(), any())
|
||||
client.retrieveApplicationInfo(app!!.device, app!!.id, app!!.name!!)
|
||||
}
|
||||
verify(receiver).onApplicationInfo(app!!.device, app!!.id, true)
|
||||
}
|
||||
|
||||
@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)
|
||||
verify(receiver, timeout(1_000)).onConnectDevice(eq(client), any(), any())
|
||||
}
|
||||
verify(receiver).onReceiveMessage(
|
||||
eq(client), eq(device!!.id), eq("SimApp"),
|
||||
argThat { p -> payload.contentEquals(p) })
|
||||
}
|
||||
|
||||
@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))
|
||||
Socket(ip, port).use { socket ->
|
||||
assertTrue(socket.isConnected)
|
||||
verify(receiver).onConnect(client)
|
||||
verify(receiver, timeout(1_000)).onConnectDevice(eq(client), any(), any())
|
||||
assertNotNull(device)
|
||||
assertNotNull(app)
|
||||
client.sendMessage(app!!, payload)
|
||||
}
|
||||
verify(receiver).onSendMessage(eq(client), any(), eq(app!!.id), isNull())
|
||||
}
|
||||
}
|
|
@ -77,7 +77,7 @@ internal class HttpServerTest: TestBase() {
|
|||
HttpServer(aapsLogger, port).use { server ->
|
||||
server.registerEndpoint("/foo") { _: SocketAddress, uri: URI, _: String? ->
|
||||
assertEquals(URI("/foo"), uri)
|
||||
"test"
|
||||
HttpURLConnection.HTTP_OK to "test"
|
||||
}
|
||||
assertTrue(server.awaitReady(Duration.ofSeconds(10)))
|
||||
val resp = reqUri.toURL().openConnection() as HttpURLConnection
|
||||
|
|
Loading…
Reference in a new issue