Import ComboCtl
Signed-off-by: Carlos Rafael Giani <crg7475@mailbox.org>
This commit is contained in:
parent
5df6084fa8
commit
643f26b7bf
68 changed files with 34436 additions and 0 deletions
|
@ -44,6 +44,9 @@ buildscript {
|
||||||
wearable_version = '2.9.0'
|
wearable_version = '2.9.0'
|
||||||
play_services_wearable_version = '17.1.0'
|
play_services_wearable_version = '17.1.0'
|
||||||
play_services_location_version = '20.0.0'
|
play_services_location_version = '20.0.0'
|
||||||
|
|
||||||
|
kotlinx_coroutines_version = '1.6.3'
|
||||||
|
kotlinx_datetime_version = '0.3.2'
|
||||||
}
|
}
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
|
|
14
pump/combov2/comboctl-changes.txt
Normal file
14
pump/combov2/comboctl-changes.txt
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
The code in comboctl/ is ComboCtl minus the jvmMain/ code, which contains code for the Linux platform.
|
||||||
|
This includes C++ glue code to the BlueZ stack. Since none of this is useful to AndroidAPS, it is better
|
||||||
|
left out, especially since it consists of almost 9000 lines of code.
|
||||||
|
|
||||||
|
Also, the original comboctl/build.gradle.kts files is replaced by comboctl/build.gradle, which is
|
||||||
|
much simpler, and builds ComboCtl as a kotlin-android project, not a kotlin-multiplatform one.
|
||||||
|
This simplifies integration into AndroidAPS, and avoids multiplatform problems (after all,
|
||||||
|
Kotlin Multiplatform is still marked as an alpha version feature).
|
||||||
|
|
||||||
|
When updating ComboCtl, it is important to keep these differences in mind.
|
||||||
|
|
||||||
|
Differences between the copy in comboctl/ and the original ComboCtl code must be kept as little as
|
||||||
|
possible, and preferably be transferred to the main ComboCtl project. This helps keep the comboctl/
|
||||||
|
copy and the main project in sync.
|
21
pump/combov2/comboctl/build.gradle
Normal file
21
pump/combov2/comboctl/build.gradle
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
apply plugin: 'com.android.library'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
|
apply from: "${project.rootDir}/core/android_dependencies.gradle"
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'info.nightscout.comboctl'
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
kotlin.srcDirs += ['src/commonMain', 'src/androidMain']
|
||||||
|
manifest.srcFile 'src/androidMain/AndroidManifest.xml'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation platform("org.jetbrains.kotlin:kotlin-bom")
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinx_datetime_version"
|
||||||
|
implementation "androidx.core:core-ktx:$core_version"
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="info.nightscout.comboctl.android">
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
</manifest>
|
|
@ -0,0 +1,262 @@
|
||||||
|
package info.nightscout.comboctl.android
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothAdapter as SystemBluetoothAdapter
|
||||||
|
import android.bluetooth.BluetoothDevice as SystemBluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothSocket as SystemBluetoothSocket
|
||||||
|
import android.content.Context
|
||||||
|
import info.nightscout.comboctl.base.BluetoothAddress
|
||||||
|
import info.nightscout.comboctl.base.BluetoothDevice
|
||||||
|
import info.nightscout.comboctl.base.BluetoothException
|
||||||
|
import info.nightscout.comboctl.base.BluetoothInterface
|
||||||
|
import info.nightscout.comboctl.base.ComboIOException
|
||||||
|
import info.nightscout.comboctl.base.LogLevel
|
||||||
|
import info.nightscout.comboctl.base.Logger
|
||||||
|
import info.nightscout.comboctl.utils.retryBlocking
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
||||||
|
private val logger = Logger.get("AndroidBluetoothDevice")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class representing a Bluetooth device accessible through Android's Bluetooth API.
|
||||||
|
*
|
||||||
|
* Users typically do not instantiate this directly. Instead,
|
||||||
|
* [AndroidBluetoothInterface]'s implementation of [BluetoothInterface.getDevice]
|
||||||
|
* instantiates and returns this (as a [BluetoothDevice]).
|
||||||
|
*/
|
||||||
|
class AndroidBluetoothDevice(
|
||||||
|
private val androidContext: Context,
|
||||||
|
private val systemBluetoothAdapter: SystemBluetoothAdapter,
|
||||||
|
override val address: BluetoothAddress
|
||||||
|
) : BluetoothDevice(Dispatchers.IO) {
|
||||||
|
private var systemBluetoothSocket: SystemBluetoothSocket? = null
|
||||||
|
private var inputStream: InputStream? = null
|
||||||
|
private var outputStream: OutputStream? = null
|
||||||
|
private var canDoIO: Boolean = false
|
||||||
|
private var abortConnectAttempt: Boolean = false
|
||||||
|
|
||||||
|
// Use toUpperCase() since Android expects the A-F hex digits in the
|
||||||
|
// Bluetooth address string to be uppercase (lowercase ones are considered
|
||||||
|
// invalid and cause an exception to be thrown).
|
||||||
|
private val androidBtAddressString = address.toString().uppercase(Locale.ROOT)
|
||||||
|
|
||||||
|
// Base class overrides.
|
||||||
|
|
||||||
|
override fun connect() {
|
||||||
|
check(systemBluetoothSocket == null) { "Connection already established" }
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) { "Attempting to get object representing device with address $address" }
|
||||||
|
|
||||||
|
abortConnectAttempt = false
|
||||||
|
|
||||||
|
lateinit var device: SystemBluetoothDevice
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Establishing the RFCOMM connection does not always work right away.
|
||||||
|
// Depending on the Android version and the individual Android device,
|
||||||
|
// it may require several attempts until the connection is actually
|
||||||
|
// established. Some phones behave better in this than others. We
|
||||||
|
// also retrieve the BluetoothDevice instance, create an RFCOMM
|
||||||
|
// socket, _and_ try to connect in each attempt, since any one of
|
||||||
|
// these steps may initially fail.
|
||||||
|
// This is kept separate from the for-loop in Pump.connect() on purpose;
|
||||||
|
// that loop is in place because the _pump_ may not be ready to connect
|
||||||
|
// just yet (for example because the UI is still shown on the LCD), while
|
||||||
|
// the retryBlocking loop here is in place because the _Android device_
|
||||||
|
// may not be ready to connect right away.
|
||||||
|
// TODO: Test and define what happens when all attempts failed.
|
||||||
|
// The user needs to be informed and given the choice to try again.
|
||||||
|
val totalNumAttempts = 5
|
||||||
|
retryBlocking(numberOfRetries = totalNumAttempts, delayBetweenRetries = 100) { attemptNumber, previousException ->
|
||||||
|
if (abortConnectAttempt)
|
||||||
|
return@retryBlocking
|
||||||
|
|
||||||
|
if (attemptNumber == 0) {
|
||||||
|
logger(LogLevel.DEBUG) { "First attempt to establish an RFCOMM client connection to the Combo" }
|
||||||
|
} else {
|
||||||
|
logger(LogLevel.DEBUG) {
|
||||||
|
"Previous attempt to establish an RFCOMM client connection to the Combo failed with" +
|
||||||
|
"exception \"$previousException\"; trying again (this is attempt #${attemptNumber + 1} of 5)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the GC the chance to collect an older BluetoothSocket instance
|
||||||
|
// while this thread sleep (see below).
|
||||||
|
systemBluetoothSocket = null
|
||||||
|
|
||||||
|
device = systemBluetoothAdapter.getRemoteDevice(androidBtAddressString)
|
||||||
|
|
||||||
|
// Wait for 500 ms until we actually try to connect. This seems to
|
||||||
|
// circumvent an as-of-yet unknown Bluetooth related race condition.
|
||||||
|
// TODO: Clarify this and wait for whatever is going on there properly.
|
||||||
|
try {
|
||||||
|
Thread.sleep(500)
|
||||||
|
} catch (ignored: InterruptedException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
checkForConnectPermission(androidContext) {
|
||||||
|
systemBluetoothSocket = device.createInsecureRfcommSocketToServiceRecord(Constants.sdpSerialPortUUID)
|
||||||
|
|
||||||
|
// connect() must be explicitly called. Just creating the socket via
|
||||||
|
// createInsecureRfcommSocketToServiceRecord() does not implicitly
|
||||||
|
// establish the connection. This is important to keep in mind, since
|
||||||
|
// otherwise, the calls below get input and output streams which appear
|
||||||
|
// at first to be OK until their read/write functions are actually used.
|
||||||
|
// At that point, very confusing NullPointerExceptions are thrown from
|
||||||
|
// seemingly nowhere. These NPEs happen because *inside* the streams
|
||||||
|
// there are internal Input/OutputStreams, and *these* are set to null
|
||||||
|
// if the connection wasn't established. See also:
|
||||||
|
// https://stackoverflow.com/questions/24267671/inputstream-read-causes-nullpointerexception-after-having-checked-inputstream#comment37491136_24267671
|
||||||
|
// and: https://stackoverflow.com/a/24269255/560774
|
||||||
|
systemBluetoothSocket!!.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
disconnectImpl() // Clean up any partial connection states that may exist.
|
||||||
|
throw BluetoothException("Could not establish an RFCOMM client connection to device with address $address", t)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortConnectAttempt) {
|
||||||
|
logger(LogLevel.INFO) { "RFCOMM connection setup with device with address $address aborted" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
inputStream = systemBluetoothSocket!!.inputStream
|
||||||
|
} catch (e: IOException) {
|
||||||
|
disconnectImpl()
|
||||||
|
throw ComboIOException("Could not get input stream to device with address $address", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
outputStream = systemBluetoothSocket!!.outputStream
|
||||||
|
} catch (e: IOException) {
|
||||||
|
disconnectImpl()
|
||||||
|
throw ComboIOException("Could not get output stream to device with address $address", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
canDoIO = true
|
||||||
|
|
||||||
|
logger(LogLevel.INFO) { "RFCOMM connection with device with address $address established" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun disconnect() {
|
||||||
|
if (systemBluetoothSocket == null) {
|
||||||
|
logger(LogLevel.DEBUG) { "Device already disconnected - ignoring redundant call" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectImpl()
|
||||||
|
|
||||||
|
logger(LogLevel.INFO) { "RFCOMM connection with device with address $address terminated" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unpair() {
|
||||||
|
try {
|
||||||
|
val device = systemBluetoothAdapter.getRemoteDevice(androidBtAddressString)
|
||||||
|
|
||||||
|
// At time of writing (2021-12-06), the removeBond method
|
||||||
|
// is inexplicably still marked with @hide, so we must use
|
||||||
|
// reflection to get to it and unpair this device.
|
||||||
|
val removeBondMethod = device::class.java.getMethod("removeBond")
|
||||||
|
removeBondMethod.invoke(device)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logger(LogLevel.ERROR) { "Unpairing device with address $address failed with error $t" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun blockingSend(dataToSend: List<Byte>) {
|
||||||
|
// Handle corner case when disconnect() is called in a different coroutine
|
||||||
|
// shortly before this function is run.
|
||||||
|
if (!canDoIO) {
|
||||||
|
logger(LogLevel.DEBUG) { "We are disconnecting; ignoring attempt at sending data" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
check(outputStream != null) { "Device is not connected - cannot send data" }
|
||||||
|
|
||||||
|
try {
|
||||||
|
outputStream!!.write(dataToSend.toByteArray())
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// If we are disconnecting, don't bother re-throwing the exception;
|
||||||
|
// one is always thrown when the stream is closed while write() blocks,
|
||||||
|
// and this essentially just means "write() call aborted because the
|
||||||
|
// stream got closed". That's not an error.
|
||||||
|
if (canDoIO)
|
||||||
|
throw ComboIOException("Could not write data to device with address $address", e)
|
||||||
|
else
|
||||||
|
logger(LogLevel.DEBUG) { "Aborted write call because we are disconnecting" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun blockingReceive(): List<Byte> {
|
||||||
|
// Handle corner case when disconnect() is called in a different coroutine
|
||||||
|
// shortly before this function is run.
|
||||||
|
if (!canDoIO) {
|
||||||
|
logger(LogLevel.DEBUG) { "We are disconnecting; ignoring attempt at receiving data" }
|
||||||
|
return listOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
check(inputStream != null) { "Device is not connected - cannot receive data" }
|
||||||
|
|
||||||
|
try {
|
||||||
|
val buffer = ByteArray(512)
|
||||||
|
val numReadBytes = inputStream!!.read(buffer)
|
||||||
|
return if (numReadBytes > 0) buffer.toList().subList(0, numReadBytes) else listOf()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// If we are disconnecting, don't bother re-throwing the exception;
|
||||||
|
// one is always thrown when the stream is closed while read() blocks,
|
||||||
|
// and this essentially just means "read() call aborted because the
|
||||||
|
// stream got closed". That's not an error.
|
||||||
|
if (canDoIO)
|
||||||
|
throw ComboIOException("Could not read data from device with address $address", e)
|
||||||
|
else {
|
||||||
|
logger(LogLevel.DEBUG) { "Aborted read call because we are disconnecting" }
|
||||||
|
return listOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disconnectImpl() {
|
||||||
|
canDoIO = false
|
||||||
|
abortConnectAttempt = true
|
||||||
|
|
||||||
|
if (inputStream != null) {
|
||||||
|
try {
|
||||||
|
logger(LogLevel.DEBUG) { "Closing input stream" }
|
||||||
|
inputStream!!.close()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
logger(LogLevel.WARN) { "Caught exception while closing input stream to device with address $address: $e - ignoring exception" }
|
||||||
|
} finally {
|
||||||
|
inputStream = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outputStream != null) {
|
||||||
|
try {
|
||||||
|
logger(LogLevel.DEBUG) { "Closing output stream" }
|
||||||
|
outputStream!!.close()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
logger(LogLevel.WARN) { "Caught exception while closing output stream to device with address $address: $e - ignoring exception" }
|
||||||
|
} finally {
|
||||||
|
outputStream = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemBluetoothSocket != null) {
|
||||||
|
try {
|
||||||
|
logger(LogLevel.DEBUG) { "Closing Bluetooth socket" }
|
||||||
|
systemBluetoothSocket!!.close()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
logger(LogLevel.WARN) { "Caught exception while closing Bluetooth socket to device with address $address: $e - ignoring exception" }
|
||||||
|
} finally {
|
||||||
|
systemBluetoothSocket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) { "Device disconnected" }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,614 @@
|
||||||
|
package info.nightscout.comboctl.android
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothAdapter as SystemBluetoothAdapter
|
||||||
|
import android.bluetooth.BluetoothDevice as SystemBluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothManager as SystemBluetoothManager
|
||||||
|
import android.bluetooth.BluetoothServerSocket as SystemBluetoothServerSocket
|
||||||
|
import android.bluetooth.BluetoothSocket as SystemBluetoothSocket
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import info.nightscout.comboctl.base.BluetoothAddress
|
||||||
|
import info.nightscout.comboctl.base.BluetoothDevice
|
||||||
|
import info.nightscout.comboctl.base.BluetoothException
|
||||||
|
import info.nightscout.comboctl.base.BluetoothInterface
|
||||||
|
import info.nightscout.comboctl.base.LogLevel
|
||||||
|
import info.nightscout.comboctl.base.Logger
|
||||||
|
import info.nightscout.comboctl.base.toBluetoothAddress
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
private val logger = Logger.get("AndroidBluetoothInterface")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class for accessing Bluetooth functionality on Android.
|
||||||
|
*
|
||||||
|
* This needs an Android [Context] that is always present for
|
||||||
|
* the duration of the app's existence. It is not recommended
|
||||||
|
* to use the context from an [Activity], since such a context
|
||||||
|
* may go away if the user turns the screen for example. If
|
||||||
|
* the context goes away, and discovery is ongoing, then that
|
||||||
|
* discovery prematurely ends. The context of an [Application]
|
||||||
|
* instance is an ideal choice.
|
||||||
|
*/
|
||||||
|
class AndroidBluetoothInterface(private val androidContext: Context) : BluetoothInterface {
|
||||||
|
private var bluetoothAdapter: SystemBluetoothAdapter? = null
|
||||||
|
private var rfcommServerSocket: SystemBluetoothServerSocket? = null
|
||||||
|
private var discoveryStarted = false
|
||||||
|
private var discoveryBroadcastReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
|
// Note that this contains ALL paired/bonded devices, not just
|
||||||
|
// the ones that pass the deviceFilterCallback.This is important
|
||||||
|
// in case the filter is changed sometime later, otherwise
|
||||||
|
// getPairedDeviceAddresses() would return an incomplete
|
||||||
|
// list.getPairedDeviceAddresses() has to apply the filter manually.
|
||||||
|
private val pairedDeviceAddresses = mutableSetOf<BluetoothAddress>()
|
||||||
|
|
||||||
|
// This is necessary, since the BroadcastReceivers always
|
||||||
|
// run in the UI thread, while access to the pairedDeviceAddresses
|
||||||
|
// can be requested from other threads.
|
||||||
|
private val deviceAddressLock = ReentrantLock()
|
||||||
|
|
||||||
|
private var listenThread: Thread? = null
|
||||||
|
|
||||||
|
private var unpairedDevicesBroadcastReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
|
// Stores SystemBluetoothDevice that were previously seen in
|
||||||
|
// onAclConnected(). These instances represent a device that
|
||||||
|
// was found during discovery. The first time the device is
|
||||||
|
// discovered, an instance is provided - but that first instance
|
||||||
|
// is not usable (this seems to be caused by an underlying
|
||||||
|
// Bluetooth stack bug). Only when _another_ instance that
|
||||||
|
// represents the same device is seen can that other instance
|
||||||
|
// be used and pairing can continue. Therefore, we store the
|
||||||
|
// previous observation to be able to detect whether a
|
||||||
|
// discovered instance is the first or second one that represents
|
||||||
|
// the device. We also retain the first instance until the
|
||||||
|
// second one is found - this seems to improve pairing stability
|
||||||
|
// on some Android devices.
|
||||||
|
// TODO: Find out why these weird behavior occurs and why
|
||||||
|
// we can only use the second instance.
|
||||||
|
private val previouslyDiscoveredDevices = mutableMapOf<BluetoothAddress, SystemBluetoothDevice?>()
|
||||||
|
|
||||||
|
// Set to a non-null value if discovery timeouts or if it is
|
||||||
|
// manually stopped via stopDiscovery().
|
||||||
|
private var discoveryStoppedReason: BluetoothInterface.DiscoveryStoppedReason? = null
|
||||||
|
// Set to true once a device is found. Used in onDiscoveryFinished()
|
||||||
|
// to suppress a discoveryStopped callback invocation.
|
||||||
|
private var foundDevice = false
|
||||||
|
|
||||||
|
// Invoked if discovery stops for any reason other than that
|
||||||
|
// a device was found.
|
||||||
|
private var discoveryStopped: (reason: BluetoothInterface.DiscoveryStoppedReason) -> Unit = { }
|
||||||
|
|
||||||
|
override var onDeviceUnpaired: (deviceAddress: BluetoothAddress) -> Unit = { }
|
||||||
|
|
||||||
|
override var deviceFilterCallback: (deviceAddress: BluetoothAddress) -> Boolean = { true }
|
||||||
|
|
||||||
|
fun setup() {
|
||||||
|
val bluetoothManager = androidContext.getSystemService(Context.BLUETOOTH_SERVICE) as SystemBluetoothManager
|
||||||
|
bluetoothAdapter = bluetoothManager.adapter
|
||||||
|
|
||||||
|
val bondedDevices = checkForConnectPermission(androidContext) {
|
||||||
|
bluetoothAdapter!!.bondedDevices
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) { "Found ${bondedDevices.size} bonded Bluetooth device(s)" }
|
||||||
|
|
||||||
|
for (bondedDevice in bondedDevices) {
|
||||||
|
val androidBtAddressString = bondedDevice.address
|
||||||
|
logger(LogLevel.DEBUG) {
|
||||||
|
"... device $androidBtAddressString"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val comboctlBtAddress = androidBtAddressString.toBluetoothAddress()
|
||||||
|
pairedDeviceAddresses.add(comboctlBtAddress)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
logger(LogLevel.ERROR) {
|
||||||
|
"Could not convert Android bluetooth device address " +
|
||||||
|
"\"$androidBtAddressString\" to a valid BluetoothAddress instance; skipping device"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unpairedDevicesBroadcastReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
logger(LogLevel.DEBUG) { "unpairedDevicesBroadcastReceiver received new action: ${intent.action}" }
|
||||||
|
|
||||||
|
when (intent.action) {
|
||||||
|
SystemBluetoothDevice.ACTION_BOND_STATE_CHANGED -> onBondStateChanged(intent)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidContext.registerReceiver(
|
||||||
|
unpairedDevicesBroadcastReceiver,
|
||||||
|
IntentFilter(SystemBluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun teardown() {
|
||||||
|
if (unpairedDevicesBroadcastReceiver != null) {
|
||||||
|
androidContext.unregisterReceiver(unpairedDevicesBroadcastReceiver)
|
||||||
|
unpairedDevicesBroadcastReceiver = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Callback for custom discovery activity startup.
|
||||||
|
*
|
||||||
|
* Useful for when more elaborate start procedures are done such as those that use
|
||||||
|
* [ActivityResultCaller.registerForActivityResult]. If this callback is set to null,
|
||||||
|
* the default behavior is used (= start activity with [Activity.startActivity]).
|
||||||
|
* Note that this default behavior does not detected when the user rejects permission
|
||||||
|
* to make the Android device discoverable.
|
||||||
|
*/
|
||||||
|
var customDiscoveryActivityStartCallback: ((intent: Intent) -> Unit)? = null
|
||||||
|
|
||||||
|
override fun startDiscovery(
|
||||||
|
sdpServiceName: String,
|
||||||
|
sdpServiceProvider: String,
|
||||||
|
sdpServiceDescription: String,
|
||||||
|
btPairingPin: String,
|
||||||
|
discoveryDuration: Int,
|
||||||
|
onDiscoveryStopped: (reason: BluetoothInterface.DiscoveryStoppedReason) -> Unit,
|
||||||
|
onFoundNewPairedDevice: (deviceAddress: BluetoothAddress) -> Unit
|
||||||
|
) {
|
||||||
|
check(!discoveryStarted) { "Discovery already started" }
|
||||||
|
|
||||||
|
previouslyDiscoveredDevices.clear()
|
||||||
|
foundDevice = false
|
||||||
|
discoveryStoppedReason = null
|
||||||
|
|
||||||
|
// The Combo communicates over RFCOMM using the SDP Serial Port Profile.
|
||||||
|
// We use an insecure socket, which means that it lacks an authenticated
|
||||||
|
// link key. This is done because the Combo does not use this feature.
|
||||||
|
//
|
||||||
|
// TODO: Can Android RFCOMM SDP service records be given custom
|
||||||
|
// sdpServiceProvider and sdpServiceDescription values? (This is not
|
||||||
|
// necessary for correct function, just a detail for sake of completeness.)
|
||||||
|
logger(LogLevel.DEBUG) { "Setting up RFCOMM listener socket" }
|
||||||
|
rfcommServerSocket = checkForConnectPermission(androidContext) {
|
||||||
|
bluetoothAdapter!!.listenUsingInsecureRfcommWithServiceRecord(
|
||||||
|
sdpServiceName,
|
||||||
|
Constants.sdpSerialPortUUID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a separate thread to accept and throw away incoming RFCOMM connections.
|
||||||
|
// We do not actually use those; the RFCOMM listener socket only exists to be
|
||||||
|
// able to provide an SDP SerialPort service record that can be discovered by
|
||||||
|
// the pump, and that record needs an RFCOMM listener port number.
|
||||||
|
listenThread = thread {
|
||||||
|
logger(LogLevel.DEBUG) { "RFCOMM listener thread started" }
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
logger(LogLevel.DEBUG) { "Waiting for incoming RFCOMM socket to accept" }
|
||||||
|
var socket: SystemBluetoothSocket? = null
|
||||||
|
if (rfcommServerSocket != null)
|
||||||
|
socket = rfcommServerSocket!!.accept()
|
||||||
|
if (socket != null) {
|
||||||
|
logger(LogLevel.DEBUG) { "Closing accepted incoming RFCOMM socket" }
|
||||||
|
try {
|
||||||
|
socket.close()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
// This happens when rfcommServerSocket.close() is called.
|
||||||
|
logger(LogLevel.DEBUG) { "RFCOMM listener accept() call aborted" }
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) { "RFCOMM listener thread stopped" }
|
||||||
|
}
|
||||||
|
|
||||||
|
this.discoveryStopped = onDiscoveryStopped
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) {
|
||||||
|
"Registering receiver for getting notifications about pairing requests and connected devices"
|
||||||
|
}
|
||||||
|
|
||||||
|
val intentFilter = IntentFilter()
|
||||||
|
intentFilter.addAction(SystemBluetoothDevice.ACTION_ACL_CONNECTED)
|
||||||
|
intentFilter.addAction(SystemBluetoothDevice.ACTION_PAIRING_REQUEST)
|
||||||
|
intentFilter.addAction(SystemBluetoothAdapter.ACTION_DISCOVERY_FINISHED)
|
||||||
|
intentFilter.addAction(SystemBluetoothAdapter.ACTION_SCAN_MODE_CHANGED)
|
||||||
|
|
||||||
|
discoveryBroadcastReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
logger(LogLevel.DEBUG) { "discoveryBroadcastReceiver received new action: ${intent.action}" }
|
||||||
|
|
||||||
|
when (intent.action) {
|
||||||
|
SystemBluetoothDevice.ACTION_ACL_CONNECTED -> onAclConnected(intent, onFoundNewPairedDevice)
|
||||||
|
SystemBluetoothDevice.ACTION_PAIRING_REQUEST -> onPairingRequest(intent, btPairingPin)
|
||||||
|
SystemBluetoothAdapter.ACTION_DISCOVERY_FINISHED -> onDiscoveryFinished()
|
||||||
|
SystemBluetoothAdapter.ACTION_SCAN_MODE_CHANGED -> onScanModeChanged(intent)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidContext.registerReceiver(discoveryBroadcastReceiver, intentFilter)
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) { "Starting activity for making this Android device discoverable" }
|
||||||
|
|
||||||
|
val discoverableIntent = Intent(SystemBluetoothAdapter.ACTION_REQUEST_DISCOVERABLE).apply {
|
||||||
|
putExtra(SystemBluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, discoveryDuration)
|
||||||
|
putExtra(SystemBluetoothAdapter.EXTRA_SCAN_MODE, SystemBluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customDiscoveryActivityStartCallback == null) {
|
||||||
|
// Do the default start procedure if no custom one was defined.
|
||||||
|
|
||||||
|
// This flag is necessary to be able to start the scan from the given context,
|
||||||
|
// which is _not_ an activity. Starting scans from activities is potentially
|
||||||
|
// problematic since they can go away at any moment. If this is not desirable,
|
||||||
|
// relying on the AAPS Context is better, but we have to create a new task then.
|
||||||
|
discoverableIntent.flags = discoverableIntent.flags or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
|
androidContext.startActivity(discoverableIntent)
|
||||||
|
} else {
|
||||||
|
customDiscoveryActivityStartCallback?.invoke(discoverableIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) { "Started discovery" }
|
||||||
|
|
||||||
|
discoveryStarted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stopDiscovery() {
|
||||||
|
discoveryStoppedReason = BluetoothInterface.DiscoveryStoppedReason.MANUALLY_STOPPED
|
||||||
|
stopDiscoveryInternal()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDevice(deviceAddress: BluetoothAddress): BluetoothDevice =
|
||||||
|
AndroidBluetoothDevice(androidContext, bluetoothAdapter!!, deviceAddress)
|
||||||
|
|
||||||
|
override fun getAdapterFriendlyName() =
|
||||||
|
checkForConnectPermission(androidContext) { bluetoothAdapter!!.name }
|
||||||
|
?: throw BluetoothException("Could not get Bluetooth adapter friendly name")
|
||||||
|
|
||||||
|
override fun getPairedDeviceAddresses(): Set<BluetoothAddress> =
|
||||||
|
try {
|
||||||
|
deviceAddressLock.lock()
|
||||||
|
pairedDeviceAddresses.filter { pairedDeviceAddress -> deviceFilterCallback(pairedDeviceAddress) }.toSet()
|
||||||
|
} finally {
|
||||||
|
deviceAddressLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopDiscoveryInternal() {
|
||||||
|
// Close the server socket. This frees RFCOMM resources and ends
|
||||||
|
// the listenThread because the accept() call inside will be aborted
|
||||||
|
// by the close() call.
|
||||||
|
try {
|
||||||
|
if (rfcommServerSocket != null)
|
||||||
|
rfcommServerSocket!!.close()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
logger(LogLevel.ERROR) { "Caught IO exception while closing RFCOMM server socket: $e" }
|
||||||
|
} finally {
|
||||||
|
rfcommServerSocket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// The listenThread will be shutting down now after the server
|
||||||
|
// socket was closed, since the blocking accept() call inside
|
||||||
|
// the thread gets aborted by close(). Just wait here for the
|
||||||
|
// thread to fully finish before we continue.
|
||||||
|
if (listenThread != null) {
|
||||||
|
logger(LogLevel.DEBUG) { "Waiting for RFCOMM listener thread to finish" }
|
||||||
|
listenThread!!.join()
|
||||||
|
logger(LogLevel.DEBUG) { "RFCOMM listener thread finished" }
|
||||||
|
listenThread = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discoveryBroadcastReceiver != null) {
|
||||||
|
androidContext.unregisterReceiver(discoveryBroadcastReceiver)
|
||||||
|
discoveryBroadcastReceiver = null
|
||||||
|
}
|
||||||
|
|
||||||
|
runIfScanPermissionGranted(androidContext) {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
if (bluetoothAdapter!!.isDiscovering) {
|
||||||
|
logger(LogLevel.DEBUG) { "Stopping discovery" }
|
||||||
|
bluetoothAdapter!!.cancelDiscovery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discoveryStarted) {
|
||||||
|
logger(LogLevel.DEBUG) { "Stopped discovery" }
|
||||||
|
discoveryStarted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onAclConnected(intent: Intent, foundNewPairedDevice: (deviceAddress: BluetoothAddress) -> Unit) {
|
||||||
|
// Sanity check in case we get this notification for the
|
||||||
|
// device already and need to avoid duplicate processing.
|
||||||
|
if (intent.getStringExtra("address") != null)
|
||||||
|
return
|
||||||
|
|
||||||
|
// Sanity check to make sure we can actually get
|
||||||
|
// a Bluetooth device out of the intent. Otherwise,
|
||||||
|
// we have to wait for the next notification.
|
||||||
|
val androidBtDevice = intent.getParcelableExtra<SystemBluetoothDevice>(SystemBluetoothDevice.EXTRA_DEVICE)
|
||||||
|
if (androidBtDevice == null) {
|
||||||
|
logger(LogLevel.DEBUG) { "Ignoring ACL_CONNECTED intent that has no Bluetooth device" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val androidBtAddressString = androidBtDevice.address
|
||||||
|
// This effectively marks the device as "already processed"
|
||||||
|
// (see the getStringExtra() call above).
|
||||||
|
intent.putExtra("address", androidBtAddressString)
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) { "ACL_CONNECTED intent has Bluetooth device with address $androidBtAddressString" }
|
||||||
|
|
||||||
|
val comboctlBtAddress = try {
|
||||||
|
androidBtAddressString.toBluetoothAddress()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logger(LogLevel.ERROR) {
|
||||||
|
"Could not convert Android bluetooth device address " +
|
||||||
|
"\"$androidBtAddressString\" to a valid BluetoothAddress instance; skipping device"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// During discovery, the ACTION_ACL_CONNECTED action apparently
|
||||||
|
// is notified at least *twice*. And, the device that is present
|
||||||
|
// in the intent may not be the same device there was during
|
||||||
|
// the first notification (= the parcelableExtra EXTRA_DEVICE
|
||||||
|
// seen above). It turns out that only the *second EXTRA_DEVICE
|
||||||
|
// can actually be used (otherwise pairing fails). The reason
|
||||||
|
// for this is unknown, but seems to be caused by bugs in the
|
||||||
|
// Fluoride (aka BlueDroid) Bluetooth stack.
|
||||||
|
// To circumvent this, we don't do anything the first time,
|
||||||
|
// but remember the device's Bluetooth address. Only when we
|
||||||
|
// see that address again do we actually proceed with announcing
|
||||||
|
// the device as having been discovered.
|
||||||
|
// NOTE: This is different from the getStringExtra() check
|
||||||
|
// above. That one checks if the *same* Android Bluetooth device
|
||||||
|
// instance was already processed. This check here instead
|
||||||
|
// verifies if we have seen the same Bluetooth address on
|
||||||
|
// *different* Android Bluetooth device instances.
|
||||||
|
// TODO: Test how AndroidBluetoothInterface behaves if the
|
||||||
|
// device is unpaired while discovery is ongoing (manually by
|
||||||
|
// the user for example). In theory, this should be handled
|
||||||
|
// properly by the onBondStateChanged function below.
|
||||||
|
// TODO: This check may not be necessary on all Android
|
||||||
|
// devices. On some, it seems to also work if we use the
|
||||||
|
// first offered BluetoothDevice.
|
||||||
|
if (comboctlBtAddress !in previouslyDiscoveredDevices) {
|
||||||
|
previouslyDiscoveredDevices[comboctlBtAddress] = androidBtDevice
|
||||||
|
logger(LogLevel.DEBUG) {
|
||||||
|
"Device with address $comboctlBtAddress discovered for the first time; " +
|
||||||
|
"need to \"discover\" it again to be able to announce its discovery"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
previouslyDiscoveredDevices[comboctlBtAddress] = null
|
||||||
|
logger(LogLevel.DEBUG) {
|
||||||
|
"Device with address $comboctlBtAddress discovered for the second time; " +
|
||||||
|
"announcing it as discovered"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always adding the device to the paired addresses even
|
||||||
|
// if the deviceFilterCallback() below returns false. See
|
||||||
|
// the pairedDeviceAddresses comments above for more.
|
||||||
|
try {
|
||||||
|
deviceAddressLock.lock()
|
||||||
|
pairedDeviceAddresses.add(comboctlBtAddress)
|
||||||
|
} finally {
|
||||||
|
deviceAddressLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(LogLevel.INFO) { "Got device with address $androidBtAddressString" }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Apply device filter before announcing a newly
|
||||||
|
// discovered device, just as the ComboCtl
|
||||||
|
// BluetoothInterface.startDiscovery()
|
||||||
|
// documentation requires.
|
||||||
|
if (deviceFilterCallback(comboctlBtAddress)) {
|
||||||
|
foundDevice = true
|
||||||
|
stopDiscoveryInternal()
|
||||||
|
foundNewPairedDevice(comboctlBtAddress)
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logger(LogLevel.ERROR) { "Caught error while invoking foundNewPairedDevice callback: $t" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onBondStateChanged(intent: Intent) {
|
||||||
|
// Here, we handle the case where a previously paired
|
||||||
|
// device just got unpaired. The caller needs to know
|
||||||
|
// about this to check if said device was a Combo.
|
||||||
|
// If so, the caller may have to update states like
|
||||||
|
// the pump state store accordingly.
|
||||||
|
|
||||||
|
val androidBtDevice = intent.getParcelableExtra<SystemBluetoothDevice>(SystemBluetoothDevice.EXTRA_DEVICE)
|
||||||
|
if (androidBtDevice == null) {
|
||||||
|
logger(LogLevel.DEBUG) { "Ignoring BOND_STATE_CHANGED intent that has no Bluetooth device" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val androidBtAddressString = androidBtDevice.address
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) { "PAIRING_REQUEST intent has Bluetooth device with address $androidBtAddressString" }
|
||||||
|
|
||||||
|
val comboctlBtAddress = try {
|
||||||
|
androidBtAddressString.toBluetoothAddress()
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
logger(LogLevel.ERROR) {
|
||||||
|
"Could not convert Android bluetooth device address " +
|
||||||
|
"\"$androidBtAddressString\" to a valid BluetoothAddress instance; ignoring device"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val previousBondState = intent.getIntExtra(SystemBluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, SystemBluetoothDevice.ERROR)
|
||||||
|
val currentBondState = intent.getIntExtra(SystemBluetoothDevice.EXTRA_BOND_STATE, SystemBluetoothDevice.ERROR)
|
||||||
|
|
||||||
|
// An unpaired device is characterized by a state change
|
||||||
|
// from non-NONE to NONE. Filter out all other state changes.
|
||||||
|
if (!((currentBondState == SystemBluetoothDevice.BOND_NONE) && (previousBondState != SystemBluetoothDevice.BOND_NONE))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
previouslyDiscoveredDevices.remove(comboctlBtAddress)
|
||||||
|
|
||||||
|
// Always removing the device from the paired addresses
|
||||||
|
// event if the deviceFilterCallback() below returns false.
|
||||||
|
// See the pairedDeviceAddresses comments above for more.
|
||||||
|
try {
|
||||||
|
deviceAddressLock.lock()
|
||||||
|
pairedDeviceAddresses.remove(comboctlBtAddress)
|
||||||
|
logger(LogLevel.DEBUG) { "Removed device with address $comboctlBtAddress from the list of paired devices" }
|
||||||
|
} finally {
|
||||||
|
deviceAddressLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply device filter before announcing an
|
||||||
|
// unpaired device, just as the ComboCtl
|
||||||
|
// BluetoothInterface.startDiscovery()
|
||||||
|
// documentation requires.
|
||||||
|
try {
|
||||||
|
if (deviceFilterCallback(comboctlBtAddress)) {
|
||||||
|
onDeviceUnpaired(comboctlBtAddress)
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logger(LogLevel.ERROR) { "Caught error while invoking onDeviceUnpaired callback: $t" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPairingRequest(intent: Intent, btPairingPin: String) {
|
||||||
|
val androidBtDevice = intent.getParcelableExtra<SystemBluetoothDevice>(SystemBluetoothDevice.EXTRA_DEVICE)
|
||||||
|
if (androidBtDevice == null) {
|
||||||
|
logger(LogLevel.DEBUG) { "Ignoring PAIRING_REQUEST intent that has no Bluetooth device" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val androidBtAddressString = androidBtDevice.address
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) { "PAIRING_REQUEST intent has Bluetooth device with address $androidBtAddressString" }
|
||||||
|
|
||||||
|
val comboctlBtAddress = try {
|
||||||
|
androidBtAddressString.toBluetoothAddress()
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
logger(LogLevel.ERROR) {
|
||||||
|
"Could not convert Android bluetooth device address " +
|
||||||
|
"\"$androidBtAddressString\" to a valid BluetoothAddress instance; ignoring device"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deviceFilterCallback(comboctlBtAddress)) {
|
||||||
|
logger(LogLevel.DEBUG) { "This is not a Combo pump; ignoring device" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(LogLevel.INFO) {
|
||||||
|
" Device with address $androidBtAddressString is a Combo pump; accepting Bluetooth pairing request"
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: The setPin(), createBond(), and setPairingConfirmation()
|
||||||
|
// calls *must* be made, no matter if the permissions were given
|
||||||
|
// or not. Otherwise, pairing fails. This is because the Combo's
|
||||||
|
// pairing mechanism is unusual; the Bluetooth PIN is hardcoded
|
||||||
|
// (see the BT_PAIRING_PIN constant), and the application enters
|
||||||
|
// it programmatically. For security reasons, this isn't normally
|
||||||
|
// doable. But, with the calls below, it seems to work. This sort
|
||||||
|
// of bends what is possible in Android, and is the cause for
|
||||||
|
// pairing difficulties, but cannot be worked around.
|
||||||
|
//
|
||||||
|
// This means that setPin(), createBond(), and setPairingConfirmation()
|
||||||
|
// _must not_ be called with functions like checkForConnectPermission(),
|
||||||
|
// since those functions would always detect the missing permissions
|
||||||
|
// and refuse to invoke these functions.
|
||||||
|
//
|
||||||
|
// Furthermore, setPairingConfirmation requires the BLUETOOTH_PRIVILEGED
|
||||||
|
// permission. However, this permission is only accessible to system
|
||||||
|
// apps. But again, without it, pairing fails, and we are probably
|
||||||
|
// using undocumented behavior here. The call does fail with a
|
||||||
|
// SecurityException, but still seems to do _something_.
|
||||||
|
|
||||||
|
try {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
if (!androidBtDevice.setPin(btPairingPin.encodeToByteArray())) {
|
||||||
|
logger(LogLevel.WARN) { "Could not set Bluetooth pairing PIN" }
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logger(LogLevel.WARN) { "Caught error while setting Bluetooth pairing PIN: $t" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
if (!androidBtDevice.createBond()) {
|
||||||
|
logger(LogLevel.WARN) { "Could not create bond" }
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logger(LogLevel.WARN) { "Caught error while creating bond: $t" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
if (!androidBtDevice.setPairingConfirmation(true)) {
|
||||||
|
logger(LogLevel.WARN) { "Could not set pairing confirmation" }
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logger(LogLevel.WARN) { "Caught exception while setting pairing confirmation: $t" }
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(LogLevel.INFO) { "Established Bluetooth pairing with Combo pump with address $androidBtAddressString" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDiscoveryFinished() {
|
||||||
|
logger(LogLevel.DEBUG) { "Discovery finished" }
|
||||||
|
|
||||||
|
// If a device was found, foundNewPairedDevice is called,
|
||||||
|
// which implicitly announces that discovery stopped.
|
||||||
|
if (!foundDevice) {
|
||||||
|
// discoveryStoppedReason is set to a non-NULL value only
|
||||||
|
// if stopDiscovery() is called. If the discovery timeout
|
||||||
|
// is reached, we get to this point, but the value of
|
||||||
|
// discoveryStoppedReason is still null.
|
||||||
|
discoveryStopped(discoveryStoppedReason ?: BluetoothInterface.DiscoveryStoppedReason.DISCOVERY_TIMEOUT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onScanModeChanged(intent: Intent) {
|
||||||
|
// Only using EXTRA_SCAN_MODE here, since EXTRA_PREVIOUS_SCAN_MODE never
|
||||||
|
// seems to be populated. See: https://stackoverflow.com/a/30935424/560774
|
||||||
|
// This appears to be either a bug in Android or an error in the documentation.
|
||||||
|
|
||||||
|
val currentScanMode = intent.getIntExtra(SystemBluetoothAdapter.EXTRA_SCAN_MODE, SystemBluetoothAdapter.ERROR)
|
||||||
|
if (currentScanMode == SystemBluetoothAdapter.ERROR) {
|
||||||
|
logger(LogLevel.ERROR) { "Could not get current scan mode; EXTRA_SCAN_MODE extra field missing" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) { "Scan mode changed to $currentScanMode" }
|
||||||
|
|
||||||
|
// Since EXTRA_PREVIOUS_SCAN_MODE is not available, we have to use a trick
|
||||||
|
// to make sure we detect a discovery timeout. If there's a broadcast
|
||||||
|
// receiver, we must have been discoverable so far. And if the EXTRA_SCAN_MODE
|
||||||
|
// field indicates that we aren't discoverable right now, it follows that
|
||||||
|
// we used to be discoverable but no longer are.
|
||||||
|
if ((discoveryBroadcastReceiver != null) &&
|
||||||
|
(currentScanMode != SystemBluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE)
|
||||||
|
) {
|
||||||
|
logger(LogLevel.INFO) { "We are no longer discoverable" }
|
||||||
|
// Only proceed if the discovery timed out. This happens if no device was
|
||||||
|
// found and discoveryStoppedReason wasn't set. (see stopDiscovery()
|
||||||
|
// for an example where discoveryStoppedReason is set prior to stopping.)
|
||||||
|
if (!foundDevice && (discoveryStoppedReason == null)) {
|
||||||
|
discoveryStoppedReason = BluetoothInterface.DiscoveryStoppedReason.DISCOVERY_TIMEOUT
|
||||||
|
onDiscoveryFinished()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
package info.nightscout.comboctl.android
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import info.nightscout.comboctl.base.LogLevel
|
||||||
|
import info.nightscout.comboctl.base.LoggerBackend
|
||||||
|
|
||||||
|
class AndroidLoggerBackend : LoggerBackend {
|
||||||
|
override fun log(tag: String, level: LogLevel, throwable: Throwable?, message: String?) {
|
||||||
|
when (level) {
|
||||||
|
LogLevel.VERBOSE -> Log.v(tag, message, throwable)
|
||||||
|
LogLevel.DEBUG -> Log.d(tag, message, throwable)
|
||||||
|
LogLevel.INFO -> Log.i(tag, message, throwable)
|
||||||
|
LogLevel.WARN -> Log.w(tag, message, throwable)
|
||||||
|
LogLevel.ERROR -> Log.e(tag, message, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
package info.nightscout.comboctl.android
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import info.nightscout.comboctl.base.BluetoothPermissionException
|
||||||
|
|
||||||
|
private const val bluetoothConnectPermission = "android.permission.BLUETOOTH_CONNECT"
|
||||||
|
private const val bluetoothScanPermission = "android.permission.BLUETOOTH_SCAN"
|
||||||
|
|
||||||
|
class AndroidBluetoothPermissionException(val missingPermissions: List<String>) :
|
||||||
|
BluetoothPermissionException("Missing Bluetooth permissions: ${missingPermissions.joinToString(", ")}")
|
||||||
|
|
||||||
|
internal fun <T> checkForConnectPermission(androidContext: Context, block: () -> T) =
|
||||||
|
checkForPermissions(androidContext, listOf(bluetoothConnectPermission), block)
|
||||||
|
|
||||||
|
internal fun <T> checkForPermission(androidContext: Context, permission: String, block: () -> T) =
|
||||||
|
checkForPermissions(androidContext, listOf(permission), block)
|
||||||
|
|
||||||
|
internal fun <T> checkForPermissions(androidContext: Context, permissions: List<String>, block: () -> T): T {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val missingPermissions = permissions
|
||||||
|
.filter {
|
||||||
|
ContextCompat.checkSelfPermission(androidContext, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (missingPermissions.isEmpty())
|
||||||
|
block.invoke()
|
||||||
|
else
|
||||||
|
throw AndroidBluetoothPermissionException(missingPermissions)
|
||||||
|
} else
|
||||||
|
block.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun runIfScanPermissionGranted(androidContext: Context, block: () -> Unit): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
if (ContextCompat.checkSelfPermission(androidContext, bluetoothScanPermission)
|
||||||
|
== PackageManager.PERMISSION_GRANTED) {
|
||||||
|
block.invoke()
|
||||||
|
true
|
||||||
|
} else
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
block.invoke()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package info.nightscout.comboctl.android
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
object Constants {
|
||||||
|
// This is a combination of the base SDP service UUID, which is
|
||||||
|
// 00000000-0000-1000-8000-00805F9B34FB, and the short SerialPort
|
||||||
|
// UUID, which is 0x1101. The base UUID is specified in the
|
||||||
|
// Bluetooth 4.2 spec, Vol 3, Part B, section 2.5 .
|
||||||
|
val sdpSerialPortUUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")!!
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
package info.nightscout.comboctl.utils
|
||||||
|
|
||||||
|
fun <T> retryBlocking(
|
||||||
|
numberOfRetries: Int,
|
||||||
|
delayBetweenRetries: Long = 100,
|
||||||
|
block: (Int, Exception?) -> T
|
||||||
|
): T {
|
||||||
|
require(numberOfRetries > 0)
|
||||||
|
|
||||||
|
var previousException: Exception? = null
|
||||||
|
repeat(numberOfRetries - 1) { attemptNumber ->
|
||||||
|
try {
|
||||||
|
return block(attemptNumber, previousException)
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
previousException = exception
|
||||||
|
}
|
||||||
|
Thread.sleep(delayBetweenRetries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last attempt. This one is _not_ surrounded with a try-catch
|
||||||
|
// block to make sure that if even the last attempt fails with an
|
||||||
|
// exception the caller gets informed about said exception.
|
||||||
|
return block(numberOfRetries - 1, previousException)
|
||||||
|
}
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,47 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
const val NUM_BLUETOOTH_ADDRESS_BYTES = 6
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class containing a 6-byte Bluetooth address.
|
||||||
|
*
|
||||||
|
* The address bytes are stored in the printed order.
|
||||||
|
* For example, a Bluetooth address 11:22:33:44:55:66
|
||||||
|
* is stored as a 0x11, 0x22, 0x33, 0x44, 0x55, 0x66
|
||||||
|
* array, with 0x11 being the first byte. This is how
|
||||||
|
* Android stores Bluetooth address bytes. Note though
|
||||||
|
* that some Bluetooth stacks like BlueZ store the
|
||||||
|
* bytes in the reverse order.
|
||||||
|
*/
|
||||||
|
data class BluetoothAddress(private val addressBytes: List<Byte>) : Iterable<Byte> {
|
||||||
|
/**
|
||||||
|
* Number of address bytes (always 6).
|
||||||
|
*
|
||||||
|
* This mainly exists to make this class compatible with
|
||||||
|
* code that operates on collections.
|
||||||
|
*/
|
||||||
|
val size = NUM_BLUETOOTH_ADDRESS_BYTES
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(addressBytes.size == size)
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun get(index: Int) = addressBytes[index]
|
||||||
|
|
||||||
|
override operator fun iterator() = addressBytes.iterator()
|
||||||
|
|
||||||
|
override fun toString() = addressBytes.toHexString(":")
|
||||||
|
|
||||||
|
fun toByteArray() = addressBytes.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a 6-byte bytearray to a BluetoothAddress.
|
||||||
|
*
|
||||||
|
* @return BluetoothAddress variant of the 6 bytearray bytes.
|
||||||
|
* @throws IllegalArgumentException if the bytearray's length
|
||||||
|
* is not exactly 6 bytes.
|
||||||
|
*/
|
||||||
|
fun ByteArray.toBluetoothAddress() = BluetoothAddress(this.toList())
|
||||||
|
|
||||||
|
fun String.toBluetoothAddress() = BluetoothAddress(this.split(":").map { it.toInt(radix = 16).toByte() })
|
|
@ -0,0 +1,62 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract class for operating Bluetooth devices.
|
||||||
|
*
|
||||||
|
* Subclasses implement blocking IO to allow for RFCOMM-based
|
||||||
|
* IO with a Bluetooth device.
|
||||||
|
*
|
||||||
|
* Subclass instances are created by [BluetoothInterface] subclasses.
|
||||||
|
*
|
||||||
|
* @param ioDispatcher [CoroutineDispatcher] where the
|
||||||
|
* [blockingSend] and [blockingReceive] calls shall take place.
|
||||||
|
* See the [BlockingComboIO] for details. Typically, this dispatcher
|
||||||
|
* is sent by the [BluetoothInterface] subclass that creates this
|
||||||
|
* [BluetoothDevice] instance.
|
||||||
|
*/
|
||||||
|
abstract class BluetoothDevice(ioDispatcher: CoroutineDispatcher) : BlockingComboIO(ioDispatcher) {
|
||||||
|
/**
|
||||||
|
* The device's Bluetooth address.
|
||||||
|
*/
|
||||||
|
abstract val address: BluetoothAddress
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up the device's RFCOMM connection.
|
||||||
|
*
|
||||||
|
* This function blocks until the connection is set up or an error occurs.
|
||||||
|
*
|
||||||
|
* @throws BluetoothPermissionException if connecting fails
|
||||||
|
* because connection permissions are missing.
|
||||||
|
* @throws BluetoothException if connection fails due to an underlying
|
||||||
|
* Bluetooth issue.
|
||||||
|
* @throws ComboIOException if connection fails due to an underlying
|
||||||
|
* IO issue and if the device was unpaired.
|
||||||
|
* @throws IllegalStateException if this object is in a state
|
||||||
|
* that does not permit connecting, such as a device
|
||||||
|
* that has been shut down.
|
||||||
|
*/
|
||||||
|
abstract fun connect()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly disconnect the device's RFCOMM connection now.
|
||||||
|
*
|
||||||
|
* After this call, this BluetoothDevice instance cannot be user
|
||||||
|
* anymore until it is reconnected via a new [connect] call.
|
||||||
|
*/
|
||||||
|
abstract fun disconnect()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpairs this device.
|
||||||
|
*
|
||||||
|
* Once this was called, this [BluetoothDevice] instance must not be used anymore.
|
||||||
|
* [disconnect] may be called, but will be a no-op. [connect], [send] and [receive]
|
||||||
|
* will throw an [IllegalStateException].
|
||||||
|
*
|
||||||
|
* Calling this while a connection is running leads to undefined behavior.
|
||||||
|
* Make sure to call [disconnect] before this function if a connection
|
||||||
|
* is currently present.
|
||||||
|
*/
|
||||||
|
abstract fun unpair()
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for Bluetooth specific exceptions.
|
||||||
|
*
|
||||||
|
* @param message The detail message.
|
||||||
|
* @param cause Throwable that further describes the cause of the exception.
|
||||||
|
*/
|
||||||
|
open class BluetoothException(message: String?, cause: Throwable?) : ComboIOException(message, cause) {
|
||||||
|
constructor(message: String) : this(message, null)
|
||||||
|
constructor(cause: Throwable) : this(null, cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for exceptions that get thrown when permissions for scanning, connecting etc. are missing.
|
||||||
|
*
|
||||||
|
* Subclasses contain OS specific information, like the exact missing permissions.
|
||||||
|
*
|
||||||
|
* @param message The detail message.
|
||||||
|
* @param cause Throwable that further describes the cause of the exception.
|
||||||
|
*/
|
||||||
|
open class BluetoothPermissionException(message: String?, cause: Throwable?) : BluetoothException(message, cause) {
|
||||||
|
constructor(message: String) : this(message, null)
|
||||||
|
constructor(cause: Throwable) : this(null, cause)
|
||||||
|
}
|
|
@ -0,0 +1,210 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple high-level interface to the system's Bluetooth stack.
|
||||||
|
*
|
||||||
|
* This interface offers the bare minimum to accomplish the following tasks:
|
||||||
|
*
|
||||||
|
* 1. Discover and pair Bluetooth devices with the given pairing PIN.
|
||||||
|
* (An SDP service is temporarily set up during discovery.)
|
||||||
|
* 2. Connect to a Bluetooth device and enable RFCOMM-based blocking IO with it.
|
||||||
|
*
|
||||||
|
* The constructor must set up all necessary platform specific resources.
|
||||||
|
*/
|
||||||
|
interface BluetoothInterface {
|
||||||
|
/**
|
||||||
|
* Possible reasons for why discovery stopped.
|
||||||
|
*
|
||||||
|
* Used in the [startDiscovery] discoveryStopped callback.
|
||||||
|
*/
|
||||||
|
enum class DiscoveryStoppedReason(val str: String) {
|
||||||
|
MANUALLY_STOPPED("manually stopped"),
|
||||||
|
DISCOVERY_ERROR("error during discovery"),
|
||||||
|
DISCOVERY_TIMEOUT("discovery timeout reached")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for when a previously paired device is unpaired.
|
||||||
|
*
|
||||||
|
* This is independent of the device discovery. That is, this callback
|
||||||
|
* can be invoked by the implementation even when discovery is inactive.
|
||||||
|
*
|
||||||
|
* The unpairing may have been done via [BluetoothDevice.unpair] or
|
||||||
|
* via some sort of system settings.
|
||||||
|
*
|
||||||
|
* Note that this callback may be called from another thread. Using
|
||||||
|
* synchronization primitives to avoid race conditions is recommended.
|
||||||
|
* Also, implementations must make sure that setting the callback
|
||||||
|
* can not cause data races; that is, it must not happen that a new
|
||||||
|
* callback is set while the existing callback is invoked due to an
|
||||||
|
* unpaired device.
|
||||||
|
*
|
||||||
|
* Do not spend too much time in this callback, since it may block
|
||||||
|
* internal threads.
|
||||||
|
*
|
||||||
|
* Exceptions thrown by this callback are logged, but not propagated.
|
||||||
|
*
|
||||||
|
* See the note at [getPairedDeviceAddresses] about using this callback
|
||||||
|
* and that function in the correct order.
|
||||||
|
*/
|
||||||
|
var onDeviceUnpaired: (deviceAddress: BluetoothAddress) -> Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for filtering devices based on their Bluetooth addresses.
|
||||||
|
*
|
||||||
|
* This is used for checking if a device shall be processed or ignored.
|
||||||
|
* When a newly paired device is discovered, or a paired device is
|
||||||
|
* unpaired, this callback is invoked. If it returns false, then
|
||||||
|
* the device is ignored, and those callbacks don't get called.
|
||||||
|
*
|
||||||
|
* Note that this callback may be called from another thread. Using
|
||||||
|
* synchronization primitives to avoid race conditions is recommended.
|
||||||
|
* Also, implementations must make sure that setting the callback
|
||||||
|
* can not cause data races.
|
||||||
|
*
|
||||||
|
* Do not spend too much time in this callback, since it may block
|
||||||
|
* internal threads.
|
||||||
|
*
|
||||||
|
* IMPORTANT: This callback must not throw.
|
||||||
|
*
|
||||||
|
* The default callback always returns true.
|
||||||
|
*/
|
||||||
|
var deviceFilterCallback: (deviceAddress: BluetoothAddress) -> Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts discovery of Bluetooth devices that haven't been paired yet.
|
||||||
|
*
|
||||||
|
* Discovery is actually a process that involves multiple parts:
|
||||||
|
*
|
||||||
|
* 1. An SDP service is set up. This service is then announced to
|
||||||
|
* Bluetooth devices. Each SDP device has a record with multiple
|
||||||
|
* attributes, three of which are defined by the sdp* arguments.
|
||||||
|
* 2. Pairing is set up so that when a device tries to pair with the
|
||||||
|
* interface, it is authenticated using the given PIN.
|
||||||
|
* 3. Each detected device is filtered via its address by calling
|
||||||
|
* the [deviceFilterCallback]. Only those devices whose addresses
|
||||||
|
* pass this filter are forwarded to the pairing authorization
|
||||||
|
* (see step 2 above). As a result, only the filtered devices
|
||||||
|
* can eventually have their address passed to the
|
||||||
|
* foundNewPairedDevice callback.
|
||||||
|
*
|
||||||
|
* Note that the callbacks typically are called from a different
|
||||||
|
* thread, so make sure that thread synchronization primitives like
|
||||||
|
* mutexes are used.
|
||||||
|
*
|
||||||
|
* Do not spend too much time in the callbacks, since this
|
||||||
|
* may block internal threads.
|
||||||
|
*
|
||||||
|
* This function may only be called after creating the interface and
|
||||||
|
* after discovery stopped.
|
||||||
|
*
|
||||||
|
* Discovery can stop because of these reasons:
|
||||||
|
*
|
||||||
|
* 1. [stopDiscovery] is called. This will cause the discoveryStopped
|
||||||
|
* callback to be invoked, with its "reason" argument value set to
|
||||||
|
* [DiscoveryStoppedReason.MANUALLY_STOPPED].
|
||||||
|
* 2. An error occurred during discovery. The discoveryStopped callback
|
||||||
|
* is then called with its "reason" argument value set to
|
||||||
|
* [DiscoveryStoppedReason.DISCOVERY_ERROR].
|
||||||
|
* 3. The discovery timeout was reached and no device was discovered.
|
||||||
|
* The discoveryStopped callback is then called with its "reason"
|
||||||
|
* argument value set to [DiscoveryStoppedReason.DISCOVERY_TIMEOUT].
|
||||||
|
* 4. A device is discovered and paired (with the given pairing PIN).
|
||||||
|
* The discoveryStopped callback is _not_ called in that case.
|
||||||
|
* The foundNewPairedDevice callback is called (after discovery
|
||||||
|
* was shut down), both announcing the newly discovered device to
|
||||||
|
* the caller and implicitly notifying that discovery stopped.
|
||||||
|
*
|
||||||
|
* @param sdpServiceName Name for the SDP service record.
|
||||||
|
* Must not be empty.
|
||||||
|
* @param sdpServiceProvider Human-readable name of the provider of
|
||||||
|
* this SDP service record. Must not be empty.
|
||||||
|
* @param sdpServiceDescription Human-readable description of
|
||||||
|
* this SDP service record. Must not be empty.
|
||||||
|
* @param btPairingPin Bluetooth PIN code to use for pairing.
|
||||||
|
* Not to be confused with the Combo's 10-digit pairing PIN.
|
||||||
|
* This PIN is a sequence of characters used by the Bluetooth
|
||||||
|
* stack for its pairing/authorization.
|
||||||
|
* @param discoveryDuration How long the discovery shall go on,
|
||||||
|
* in seconds. Must be a value between 1 and 300.
|
||||||
|
* @param onDiscoveryStopped: Callback that gets invoked when discovery
|
||||||
|
* is stopped for any reason _other_ than that a device
|
||||||
|
* was discovered.
|
||||||
|
* @param onFoundNewPairedDevice Callback that gets invoked when a device
|
||||||
|
* was found that passed the filter (see [deviceFilterCallback])
|
||||||
|
* and is paired. Exceptions thrown by this callback are logged,
|
||||||
|
* but not propagated. Discovery is stopped before this is called.
|
||||||
|
* @throws IllegalStateException if this is called again after
|
||||||
|
* discovery has been started already, or if the interface
|
||||||
|
* is in a state in which discovery is not possible, such as
|
||||||
|
* a Bluetooth subsystem that has been shut down.
|
||||||
|
* @throws BluetoothPermissionException if discovery fails because
|
||||||
|
* scanning and connection permissions are missing.
|
||||||
|
* @throws BluetoothException if discovery fails due to an underlying
|
||||||
|
* Bluetooth issue.
|
||||||
|
*/
|
||||||
|
fun startDiscovery(
|
||||||
|
sdpServiceName: String,
|
||||||
|
sdpServiceProvider: String,
|
||||||
|
sdpServiceDescription: String,
|
||||||
|
btPairingPin: String,
|
||||||
|
discoveryDuration: Int,
|
||||||
|
onDiscoveryStopped: (reason: DiscoveryStoppedReason) -> Unit,
|
||||||
|
onFoundNewPairedDevice: (deviceAddress: BluetoothAddress) -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops any ongoing discovery.
|
||||||
|
*
|
||||||
|
* If no discovery is going on, this does nothing.
|
||||||
|
*/
|
||||||
|
fun stopDiscovery()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and returns a BluetoothDevice for the given address.
|
||||||
|
*
|
||||||
|
* This merely creates a new BluetoothDevice instance. It does
|
||||||
|
* not connect to the device. Use [BluetoothDevice.connect]
|
||||||
|
* for that purpose.
|
||||||
|
*
|
||||||
|
* NOTE: Creating multiple instances to the same device is
|
||||||
|
* possible, but untested.
|
||||||
|
*
|
||||||
|
* @return BluetoothDevice instance for the device with the
|
||||||
|
* given address
|
||||||
|
* @throws IllegalStateException if the interface is in a state
|
||||||
|
* in which accessing devices is not possible, such as
|
||||||
|
* a Bluetooth subsystem that has been shut down.
|
||||||
|
*/
|
||||||
|
fun getDevice(deviceAddress: BluetoothAddress): BluetoothDevice
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the friendly (= human-readable) name for the adapter.
|
||||||
|
*
|
||||||
|
* @throws BluetoothPermissionException if getting the adapter name
|
||||||
|
* fails because connection permissions are missing.
|
||||||
|
* @throws BluetoothException if getting the adapter name fails
|
||||||
|
* due to an underlying Bluetooth issue.
|
||||||
|
*/
|
||||||
|
fun getAdapterFriendlyName(): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a set of addresses of paired Bluetooth devices.
|
||||||
|
*
|
||||||
|
* The [deviceFilterCallback] is applied here. That is, the returned set
|
||||||
|
* only contains addresses of devices which passed that filter.
|
||||||
|
*
|
||||||
|
* The return value is a new set, not a reference to an internal
|
||||||
|
* one, so it is safe to use even if devices get paired/unpaired
|
||||||
|
* in the meantime.
|
||||||
|
*
|
||||||
|
* To avoid a race condition where an unpaired device is missed
|
||||||
|
* when an application is starting, it is recommended to first
|
||||||
|
* assign the [onDeviceUnpaired] callback, and then retrieve the
|
||||||
|
* list of paired addresses here. If it is done the other way
|
||||||
|
* round, it is possible that between the [getPairedDeviceAddresses]
|
||||||
|
* call and the [onDeviceUnpaired] assignment, a device is
|
||||||
|
* unpaired, and thus does not get noticed.
|
||||||
|
*/
|
||||||
|
fun getPairedDeviceAddresses(): Set<BluetoothAddress>
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the CRC-16-MCRF4XX checksum out of the given data.
|
||||||
|
*
|
||||||
|
* This function can be called repeatedly on various buffer views if
|
||||||
|
* all of them are to be covered by the same checksum. In that case,
|
||||||
|
* simply pass the previously computed checksum as currentChecksum
|
||||||
|
* argument to get an updated checksum. Otherwise, just using the
|
||||||
|
* default value 0xFFFF (the "initial seed") is enough.
|
||||||
|
*
|
||||||
|
* @param data Data to compute the checksum out of.
|
||||||
|
* @param currentChecksum Current checksum, or 0xFFFF as initial seed.
|
||||||
|
* @return The computed checksum.
|
||||||
|
*/
|
||||||
|
fun calculateCRC16MCRF4XX(data: List<Byte>, currentChecksum: Int = 0xFFFF): Int {
|
||||||
|
// Original implementation from https://gist.github.com/aurelj/270bb8af82f65fa645c1#gistcomment-2884584
|
||||||
|
|
||||||
|
if (data.isEmpty())
|
||||||
|
return currentChecksum
|
||||||
|
|
||||||
|
var newChecksum = currentChecksum
|
||||||
|
|
||||||
|
for (dataByte in data) {
|
||||||
|
var t: Int
|
||||||
|
var L: Int
|
||||||
|
|
||||||
|
newChecksum = newChecksum xor dataByte.toPosInt()
|
||||||
|
// The "and 0xFF" are needed since the original C implementation
|
||||||
|
// worked with implicit 8-bit logic, meaning that only the lower
|
||||||
|
// 8 bits are kept - the rest is thrown away.
|
||||||
|
L = (newChecksum xor (newChecksum shl 4)) and 0xFF
|
||||||
|
t = ((L shl 3) or (L shr 5)) and 0xFF
|
||||||
|
L = L xor (t and 0x07)
|
||||||
|
t = (t and 0xF8) xor (((t shl 1) or (t shr 7)) and 0x0F) xor (newChecksum shr 8)
|
||||||
|
newChecksum = (L shl 8) or t
|
||||||
|
}
|
||||||
|
|
||||||
|
return newChecksum
|
||||||
|
}
|
|
@ -0,0 +1,104 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
const val CIPHER_KEY_SIZE = 16
|
||||||
|
const val CIPHER_BLOCK_SIZE = 16
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class for en- and decrypting packets going to and coming from the Combo.
|
||||||
|
*
|
||||||
|
* The packets are encrypted using the Twofish symmetric block cipher.
|
||||||
|
* It en- and decrypts blocks of 128 bits (16 bytes). Key size too is 128 bits.
|
||||||
|
*
|
||||||
|
* @property key The 128-bit key for en- and decrypting. Initially set to null.
|
||||||
|
* Callers must first set this to a valid non-null value before any
|
||||||
|
* en- and decrypting can be performed.
|
||||||
|
*/
|
||||||
|
class Cipher(val key: ByteArray) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(key.size == CIPHER_KEY_SIZE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val keyObject = Twofish.processKey(key)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a 128-bit block of cleartext, producing a 128-bit ciphertext block.
|
||||||
|
*
|
||||||
|
* The key must have been set to a valid value before calling this function.
|
||||||
|
*
|
||||||
|
* @param cleartext Array of 16 bytes (128 bits) of cleartext to encrypt.
|
||||||
|
* @return Array of 16 bytes (128 bits) of ciphertext.
|
||||||
|
*/
|
||||||
|
fun encrypt(cleartext: ByteArray): ByteArray {
|
||||||
|
require(cleartext.size == CIPHER_BLOCK_SIZE)
|
||||||
|
return Twofish.blockEncrypt(cleartext, 0, keyObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a 128-bit block of ciphertext, producing a 128-bit cleartext block.
|
||||||
|
*
|
||||||
|
* The key must have been set to a valid value before calling this function.
|
||||||
|
*
|
||||||
|
* @param ciphertext Array of 16 bytes (128 bits) of ciphertext to decrypt.
|
||||||
|
* @return Array of 16 bytes (128 bits) of cleartext.
|
||||||
|
*/
|
||||||
|
fun decrypt(ciphertext: ByteArray): ByteArray {
|
||||||
|
require(ciphertext.size == CIPHER_BLOCK_SIZE)
|
||||||
|
return Twofish.blockDecrypt(ciphertext, 0, keyObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString() = key.toHexString(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.toCipher() = Cipher(this.split(" ").map { it.toInt(radix = 16).toByte() }.toByteArray())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a weak key out of a 10-digit PIN.
|
||||||
|
*
|
||||||
|
* The weak key is needed during the Combo pairing process. The
|
||||||
|
* 10-digit PIN is displayed on the Combo's LCD, and the user has
|
||||||
|
* to enter it into whatever program is being paired with the Combo.
|
||||||
|
* Out of that PIN, the "weak key" is generated. That key is used
|
||||||
|
* for decrypting a subsequently incoming packet that contains
|
||||||
|
* additional keys that are used for en- and decrypting followup
|
||||||
|
* packets coming from and going to the Combo.
|
||||||
|
*
|
||||||
|
* @param PIN Pairing PIN to use for generating the weak key.
|
||||||
|
* @return 16 bytes containing the generated 128-bit weak key.
|
||||||
|
*/
|
||||||
|
fun generateWeakKeyFromPIN(PIN: PairingPIN): ByteArray {
|
||||||
|
// Verify that the PIN is smaller than the cipher key.
|
||||||
|
// NOTE: This could be a compile-time check, since these
|
||||||
|
// are constants. But currently, it is not known how to
|
||||||
|
// do this check at compile time.
|
||||||
|
require(PAIRING_PIN_SIZE < CIPHER_KEY_SIZE)
|
||||||
|
|
||||||
|
val generatedKey = ByteArray(CIPHER_KEY_SIZE)
|
||||||
|
|
||||||
|
// The weak key generation algorithm computes the first
|
||||||
|
// 10 bytes simply by looking at the first 10 PIN
|
||||||
|
// digits, interpreting them as characters, and using the
|
||||||
|
// ASCII indices of these characters. For example, suppose
|
||||||
|
// that the first PIN digit is 2. It is interpreted as
|
||||||
|
// character "2". That character has ASCII index 50.
|
||||||
|
// Therefore, the first byte in the key is set to 50.
|
||||||
|
for (i in 0 until PAIRING_PIN_SIZE) {
|
||||||
|
val pinDigit = PIN[i]
|
||||||
|
|
||||||
|
val pinAsciiIndex = pinDigit + '0'.code
|
||||||
|
generatedKey[i] = pinAsciiIndex.toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The PIN has 10 digits, not 16, but the key has 16
|
||||||
|
// bytes. For the last 6 bytes, the first 6 digits are
|
||||||
|
// treated just like above, except that the ASCII index
|
||||||
|
// is XORed with 0xFF.
|
||||||
|
for (i in 0 until 6) {
|
||||||
|
val pinDigit = PIN[i]
|
||||||
|
|
||||||
|
val pinAsciiIndex = pinDigit + '0'.code
|
||||||
|
generatedKey[i + PAIRING_PIN_SIZE] = (0xFF xor pinAsciiIndex).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
return generatedKey
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for ComboCtl specific exceptions.
|
||||||
|
*
|
||||||
|
* @param message The detail message.
|
||||||
|
* @param cause Throwable that further describes the cause of the exception.
|
||||||
|
*/
|
||||||
|
open class ComboException(message: String?, cause: Throwable?) : Exception(message, cause) {
|
||||||
|
constructor(message: String) : this(message, null)
|
||||||
|
constructor(cause: Throwable) : this(null, cause)
|
||||||
|
}
|
|
@ -0,0 +1,261 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
// Combo frames are delimited by the FRAME_DELIMITER byte 0xCC. Each
|
||||||
|
// frame begins and ends with this byte. Binary payload itself can
|
||||||
|
// also contain that byte, however. To solve this, there is the
|
||||||
|
// ESCAPE_BYTE 0x77. If payload itself contains a 0xCC byte, it is
|
||||||
|
// escaped by replacing that byte with the 2-byte sequence 0x77 0xDD.
|
||||||
|
// If the escape byte 0x77 itself is in the payload, then that byte
|
||||||
|
// is replaced by the 2-byte sequence 0x77 0xEE.
|
||||||
|
private const val FRAME_DELIMITER = 0xCC.toByte()
|
||||||
|
private const val ESCAPE_BYTE = 0x77.toByte()
|
||||||
|
private const val ESCAPED_FRAME_DELIMITER = 0xDD.toByte()
|
||||||
|
private const val ESCAPED_ESCAPE_BYTE = 0xEE.toByte()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when parsing a Combo frame fails.
|
||||||
|
*
|
||||||
|
* @param message The detail message.
|
||||||
|
*/
|
||||||
|
class FrameParseException(message: String) : ComboException(message)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses incoming streaming data to isolate frames and extract their payload.
|
||||||
|
*
|
||||||
|
* The Combo uses RFCOMM for communication, which is a stream based communication
|
||||||
|
* channel, not a datagram based one. Therefore, it is necessary to use some sort
|
||||||
|
* of framing mechanism to denote where frames begin and end.
|
||||||
|
*
|
||||||
|
* This class parses incoming data to detect the beginning of a frame. Then, it
|
||||||
|
* continues to parse data until it detects the end of the current frame. At that
|
||||||
|
* point, it extracts the payload from inside that frame, and returns it. This is
|
||||||
|
* done by [parseFrame].
|
||||||
|
*
|
||||||
|
* The payload is a transport layer packet. See [TransportLayerIO.Packet] for details
|
||||||
|
* about those.
|
||||||
|
*/
|
||||||
|
class ComboFrameParser {
|
||||||
|
/**
|
||||||
|
* Resets the internal state of the parser, discarding any accumulated data.
|
||||||
|
*
|
||||||
|
* This readies the parser for an entirely new transmission.
|
||||||
|
*/
|
||||||
|
fun reset() {
|
||||||
|
accumulationBuffer.clear()
|
||||||
|
currentReadOffset = 0
|
||||||
|
frameStarted = false
|
||||||
|
frameStartOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes incoming data into the parser's accumulation buffer for parsing.
|
||||||
|
*
|
||||||
|
* Call this only when [parseFrame] returns null, since incoming data blocks
|
||||||
|
* may contain more than one frame, meaning that the data passed to one
|
||||||
|
* [pushData] call may allow for multiple [parseFrame] calls that return
|
||||||
|
* parsed payload.
|
||||||
|
*
|
||||||
|
* @param data Data to push into the parser.
|
||||||
|
*/
|
||||||
|
fun pushData(data: List<Byte>) {
|
||||||
|
accumulationBuffer.addAll(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses previously accumulated data and extracts a frame if one is detected.
|
||||||
|
*
|
||||||
|
* This searches the accumulated data for a frame delimiter character. If one
|
||||||
|
* is found, the parser continues to look for a second delimiter. These two
|
||||||
|
* delimiters then denote the beginning and end of a frame. At that point, this
|
||||||
|
* function will extract the payload in between the delimiters.
|
||||||
|
*
|
||||||
|
* This function also takes care of un-escaping characters if necessary.
|
||||||
|
*
|
||||||
|
* If there currently is no complete frame in the accumulation buffer, this
|
||||||
|
* returns null. In that case, the user is supposed to call [pushData] to place
|
||||||
|
* more data into the accumulation buffer to parse.
|
||||||
|
*
|
||||||
|
* @return Payload of a detected frame, or null if no complete frame was
|
||||||
|
* currently found.
|
||||||
|
* @throws FrameParseException in case of invalid data.
|
||||||
|
*/
|
||||||
|
fun parseFrame(): List<Byte>? {
|
||||||
|
// The part that begins at currentReadOffset is not yet parsed.
|
||||||
|
// Look through it to see if a frame delimiter can be found.
|
||||||
|
while (currentReadOffset < accumulationBuffer.size) {
|
||||||
|
// Get the next byte. We use readNextFrameByte() here to handle
|
||||||
|
// escaped bytes.
|
||||||
|
val currentByteInfo = readNextFrameByte(currentReadOffset)
|
||||||
|
|
||||||
|
// Get the current byte. It is set to null when readNextFrameByte()
|
||||||
|
// finds an escape byte, but there's currently no more data after
|
||||||
|
// that byte. See readNextFrameByte() for more about this.
|
||||||
|
val currentByte: Byte = currentByteInfo.first ?: return null
|
||||||
|
val currentByteWasEscaped = currentByteInfo.third
|
||||||
|
|
||||||
|
val oldReadOffset = currentReadOffset
|
||||||
|
currentReadOffset = currentByteInfo.second
|
||||||
|
|
||||||
|
if (frameStarted) {
|
||||||
|
// The start of a frame was previously detected. Continue
|
||||||
|
// to parse data until either the end of the current accumulated
|
||||||
|
// data is reached or a frame delimiter byte is found.
|
||||||
|
|
||||||
|
if (currentByte == FRAME_DELIMITER) {
|
||||||
|
// Found a frame delimiter byte. If it wasn't present as
|
||||||
|
// an escaped byte, it means it actually does delimit
|
||||||
|
// the end of the current frame. if so, extract its payload.
|
||||||
|
|
||||||
|
if (currentByteWasEscaped) {
|
||||||
|
// In this case, the byte was escaped, so it is part
|
||||||
|
// of the payload, and not the end of the current frame.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the frame's payload, un-escaping any escaped
|
||||||
|
// bytes inside (done by the readNextFrameByte() call).
|
||||||
|
val framePayload = ArrayList<Byte>()
|
||||||
|
val frameEndOffset = currentReadOffset - 1
|
||||||
|
var frameReadOffset = frameStartOffset
|
||||||
|
while (frameReadOffset < frameEndOffset) {
|
||||||
|
val nextByteInfo = readNextFrameByte(frameReadOffset)
|
||||||
|
frameReadOffset = nextByteInfo.second
|
||||||
|
framePayload.add(nextByteInfo.first!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// After extracting the frame, remove its data from the
|
||||||
|
// accumulation buffer and keep track of what data is left
|
||||||
|
// in there. We need to keep that remainder around, since
|
||||||
|
// it may be the start of a new frame.
|
||||||
|
accumulationBuffer.subList(0, currentReadOffset).clear()
|
||||||
|
|
||||||
|
frameStarted = false
|
||||||
|
currentReadOffset = 0
|
||||||
|
|
||||||
|
return framePayload
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No frame start was detected so far. Combo transmissions
|
||||||
|
// pack frames seamlessly together, meaning that as soon as
|
||||||
|
// a frame delimiter denotes the end of a frame, or an RFCOMM
|
||||||
|
// connection is freshly set up, the next byte can only be
|
||||||
|
// a frame delimiter. Anything else would indicate bogus data.
|
||||||
|
// Also, in this case, we don't assume that bytes could be
|
||||||
|
// escaped, since escaping only makes sense _within_ a frame.
|
||||||
|
|
||||||
|
if (currentByte == FRAME_DELIMITER) {
|
||||||
|
frameStarted = true
|
||||||
|
frameStartOffset = currentReadOffset
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
throw FrameParseException(
|
||||||
|
"Found non-delimiter byte ${currentByte.toHexString(2)} " +
|
||||||
|
"outside of frames (surrounding context: ${accumulationBuffer.toHexStringWithContext(oldReadOffset)})"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readNextFrameByte(readOffset: Int): Triple<Byte?, Int, Boolean> {
|
||||||
|
// The code here reads bytes from the accumulation buffer. If the
|
||||||
|
// start of a frame was detected, it also handles un-escaping
|
||||||
|
// escaped bytes (see the explanation at the top of this source).
|
||||||
|
// It returns a triple of the parsed byte, the updated read offset,
|
||||||
|
// and a boolean that is true if the read byte was present in escaped
|
||||||
|
// escaped form in the accumulated data. The latter is important to
|
||||||
|
// be able to distinguish actual frame delimiters and escape bytes
|
||||||
|
// from payload bytes that happen to have the same value as these
|
||||||
|
// special bytes. In case no byte could be parsed, null is returned.
|
||||||
|
|
||||||
|
// We can't read anything, since there is no data that can be parsed.
|
||||||
|
// Return null as byte value to indicate to the caller that we need
|
||||||
|
// to accumulate more data.
|
||||||
|
if (readOffset >= accumulationBuffer.size)
|
||||||
|
return Triple(null, readOffset, false)
|
||||||
|
|
||||||
|
val curByte = accumulationBuffer[readOffset]
|
||||||
|
|
||||||
|
// If we are outside of a frame, just return the current byte directly.
|
||||||
|
// Outside of a frame, only frame delimiter bytes should exist anyway.
|
||||||
|
if (!frameStarted)
|
||||||
|
return Triple(curByte, readOffset + 1, false)
|
||||||
|
|
||||||
|
return if (curByte == ESCAPE_BYTE) {
|
||||||
|
// We found an escape byte. We need the next byte to see what
|
||||||
|
// particular byte was escaped by it.
|
||||||
|
|
||||||
|
if (readOffset == (accumulationBuffer.size - 1)) {
|
||||||
|
// Can't determine the output yet since we need 2 bytes to determine
|
||||||
|
// what the output should be, and currently we only have one byte;
|
||||||
|
// the escape byte. Return null as byte value to indicate to the
|
||||||
|
// caller that we need to accumulate more data.
|
||||||
|
Triple(null, readOffset, false)
|
||||||
|
} else {
|
||||||
|
when (accumulationBuffer[readOffset + 1]) {
|
||||||
|
ESCAPED_FRAME_DELIMITER -> Triple(FRAME_DELIMITER, readOffset + 2, true)
|
||||||
|
ESCAPED_ESCAPE_BYTE -> Triple(ESCAPE_BYTE, readOffset + 2, true)
|
||||||
|
else -> {
|
||||||
|
throw FrameParseException(
|
||||||
|
"Found escape byte, but followup byte ${accumulationBuffer[readOffset + 1].toHexString(2)} " +
|
||||||
|
"is not a valid combination (surrounding context: ${accumulationBuffer.toHexStringWithContext(readOffset + 1)})"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// This is not an escape byte, so just output it directly.
|
||||||
|
Triple(curByte, readOffset + 1, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accumulationBuffer = ArrayList<Byte>()
|
||||||
|
private var currentReadOffset: Int = 0
|
||||||
|
private var frameStarted: Boolean = false
|
||||||
|
private var frameStartOffset: Int = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a Combo frame out of the given payload.
|
||||||
|
*
|
||||||
|
* The Combo uses RFCOMM for communication, which is a stream based communication
|
||||||
|
* channel, not a datagram based one. Therefore, it is necessary to use some sort
|
||||||
|
* of framing mechanism to denote where frames begin and end.
|
||||||
|
*
|
||||||
|
* This function places payload (a list of bytes) inside a frame so that it is
|
||||||
|
* suitable for transmission over RFCOMM.
|
||||||
|
*
|
||||||
|
* The reverse functionality is provided by the [ComboFrameParser] class.
|
||||||
|
*
|
||||||
|
* The payload is a transport layer packet. See [TransportLayerIO.Packet] for
|
||||||
|
* details about those.
|
||||||
|
*
|
||||||
|
* @return Framed version of this payload.
|
||||||
|
*/
|
||||||
|
fun List<Byte>.toComboFrame(): List<Byte> {
|
||||||
|
val escapedFrameData = ArrayList<Byte>()
|
||||||
|
|
||||||
|
escapedFrameData.add(FRAME_DELIMITER)
|
||||||
|
|
||||||
|
for (inputByte in this) {
|
||||||
|
when (inputByte) {
|
||||||
|
FRAME_DELIMITER -> {
|
||||||
|
escapedFrameData.add(ESCAPE_BYTE)
|
||||||
|
escapedFrameData.add(ESCAPED_FRAME_DELIMITER)
|
||||||
|
}
|
||||||
|
|
||||||
|
ESCAPE_BYTE -> {
|
||||||
|
escapedFrameData.add(ESCAPE_BYTE)
|
||||||
|
escapedFrameData.add(ESCAPED_ESCAPE_BYTE)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> escapedFrameData.add(inputByte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
escapedFrameData.add(FRAME_DELIMITER)
|
||||||
|
|
||||||
|
return escapedFrameData
|
||||||
|
}
|
|
@ -0,0 +1,203 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for Combo IO operations.
|
||||||
|
*
|
||||||
|
* The send and receive functions are suspending functions to be
|
||||||
|
* able to fo pairing and regular sessions by using coroutines.
|
||||||
|
* Subclasses concern themselves with adapting blocking IO APIs
|
||||||
|
* and framing the data in some way. Subclasses can also choose
|
||||||
|
* to use Flows, Channels, and RxJava/RxKotlin mechanisms
|
||||||
|
* if they wish.
|
||||||
|
*
|
||||||
|
* IO errors in subclasses are communicated to callers by
|
||||||
|
* throwing exceptions.
|
||||||
|
*/
|
||||||
|
interface ComboIO {
|
||||||
|
/**
|
||||||
|
* Sends the given block of bytes, suspending the coroutine until it is done.
|
||||||
|
*
|
||||||
|
* This function either transmits all of the bytes, or throws an
|
||||||
|
* exception if this fails. Partial transmissions are not done.
|
||||||
|
* An exception is also thrown if sending fails due to something
|
||||||
|
* that's not an error, like when a connection is closed.
|
||||||
|
*
|
||||||
|
* If an exception is thrown, the data is to be considered not
|
||||||
|
* having been sent.
|
||||||
|
*
|
||||||
|
* @param dataToSend The data to send. Must not be empty.
|
||||||
|
* @throws CancellationException if sending is aboted due to
|
||||||
|
* a terminated IO, typically due to some sort of
|
||||||
|
* disconnect function call. (This is _not_ thrown if
|
||||||
|
* the remote side closes the connection! In such a
|
||||||
|
* case, ComboIOException is thrown instead.)
|
||||||
|
* @throws ComboIOException if sending fails.
|
||||||
|
* @throws IllegalStateException if this object is in a state
|
||||||
|
* that does not permit sending, such as a device
|
||||||
|
* that has been shut down or isn't connected.
|
||||||
|
*/
|
||||||
|
suspend fun send(dataToSend: List<Byte>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives a block of bytes, suspending the coroutine until it finishes.
|
||||||
|
*
|
||||||
|
* If receiving fails, an exception is thrown. An exception
|
||||||
|
* is also thrown if receiving fails due to something that's not
|
||||||
|
* an error, like when a connection is closed.
|
||||||
|
*
|
||||||
|
* @return Received block of bytes. This is never empty.
|
||||||
|
* @throws CancellationException if receiving is aboted due to
|
||||||
|
* a terminated IO, typically due to some sort of
|
||||||
|
* disconnect function call. (This is _not_ thrown if
|
||||||
|
* the remote side closes the connection! In such a
|
||||||
|
* case, ComboIOException is thrown instead.)
|
||||||
|
* @throws ComboIOException if receiving fails.
|
||||||
|
* @throws IllegalStateException if this object is in a state
|
||||||
|
* that does not permit receiving, such as a device
|
||||||
|
* that has been shut down or isn't connected.
|
||||||
|
*/
|
||||||
|
suspend fun receive(): List<Byte>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract combo IO class for adapting blocking IO APIs.
|
||||||
|
*
|
||||||
|
* The implementations of the ComboIO interface send and receive
|
||||||
|
* calls internally use blocking send/receive functions and run
|
||||||
|
* them in the IO context to make sure their blocking behavior
|
||||||
|
* does not block the coroutine. Subclasses must implement
|
||||||
|
* blockingSend and blockingReceive.
|
||||||
|
*
|
||||||
|
* @property ioDispatcher [CoroutineDispatcher] where the
|
||||||
|
* [blockingSend] and [blockingReceive] calls shall take place.
|
||||||
|
* These calls may block threads for a nontrivial amount of time,
|
||||||
|
* so the dispatcher must be suitable for that. On JVM and Android
|
||||||
|
* platforms, there is an IO dispatcher for this.
|
||||||
|
*/
|
||||||
|
abstract class BlockingComboIO(val ioDispatcher: CoroutineDispatcher) : ComboIO {
|
||||||
|
final override suspend fun send(dataToSend: List<Byte>) {
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
blockingSend(dataToSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final override suspend fun receive(): List<Byte> {
|
||||||
|
return withContext(ioDispatcher) {
|
||||||
|
blockingReceive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks the calling thread until the given block of bytes is fully sent.
|
||||||
|
*
|
||||||
|
* In case of an error, or some other reason why sending
|
||||||
|
* cannot be done (like a closed connection), an exception
|
||||||
|
* is thrown.
|
||||||
|
*
|
||||||
|
* This function sends atomically. Either, the entire data
|
||||||
|
* is sent, or none of it is sent (the latter happens in
|
||||||
|
* case of an exception).
|
||||||
|
*
|
||||||
|
* @param dataToSend The data to send. Must not be empty.
|
||||||
|
* @throws CancellationException if sending is aboted due to
|
||||||
|
* a terminated IO, typically due to some sort of
|
||||||
|
* disconnect function call. (This is _not_ thrown if
|
||||||
|
* the remote side closes the connection! In such a
|
||||||
|
* case, ComboIOException is thrown instead.)
|
||||||
|
* @throws ComboIOException if sending fails.
|
||||||
|
* @throws IllegalStateException if this object is in a state
|
||||||
|
* that does not permit sending, such as a device
|
||||||
|
* that has been shut down or isn't connected.
|
||||||
|
*/
|
||||||
|
abstract fun blockingSend(dataToSend: List<Byte>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks the calling thread until a given block of bytes is received.
|
||||||
|
*
|
||||||
|
* In case of an error, or some other reason why receiving
|
||||||
|
* cannot be done (like a closed connection), an exception
|
||||||
|
* is thrown.
|
||||||
|
*
|
||||||
|
* @return Received block of bytes. This is never empty.
|
||||||
|
*
|
||||||
|
* @throws CancellationException if receiving is aboted due to
|
||||||
|
* a terminated IO, typically due to some sort of
|
||||||
|
* disconnect function call. (This is _not_ thrown if
|
||||||
|
* the remote side closes the connection! In such a
|
||||||
|
* case, ComboIOException is thrown instead.)
|
||||||
|
* @throws ComboIOException if receiving fails.
|
||||||
|
* @throws IllegalStateException if this object is in a state
|
||||||
|
* that does not permit receiving, such as a device
|
||||||
|
* that has been shut down or isn't connected.
|
||||||
|
*/
|
||||||
|
abstract fun blockingReceive(): List<Byte>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ComboIO subclass that puts data into Combo frames and uses
|
||||||
|
* another ComboIO object for the actual transmission.
|
||||||
|
*
|
||||||
|
* This is intended to be used for composing framed IO with
|
||||||
|
* another ComboIO subclass. This allows for easily adding
|
||||||
|
* Combo framing without having to modify ComboIO subclasses
|
||||||
|
* or having to manually integrate the Combo frame parser.
|
||||||
|
*
|
||||||
|
* @property io Underlying ComboIO to use for sending
|
||||||
|
* and receiving&parsing framed data.
|
||||||
|
*/
|
||||||
|
class FramedComboIO(private val io: ComboIO) : ComboIO {
|
||||||
|
override suspend fun send(dataToSend: List<Byte>) = io.send(dataToSend.toComboFrame())
|
||||||
|
|
||||||
|
override suspend fun receive(): List<Byte> {
|
||||||
|
try {
|
||||||
|
// Loop until a full frame is parsed, an
|
||||||
|
// error occurs, or the job is canceled.
|
||||||
|
// In the latter two cases, an exception
|
||||||
|
// is thrown, so we won't end up in an
|
||||||
|
// infinite loop here.
|
||||||
|
while (true) {
|
||||||
|
val parseResult = frameParser.parseFrame()
|
||||||
|
if (parseResult == null) {
|
||||||
|
frameParser.pushData(io.receive())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseResult
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
frameParser.reset()
|
||||||
|
throw e
|
||||||
|
} catch (e: ComboIOException) {
|
||||||
|
frameParser.reset()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the internal frame parser.
|
||||||
|
*
|
||||||
|
* Resetting means that any partial frame data inside
|
||||||
|
* the parse is discarded. This is useful if this IO
|
||||||
|
* object is reused.
|
||||||
|
*/
|
||||||
|
fun reset() {
|
||||||
|
frameParser.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val frameParser = ComboFrameParser()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for exceptions related to IO operations from/to the Combo.
|
||||||
|
*
|
||||||
|
* @param message The detail message.
|
||||||
|
* @param cause Throwable that further describes the cause of the exception.
|
||||||
|
*/
|
||||||
|
open class ComboIOException(message: String?, cause: Throwable?) : ComboException(message, cause) {
|
||||||
|
constructor(message: String) : this(message, null)
|
||||||
|
constructor(cause: Throwable) : this(null, cause)
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
object Constants {
|
||||||
|
/**
|
||||||
|
* Hard-coded Bluetooth pairing PIN used by the Combo.
|
||||||
|
*
|
||||||
|
* This is not to be confused with the PIN that is displayed on
|
||||||
|
* the Combo's LCD when pairing it with a device. That other PIN
|
||||||
|
* is part of a non-standard, Combo specific pairing process that
|
||||||
|
* happens _after_ the Bluetooth pairing has been completed.
|
||||||
|
*/
|
||||||
|
const val BT_PAIRING_PIN = "}gZ='GD?gj2r|B}>"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDP service record name the Combo searches for during discovery.
|
||||||
|
*
|
||||||
|
* Any SerialPort SDP service record that is not named like
|
||||||
|
* this is ignored by the Combo.
|
||||||
|
*/
|
||||||
|
const val BT_SDP_SERVICE_NAME = "SerialLink"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client software version number for TL_REQUEST_ID packets.
|
||||||
|
*
|
||||||
|
* This version number is used as the payload of TL_REQUEST_ID
|
||||||
|
* packets that are sent to the Combo during the pairing process.
|
||||||
|
*/
|
||||||
|
const val CLIENT_SOFTWARE_VERSION = 10504
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serial number for AL_CTRL_CONNECT packets.
|
||||||
|
*
|
||||||
|
* This serial number is used as the payload of AL_CTRL_CONNECT
|
||||||
|
* packets that are sent to the Combo when connecting to it.
|
||||||
|
*/
|
||||||
|
const val APPLICATION_LAYER_CONNECT_SERIAL_NUMBER = 12345
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
||||||
|
// Dispatcher that enforces sequential execution of coroutines,
|
||||||
|
// thus disallowing parallelism. This is important, since parallel
|
||||||
|
// IO is not supported by the Combo and only causes IO errors.
|
||||||
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
|
internal val sequencedDispatcher = Dispatchers.Default.limitedParallelism(1)
|
|
@ -0,0 +1,71 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
const val DISPLAY_FRAME_WIDTH = 96
|
||||||
|
const val DISPLAY_FRAME_HEIGHT = 32
|
||||||
|
|
||||||
|
// One frame consists of 96x32 pixels.
|
||||||
|
const val NUM_DISPLAY_FRAME_PIXELS = DISPLAY_FRAME_WIDTH * DISPLAY_FRAME_HEIGHT
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class containing a 96x32 pixel black&white Combo display frame.
|
||||||
|
*
|
||||||
|
* These frames are sent by the Combo when it is operating
|
||||||
|
* in the remote terminal (RT) mode.
|
||||||
|
*
|
||||||
|
* The pixels are stored in row-major order. One boolean equals
|
||||||
|
* one pixel.
|
||||||
|
*
|
||||||
|
* Note that this is not the layout of the pixels as transmitted
|
||||||
|
* by the Combo. Rather, the pixels are rearranged in a layout
|
||||||
|
* that is more commonly used and easier to work with.
|
||||||
|
*
|
||||||
|
* @property displayFramePixels Pixels of the display frame to use.
|
||||||
|
* The array has to have exactly NUM_DISPLAY_FRAME_PIXELS
|
||||||
|
* booleans.
|
||||||
|
*/
|
||||||
|
data class DisplayFrame(val displayFramePixels: BooleanArray) : Iterable<Boolean> {
|
||||||
|
/**
|
||||||
|
* Number of display frame pixels.
|
||||||
|
*
|
||||||
|
* This mainly exists to make this class compatible with
|
||||||
|
* code that operates on collections.
|
||||||
|
*/
|
||||||
|
val size = NUM_DISPLAY_FRAME_PIXELS
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(displayFramePixels.size == size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the pixel at the given coordinates.
|
||||||
|
*
|
||||||
|
* @param x X coordinate. Valid range is 0..95 (inclusive).
|
||||||
|
* @param y Y coordinate. Valid range is 0..31 (inclusive).
|
||||||
|
* @return true if the pixel at these coordinates is set,
|
||||||
|
* false if it is cleared.
|
||||||
|
*/
|
||||||
|
fun getPixelAt(x: Int, y: Int) = displayFramePixels[x + y * DISPLAY_FRAME_WIDTH]
|
||||||
|
|
||||||
|
operator fun get(index: Int) = displayFramePixels[index]
|
||||||
|
|
||||||
|
override operator fun iterator() = displayFramePixels.iterator()
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other == null || this::class != other::class) return false
|
||||||
|
|
||||||
|
other as DisplayFrame
|
||||||
|
|
||||||
|
if (!displayFramePixels.contentEquals(other.displayFramePixels)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return displayFramePixels.contentHashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display frame filled with empty pixels. Useful for initializations.
|
||||||
|
*/
|
||||||
|
val NullDisplayFrame = DisplayFrame(BooleanArray(NUM_DISPLAY_FRAME_PIXELS) { false })
|
|
@ -0,0 +1,165 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
private const val NUM_DISPLAY_FRAME_BYTES = NUM_DISPLAY_FRAME_PIXELS / 8
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class for assembling RT_DISPLAY application layer packet rows to a complete display frame.
|
||||||
|
*
|
||||||
|
* RT_DISPLAY packets contain 1/4th of a frame. These subsets are referred to as "rows".
|
||||||
|
* Since a frame contains 96x32 pixels, each row contains 96x8 pixels.
|
||||||
|
*
|
||||||
|
* This class assembles these rows into complete frames. To that end, it has to convert
|
||||||
|
* the layout the pixels are arranged in into a more intuitive column-major bitmap layout.
|
||||||
|
* The result is a [DisplayFrame] instance with all of the frame's pixels in row-major
|
||||||
|
* layout. See the [DisplayFrame] documentation for more details about its layout.
|
||||||
|
*
|
||||||
|
* The class is designed for streaming use. This means that it can be continuously fed
|
||||||
|
* the contents of RT_DISPLAY packets, and it will keep producing frames once it has
|
||||||
|
* enough data to complete a frame. When it completed one, it returns the frame, and
|
||||||
|
* wipes its internal row collection, allowing it to start from scratch to be able to
|
||||||
|
* begin completing a new frame.
|
||||||
|
*
|
||||||
|
* If frame data with a different index is fed into the assembler before the frame completion
|
||||||
|
* is fully done, it also resets itself. The purpose of the index is to define what frame
|
||||||
|
* each row belongs to. That way, it is assured that rows of different frames cannot be
|
||||||
|
* mixed up together. Without the index, if for some reason one RT_DISPLAY packet isn't
|
||||||
|
* received, the assembler would assemble a frame incorrectly.
|
||||||
|
*
|
||||||
|
* In practice, it is not necessary to keep this in mind. Just feed data into the assembler
|
||||||
|
* by calling its main function, [DisplayFrameAssembler.processRTDisplayPayload]. When that
|
||||||
|
* function returns null, just keep going. When it returns a [DisplayFrame] instance, then
|
||||||
|
* this is a complete frame that can be further processed / analyzed.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* val assembler = DisplayFrameAssembler()
|
||||||
|
*
|
||||||
|
* while (receivingPackets()) {
|
||||||
|
* val rtDisplayPayload = applicationLayer.parseRTDisplayPacket(packet)
|
||||||
|
*
|
||||||
|
* val displayFrame = assembler.processRTDisplayPayload(
|
||||||
|
* rtDisplayPayload.index,
|
||||||
|
* rtDisplayPayload.row,
|
||||||
|
* rtDisplayPayload.pixels
|
||||||
|
* )
|
||||||
|
* if (displayFrame != null) {
|
||||||
|
* // Output the completed frame
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class DisplayFrameAssembler {
|
||||||
|
private val rtDisplayFrameRows = mutableListOf<List<Byte>?>(null, null, null, null)
|
||||||
|
private var currentIndex: Int? = null
|
||||||
|
private var numRowsLeftUnset = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main assembly function.
|
||||||
|
*
|
||||||
|
* This feeds RT_DISPLAY data into the assembler. The index is the RT_DISPLAY
|
||||||
|
* index value. The row is a value in the 0-3 range, specifying what row this
|
||||||
|
* is about. rowBytes is the byte list containing the pixel bytes from the packet.
|
||||||
|
* This list must contain exactly 96 bytes, since the whole frame is made of
|
||||||
|
* 384 bytes, and there are 4 rows, so each row contains 384 / 4 = 96 bytes.
|
||||||
|
*
|
||||||
|
* (The incoming frame rows store pixels as bits, not as booleans, which is
|
||||||
|
* why one frame consists of 384 bytes. 96 * 32 pixels, 8 bits per byte,
|
||||||
|
* one bits per pixel -> 96 * 32 / 8 = 384 bytes.)
|
||||||
|
*
|
||||||
|
* @param index RT_DISPLAY index value.
|
||||||
|
* @param row Row number, in the 0-3 range (inclusive).
|
||||||
|
* @param rowBytes RT_DISPLAY pixel bytes.
|
||||||
|
* @return null if no frame could be completed yet. A DisplayFrame instance
|
||||||
|
* if the assembler had enough data to complete a frame.
|
||||||
|
*/
|
||||||
|
fun processRTDisplayPayload(index: Int, row: Int, rowBytes: List<Byte>): DisplayFrame? {
|
||||||
|
require(rowBytes.size == NUM_DISPLAY_FRAME_BYTES / 4) {
|
||||||
|
"Expected ${NUM_DISPLAY_FRAME_BYTES / 4} bytes in rowBytes list, got ${rowBytes.size} (index: $index row: $row)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we got data from a different frame. If so, we have to throw
|
||||||
|
// away any previously collected data, since it belongs to a previous frame.
|
||||||
|
if (index != currentIndex) {
|
||||||
|
reset()
|
||||||
|
currentIndex = index
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we actually are _adding_ a new row, decrement the numRowsLeftUnset
|
||||||
|
// counter. That counter specifies how many row entries are still set to
|
||||||
|
// null. Once the counter reaches zero, it means the rtDisplayFrameRows
|
||||||
|
// list is fully populated, and we can complete the frame.
|
||||||
|
if (rtDisplayFrameRows[row] == null)
|
||||||
|
numRowsLeftUnset -= 1
|
||||||
|
|
||||||
|
rtDisplayFrameRows[row] = rowBytes
|
||||||
|
|
||||||
|
return if (numRowsLeftUnset == 0) {
|
||||||
|
val displayFrame = assembleDisplayFrame()
|
||||||
|
currentIndex = null
|
||||||
|
displayFrame
|
||||||
|
} else
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main assembly function.
|
||||||
|
*
|
||||||
|
* This is an overloaded variant of [processRTDisplayPayload] that accepts
|
||||||
|
* an [ApplicationLayer.RTDisplayPayload] instance instead of the individual
|
||||||
|
* index, row, pixels arguments.
|
||||||
|
*/
|
||||||
|
fun processRTDisplayPayload(rtDisplayPayload: ApplicationLayer.RTDisplayPayload): DisplayFrame? =
|
||||||
|
processRTDisplayPayload(rtDisplayPayload.index, rtDisplayPayload.row, rtDisplayPayload.rowBytes)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the state of the assembler.
|
||||||
|
*
|
||||||
|
* This resets internal states to their initial values, discarding any
|
||||||
|
* partial frames that might have been received earlier.
|
||||||
|
*
|
||||||
|
* Usually, this is only called internally. However, it is useful to call
|
||||||
|
* this if the outside code itself got reset, for example after a reconnect
|
||||||
|
* event. In such situations, it is a good idea to discard any existing state.
|
||||||
|
*/
|
||||||
|
fun reset() {
|
||||||
|
rtDisplayFrameRows.fill(null)
|
||||||
|
numRowsLeftUnset = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assembleDisplayFrame(): DisplayFrame {
|
||||||
|
val displayFramePixels = BooleanArray(NUM_DISPLAY_FRAME_PIXELS) { false }
|
||||||
|
|
||||||
|
// (Note: Display frame rows are not to be confused with pixel rows. See the
|
||||||
|
// class description for details about the display frame rows.)
|
||||||
|
// Pixels are stored in the RT_DISPLAY display frame rows in a column-major
|
||||||
|
// order. Also, the rightmost column is actually stored first, and the leftmost
|
||||||
|
// one last. And since each display frame row contains 1/4th of the entire display
|
||||||
|
// frame, this means it contains 8 pixel rows. This in turn means that this
|
||||||
|
// layout stores one byte per column. So, the first byte in the display frame row
|
||||||
|
// contains the pixels from (x 95 y 0) to (x 95 y 7). The second byte contains
|
||||||
|
// pixels from (x 94 y 0) to (x 94 y 7) etc.
|
||||||
|
for (row in 0 until 4) {
|
||||||
|
val rtDisplayFrameRow = rtDisplayFrameRows[row]!!
|
||||||
|
for (column in 0 until DISPLAY_FRAME_WIDTH) {
|
||||||
|
// Get the 8 pixels from the current column.
|
||||||
|
// We invert the index by subtracting it from
|
||||||
|
// 95, since, as described above, the first
|
||||||
|
// byte actually contains the rightmost column.
|
||||||
|
val byteWithColumnPixels = rtDisplayFrameRow[95 - column].toPosInt()
|
||||||
|
// Scan the 8 pixels in the selected column.
|
||||||
|
for (y in 0 until 8) {
|
||||||
|
// Isolate the current pixel.
|
||||||
|
val pixel = ((byteWithColumnPixels and (1 shl y)) != 0)
|
||||||
|
|
||||||
|
if (pixel) {
|
||||||
|
val destPixelIndex = column + (y + row * 8) * DISPLAY_FRAME_WIDTH
|
||||||
|
displayFramePixels[destPixelIndex] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DisplayFrame(displayFramePixels)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,216 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple directed cyclic graph data structure.
|
||||||
|
*
|
||||||
|
* This class is intended to be used primarily for navigating through
|
||||||
|
* the graph and finding shortest paths through it. For this reason,
|
||||||
|
* there is an overall collection of all nodes (the [nodes] [Map]),
|
||||||
|
* while there is no corresponding overall collection of edges.
|
||||||
|
*
|
||||||
|
* Edges are all directional. If there is a bidirectional connection
|
||||||
|
* between nodes, it must be modeled with two directional edges.
|
||||||
|
*
|
||||||
|
* Nodes and edges have associated user defined values. Node values
|
||||||
|
* must be unique, since they are used as a key in [nodes].
|
||||||
|
*
|
||||||
|
* Note: Nodes and edges can only be added, not removed. This
|
||||||
|
* class isn't intended for graphs that change after construction.
|
||||||
|
*
|
||||||
|
* Constructing a graph is usually done with [apply], like this:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* val myGraph = Graph<Int, String>().apply {
|
||||||
|
* // Create some nodes with integer values
|
||||||
|
* val node1 = node(10)
|
||||||
|
* val node2 = node(20)
|
||||||
|
* val node3 = node(30)
|
||||||
|
* val node4 = node(40)
|
||||||
|
*
|
||||||
|
* // Connect two nodes in one direction (from node1 to node2) by
|
||||||
|
* // creating one edge. The new edge's value is "edge_1_2".
|
||||||
|
* connectDirectionally("edge_1_2", node1, node2)
|
||||||
|
* // Connect two nodes in both directions by creating two edges,
|
||||||
|
* // with the node2->node3 edge's value being "edge_2_3", and
|
||||||
|
* // the node3->node2 edge's value being "edge_3_2".
|
||||||
|
* connectBidirectionally("edge_2_3", "edge_3_2", node2, node3)
|
||||||
|
* // Connect more than 2 nodes in one direction. Two new edges
|
||||||
|
* // are created, node1->node2 and node2->node4. Both edges have
|
||||||
|
* // the same value "edge_N".
|
||||||
|
* connectDirectionally("edge_N", node1, node2, node4)
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class Graph<NodeValue, EdgeValue> {
|
||||||
|
/**
|
||||||
|
* Directional edge in the graph.
|
||||||
|
*
|
||||||
|
* @param value User defined value associated with this edge.
|
||||||
|
* @param targetNode Node this edge leads to.
|
||||||
|
*/
|
||||||
|
inner class Edge(val value: EdgeValue, val targetNode: Node)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node in the graph.
|
||||||
|
*
|
||||||
|
* @param value User defined value associated with this node.
|
||||||
|
*/
|
||||||
|
inner class Node(val value: NodeValue) {
|
||||||
|
private val _edges = mutableListOf<Edge>()
|
||||||
|
val edges: List<Edge> = _edges
|
||||||
|
|
||||||
|
internal fun connectTo(targetNode: Node, edgeValue: EdgeValue): Edge {
|
||||||
|
val newEdge = Edge(edgeValue, targetNode)
|
||||||
|
_edges.add(newEdge)
|
||||||
|
return newEdge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _nodes = mutableMapOf<NodeValue, Node>()
|
||||||
|
val nodes: Map<NodeValue, Node> = _nodes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new [Node] with the given user defined [value].
|
||||||
|
*/
|
||||||
|
fun node(value: NodeValue): Node {
|
||||||
|
val newNode = Node(value)
|
||||||
|
_nodes[value] = newNode
|
||||||
|
return newNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segment of a path found by [findShortestPath].
|
||||||
|
*
|
||||||
|
* An edge that is part of the shortest path equals one path segment.
|
||||||
|
* [targetNodeValue] is the [Graph.Edge.targetNode] field of that edge,
|
||||||
|
* [edgeValue] the user-defined value of that edge.
|
||||||
|
*/
|
||||||
|
data class PathSegment<NodeValue, EdgeValue>(val targetNodeValue: NodeValue, val edgeValue: EdgeValue)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience [findShortestPath] overload based on user-defined node values.
|
||||||
|
*
|
||||||
|
* This is a shortcut for accessing "from" and "to" nodes
|
||||||
|
* from [Graph.node] based on user-defined node values.
|
||||||
|
* If values are specified that are not associated with
|
||||||
|
* nodes, [IllegalArgumentException] is thrown.
|
||||||
|
*/
|
||||||
|
fun <NodeValue, EdgeValue> Graph<NodeValue, EdgeValue>.findShortestPath(
|
||||||
|
from: NodeValue,
|
||||||
|
to: NodeValue,
|
||||||
|
edgePredicate: (edgeValue: EdgeValue) -> Boolean = { true }
|
||||||
|
): List<PathSegment<NodeValue, EdgeValue>>? {
|
||||||
|
val fromNode = nodes[from] ?: throw IllegalArgumentException()
|
||||||
|
val toNode = nodes[to] ?: throw IllegalArgumentException()
|
||||||
|
return findShortestPath(fromNode, toNode, edgePredicate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the shortest path between two nodes.
|
||||||
|
*
|
||||||
|
* The path starts at [fromNode] and ends at [toNode]. If no path between
|
||||||
|
* these two nodes can be found, null is returned. If [fromNode] and
|
||||||
|
* [toNode] are the same, an empty list is returned. If no path can be
|
||||||
|
* found, null is returned.
|
||||||
|
*
|
||||||
|
* The [edgePredicate] decides whether an edge in the graph shall be
|
||||||
|
* traversed as part of the search. If the predicate returns false,
|
||||||
|
* the edge is skipped. This is useful for filtering out edges if the
|
||||||
|
* node they lead to is disabled/invalid for some reason. The predicate
|
||||||
|
* takes as its argument the value of the edge. The default predicate
|
||||||
|
* always returns true and thus allows all edges to be traversed.
|
||||||
|
*
|
||||||
|
* @param fromNode Start node of the shortest path.
|
||||||
|
* @param toNode End node of the shortest path.
|
||||||
|
* @param edgePredicate Predicate to apply to each edge during the search.
|
||||||
|
* @return Shortest path, or null if no such path exists.
|
||||||
|
*/
|
||||||
|
fun <NodeValue, EdgeValue> Graph<NodeValue, EdgeValue>.findShortestPath(
|
||||||
|
fromNode: Graph<NodeValue, EdgeValue>.Node,
|
||||||
|
toNode: Graph<NodeValue, EdgeValue>.Node,
|
||||||
|
edgePredicate: (edgeValue: EdgeValue) -> Boolean = { true }
|
||||||
|
): List<PathSegment<NodeValue, EdgeValue>>? {
|
||||||
|
if (fromNode === toNode)
|
||||||
|
return listOf()
|
||||||
|
|
||||||
|
val visitedNodes = mutableListOf<Graph<NodeValue, EdgeValue>.Node>()
|
||||||
|
val path = mutableListOf<PathSegment<NodeValue, EdgeValue>>()
|
||||||
|
|
||||||
|
fun visitAdjacentNodes(node: Graph<NodeValue, EdgeValue>.Node): Boolean {
|
||||||
|
if (node in visitedNodes)
|
||||||
|
return false
|
||||||
|
|
||||||
|
for (edge in node.edges) {
|
||||||
|
if (edgePredicate(edge.value) && (edge.targetNode === toNode)) {
|
||||||
|
path.add(0, PathSegment(edge.targetNode.value, edge.value))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visitedNodes.add(node)
|
||||||
|
|
||||||
|
for (edge in node.edges) {
|
||||||
|
if (edgePredicate(edge.value) && visitAdjacentNodes(edge.targetNode)) {
|
||||||
|
path.add(0, PathSegment(edge.targetNode.value, edge.value))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (visitAdjacentNodes(fromNode))
|
||||||
|
path
|
||||||
|
else
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <NodeValue, EdgeValue> connectDirectionally(
|
||||||
|
fromToEdgeValue: EdgeValue,
|
||||||
|
vararg nodes: Graph<NodeValue, EdgeValue>.Node
|
||||||
|
) {
|
||||||
|
require(nodes.size >= 2)
|
||||||
|
|
||||||
|
for (i in 0 until (nodes.size - 1)) {
|
||||||
|
val fromNode = nodes[i]
|
||||||
|
val toNode = nodes[i + 1]
|
||||||
|
fromNode.connectTo(toNode, fromToEdgeValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <NodeValue, EdgeValue> connectBidirectionally(
|
||||||
|
fromToEdgeValue: EdgeValue,
|
||||||
|
toFromEdgeValue: EdgeValue,
|
||||||
|
vararg nodes: Graph<NodeValue, EdgeValue>.Node
|
||||||
|
) {
|
||||||
|
require(nodes.size >= 2)
|
||||||
|
|
||||||
|
for (i in 0 until (nodes.size - 1)) {
|
||||||
|
val fromNode = nodes[i]
|
||||||
|
val toNode = nodes[i + 1]
|
||||||
|
fromNode.connectTo(toNode, fromToEdgeValue)
|
||||||
|
toNode.connectTo(fromNode, toFromEdgeValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to dump a graph using GraphViz DOT notation.
|
||||||
|
*
|
||||||
|
* This can be used for visualizing a graph using common GraphViz tools.
|
||||||
|
*/
|
||||||
|
fun <NodeValue, EdgeValue> Graph<NodeValue, EdgeValue>.toDotGraph(graphName: String): String {
|
||||||
|
var dotGraphText = "strict digraph \"$graphName\" {\n"
|
||||||
|
|
||||||
|
for (node in nodes.values) {
|
||||||
|
for (edge in node.edges) {
|
||||||
|
val fromNodeValue = node.value
|
||||||
|
val toNodeValue = edge.targetNode.value
|
||||||
|
dotGraphText += " \"$fromNodeValue\"->\"$toNodeValue\" [ label=\"${edge.value}\" ]\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dotGraphText += "}\n"
|
||||||
|
|
||||||
|
return dotGraphText
|
||||||
|
}
|
|
@ -0,0 +1,129 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid log levels.
|
||||||
|
*
|
||||||
|
* VERBOSE is recommended for log lines that produce large amounts of log information
|
||||||
|
* since they are called constantly. One example would be a packet data dump.
|
||||||
|
*
|
||||||
|
* DEBUG is recommended for detailed information about the internals that does _not_
|
||||||
|
* show up all the time (unlike VERBOSE).
|
||||||
|
*
|
||||||
|
* INFO is to be used for important information.
|
||||||
|
*
|
||||||
|
* WARN is to be used for situations that could lead to errors or are otherwise
|
||||||
|
* of potential concern.
|
||||||
|
*
|
||||||
|
* ERROR is to be used for when an error occurs.
|
||||||
|
*/
|
||||||
|
enum class LogLevel(val str: String, val numericLevel: Int) {
|
||||||
|
VERBOSE("VERBOSE", 4),
|
||||||
|
DEBUG("DEBUG", 3),
|
||||||
|
INFO("INFO", 2),
|
||||||
|
WARN("WARN", 1),
|
||||||
|
ERROR("ERROR", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for backends that actually logs the given message.
|
||||||
|
*/
|
||||||
|
interface LoggerBackend {
|
||||||
|
/**
|
||||||
|
* Instructs the backend to log the given message.
|
||||||
|
*
|
||||||
|
* The tag and log level are provided so the backend can highlight
|
||||||
|
* these in its output in whatever way it wishes.
|
||||||
|
*
|
||||||
|
* In addition, a throwable can be logged in case the log line
|
||||||
|
* was caused by one. The [SingleTagLogger.invoke] call may have provided
|
||||||
|
* only a message string, or a throwable, or both, which is why both of these
|
||||||
|
* arguments are nullable.
|
||||||
|
*
|
||||||
|
* @param tag Tag for this message. Typically, this is the name of the
|
||||||
|
* class the log operation was performed in.
|
||||||
|
* @param level Log level of the given message.
|
||||||
|
* @param message Optional string containing the message to log.
|
||||||
|
* @param throwable Optional throwable.
|
||||||
|
*/
|
||||||
|
fun log(tag: String, level: LogLevel, throwable: Throwable?, message: String?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend that does not actually log anything. Logical equivalent of Unix' /dev/null.
|
||||||
|
*/
|
||||||
|
class NullLoggerBackend : LoggerBackend {
|
||||||
|
override fun log(tag: String, level: LogLevel, throwable: Throwable?, message: String?) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend that prints log lines to stderr.
|
||||||
|
*/
|
||||||
|
class StderrLoggerBackend : LoggerBackend {
|
||||||
|
override fun log(tag: String, level: LogLevel, throwable: Throwable?, message: String?) {
|
||||||
|
val timestamp = getElapsedTimeInMs()
|
||||||
|
|
||||||
|
val stackInfo = Throwable().stackTrace[1]
|
||||||
|
val className = stackInfo.className.substringAfterLast(".")
|
||||||
|
val methodName = stackInfo.methodName
|
||||||
|
val lineNumber = stackInfo.lineNumber
|
||||||
|
|
||||||
|
val fullMessage = "[${timestamp.toStringWithDecimal(3).padStart(10, ' ')}] " +
|
||||||
|
"[${level.str}] [$tag] [$className.$methodName():$lineNumber]" +
|
||||||
|
(if (throwable != null) " (${throwable::class.qualifiedName}: \"${throwable.message}\")" else "") +
|
||||||
|
(if (message != null) " $message" else "")
|
||||||
|
|
||||||
|
System.err.println(fullMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SingleTagLogger(val tag: String) {
|
||||||
|
inline operator fun invoke(logLevel: LogLevel, logLambda: () -> String) {
|
||||||
|
if (logLevel.numericLevel <= Logger.threshold.numericLevel)
|
||||||
|
Logger.backend.log(tag, logLevel, null, logLambda.invoke())
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun invoke(logLevel: LogLevel, throwable: Throwable) {
|
||||||
|
if (logLevel.numericLevel <= Logger.threshold.numericLevel)
|
||||||
|
Logger.backend.log(tag, logLevel, throwable, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
inline operator fun invoke(logLevel: LogLevel, throwable: Throwable, logLambda: () -> String) {
|
||||||
|
if (logLevel.numericLevel <= Logger.threshold.numericLevel)
|
||||||
|
Logger.backend.log(tag, logLevel, throwable, logLambda.invoke())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main logging interface.
|
||||||
|
*
|
||||||
|
* Applications can set a custom logger backend simply by setting
|
||||||
|
* the [Logger.backend] variable to a new value. By default, the
|
||||||
|
* [StderrLoggerBackend] is used.
|
||||||
|
*
|
||||||
|
* The logger is used by adding a line like this at the top of:
|
||||||
|
* a source file:
|
||||||
|
*
|
||||||
|
* private val logger = Logger.get("TagName")
|
||||||
|
*
|
||||||
|
* Then, in the source, logging can be done like this:
|
||||||
|
*
|
||||||
|
* logger(LogLevel.DEBUG) { "Logging example" }
|
||||||
|
*
|
||||||
|
* This logs the "Logging example line" with the DEBUG log level
|
||||||
|
* and the "TagName" tag (see [LoggerBackend.log] for details).
|
||||||
|
*
|
||||||
|
* Additionally, the threshold value provides a way to prefilter
|
||||||
|
* logging based on the log level. Only log calls with the log
|
||||||
|
* level of the threshold value (or below) are logged. This helps
|
||||||
|
* with reducing log spam, and improves performance, since the
|
||||||
|
* log line lambdas (shown above) are then only invoked if the
|
||||||
|
* log level is <= the threshold. In practice, this is most useful
|
||||||
|
* for enabling/disabling verbose logging. Verbose log lines are
|
||||||
|
* only really useful for development and more advanced debugging;
|
||||||
|
* in most cases, [LogLevel.DEBUG] as threshold should suffice.
|
||||||
|
*/
|
||||||
|
object Logger {
|
||||||
|
var threshold = LogLevel.DEBUG
|
||||||
|
var backend: LoggerBackend = StderrLoggerBackend()
|
||||||
|
fun get(tag: String) = SingleTagLogger(tag)
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
const val NUM_MAC_BYTES = 8
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class containing an 8-byte machine authentication code.
|
||||||
|
*/
|
||||||
|
data class MachineAuthCode(private val macBytes: List<Byte>) : Iterable<Byte> {
|
||||||
|
/**
|
||||||
|
* Number of MAC bytes (always 8).
|
||||||
|
*
|
||||||
|
* This mainly exists to make this class compatible with
|
||||||
|
* code that operates on collections.
|
||||||
|
*/
|
||||||
|
val size = NUM_MAC_BYTES
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(macBytes.size == size)
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun get(index: Int) = macBytes[index]
|
||||||
|
|
||||||
|
override operator fun iterator() = macBytes.iterator()
|
||||||
|
|
||||||
|
override fun toString() = macBytes.toHexString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MAC consisting of 8 nullbytes. Useful for initializations and
|
||||||
|
* for the first few pairing packets that don't use MACs.
|
||||||
|
*/
|
||||||
|
val NullMachineAuthCode = MachineAuthCode(List(NUM_MAC_BYTES) { 0x00 })
|
|
@ -0,0 +1,90 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
const val NUM_NONCE_BYTES = 13
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class containing a 13-byte nonce.
|
||||||
|
*
|
||||||
|
* The nonce is a byte sequence used in Combo transport layer packets.
|
||||||
|
* It uniquely identifies a packet, since each nonce is only ever used once.
|
||||||
|
* After sending out a packet, the current tx nonce gets incremented, and the
|
||||||
|
* next packet that is to be sent out will use the incremented nonce.
|
||||||
|
*
|
||||||
|
* This class is immutable, since modification operations on a nonce don't
|
||||||
|
* really make any sense. The only two modification-like operations one ever
|
||||||
|
* wants to perform is assigning the 13 bytes (done by the constructor) and
|
||||||
|
* incrementing (done by [getIncrementedNonce] returning an incremented copy).
|
||||||
|
* The behavior of [getIncrementedNonce] may seem wasteful at first, but it
|
||||||
|
* actually isn't, because one would have to do a copy of the current tx nonce
|
||||||
|
* anyway to make sure nonces assigned to outgoing packets retain their original
|
||||||
|
* value even after the current tx nonce was incremented (since assignments
|
||||||
|
* of non-primitives in Kotlin by default are done by-reference).
|
||||||
|
*/
|
||||||
|
data class Nonce(private val nonceBytes: List<Byte>) : Iterable<Byte> {
|
||||||
|
/**
|
||||||
|
* Number of Nonce bytes (always 13).
|
||||||
|
*
|
||||||
|
* This mainly exists to make this class compatible with
|
||||||
|
* code that operates on collections.
|
||||||
|
*/
|
||||||
|
val size = NUM_NONCE_BYTES
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Check that we actually got exactly 13 nonce bytes.
|
||||||
|
require(nonceBytes.size == size)
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun get(index: Int) = nonceBytes[index]
|
||||||
|
|
||||||
|
override operator fun iterator() = nonceBytes.iterator()
|
||||||
|
|
||||||
|
override fun toString() = nonceBytes.toHexString(" ")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return an incremented copy of this nonce.
|
||||||
|
*
|
||||||
|
* This nonce's bytes will not be modified by this call.
|
||||||
|
*
|
||||||
|
* @param incrementAmount By how much the nonce is to be incremented.
|
||||||
|
* Must be at least 1.
|
||||||
|
*/
|
||||||
|
fun getIncrementedNonce(incrementAmount: Int = 1): Nonce {
|
||||||
|
require(incrementAmount >= 1)
|
||||||
|
|
||||||
|
val outputNonceBytes = ArrayList<Byte>(NUM_NONCE_BYTES)
|
||||||
|
|
||||||
|
var carry = 0
|
||||||
|
var leftoverToIncrement = incrementAmount
|
||||||
|
|
||||||
|
nonceBytes.forEach { nonceByte ->
|
||||||
|
val a = leftoverToIncrement and 255
|
||||||
|
val b = nonceByte.toPosInt()
|
||||||
|
val sum = a + b + carry
|
||||||
|
val outputByte = sum and 255
|
||||||
|
|
||||||
|
leftoverToIncrement = leftoverToIncrement ushr 8
|
||||||
|
carry = sum ushr 8
|
||||||
|
|
||||||
|
outputNonceBytes.add(outputByte.toByte())
|
||||||
|
|
||||||
|
if ((leftoverToIncrement == 0) && (carry == 0))
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in outputNonceBytes.size until NUM_NONCE_BYTES)
|
||||||
|
outputNonceBytes.add(0x00.toByte())
|
||||||
|
|
||||||
|
return Nonce(outputNonceBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Convenience function to create a nonce with 13 nullbytes.
|
||||||
|
*
|
||||||
|
* Useful for initializations.
|
||||||
|
*/
|
||||||
|
fun nullNonce() = Nonce(List(NUM_NONCE_BYTES) { 0x00 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.toNonce() = Nonce(this.split(" ").map { it.toInt(radix = 16).toByte() })
|
|
@ -0,0 +1,84 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
const val PAIRING_PIN_SIZE = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class containing a 10-digit pairing PIN.
|
||||||
|
*
|
||||||
|
* This PIN is needed during the pairing process. The Combo shows a 10-digit
|
||||||
|
* PIN on its display. The user then has to enter that PIN in the application.
|
||||||
|
* This class contains such entered PINs.
|
||||||
|
*
|
||||||
|
* @param pinDigits PIN digits. Must be an array of exactly 10 Ints.
|
||||||
|
* The Ints must be in the 0-9 range.
|
||||||
|
*/
|
||||||
|
data class PairingPIN(val pinDigits: IntArray) : Iterable<Int> {
|
||||||
|
// This code was adapted from:
|
||||||
|
// https://discuss.kotlinlang.org/t/defining-a-type-for-a-fixed-length-array/12817/2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of PIN digits (always 10).
|
||||||
|
*
|
||||||
|
* This mainly exists to make this class compatible with
|
||||||
|
* code that operates on collections.
|
||||||
|
*/
|
||||||
|
val size = PAIRING_PIN_SIZE
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(pinDigits.size == PAIRING_PIN_SIZE)
|
||||||
|
|
||||||
|
// Verify that all ints are a number between 0 and 9,
|
||||||
|
// since they are supposed to be digits.
|
||||||
|
for (i in 0 until PAIRING_PIN_SIZE) {
|
||||||
|
val pinDigit = pinDigits[i]
|
||||||
|
require((pinDigit >= 0) && (pinDigit <= 9))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get operator to access the digit with the given index.
|
||||||
|
*
|
||||||
|
* @param index Digit index. Must be a value between 0 and 9 (since the PIN has 10 digits).
|
||||||
|
* @return The digit.
|
||||||
|
*/
|
||||||
|
operator fun get(index: Int) = pinDigits[index]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set operator to set the digit with the given index.
|
||||||
|
*
|
||||||
|
* @param index Digit index. Must be a value between 0 and 9 (since the PIN has 10 digits).
|
||||||
|
* @param pinDigit The new digit to set at the given index.
|
||||||
|
*/
|
||||||
|
operator fun set(index: Int, pinDigit: Int) {
|
||||||
|
require((pinDigit >= 0) && (pinDigit <= 9))
|
||||||
|
pinDigits[index] = pinDigit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom equals operator to compare content,
|
||||||
|
// which doesn't happen by default with arrays.
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other == null) return false
|
||||||
|
if (this::class != other::class) return false
|
||||||
|
other as PairingPIN
|
||||||
|
if (!pinDigits.contentEquals(other.pinDigits)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return pinDigits.contentHashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override operator fun iterator() = pinDigits.iterator()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to print the PIN in the format the Combo prints it on its display.
|
||||||
|
*
|
||||||
|
* The format is 012-345-6789.
|
||||||
|
*/
|
||||||
|
override fun toString() = "${pinDigits[0]}${pinDigits[1]}${pinDigits[2]}-" +
|
||||||
|
"${pinDigits[3]}${pinDigits[4]}${pinDigits[5]}-" +
|
||||||
|
"${pinDigits[6]}${pinDigits[7]}${pinDigits[8]}${pinDigits[9]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nullPairingPIN() = PairingPIN(intArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
|
@ -0,0 +1,248 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlin.reflect.KClassifier
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
private val logger = Logger.get("Pump")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for specifying a stage for a [ProgressReporter] instance.
|
||||||
|
*
|
||||||
|
* @property id ID string, useful for serialization and logging.
|
||||||
|
*/
|
||||||
|
open class ProgressStage(val id: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress stages for basic operations.
|
||||||
|
*/
|
||||||
|
object BasicProgressStage {
|
||||||
|
// Fundamental stages, used for starting / ending a progress sequence.
|
||||||
|
// The Aborted stage base class is useful to be able to catch all possible
|
||||||
|
// abort reasons and also differentiate between them.
|
||||||
|
object Idle : ProgressStage("idle")
|
||||||
|
open class Aborted(id: String) : ProgressStage(id)
|
||||||
|
object Cancelled : Aborted("cancelled")
|
||||||
|
object Timeout : Aborted("timeout")
|
||||||
|
class Error(val cause: Throwable) : Aborted("error")
|
||||||
|
object Finished : ProgressStage("finished")
|
||||||
|
|
||||||
|
// Connection related stages.
|
||||||
|
object ScanningForPumpStage : ProgressStage("scanningForPump")
|
||||||
|
/**
|
||||||
|
* Bluetooth connection establishing stage.
|
||||||
|
*
|
||||||
|
* The connection setup may require several attempts on some platforms.
|
||||||
|
* If the number of attempts so far exceeds the total number, the
|
||||||
|
* connection attempt fails. If no total number is set (that is,
|
||||||
|
* (totalNumAttempts is set to null), then there is no defined limit.
|
||||||
|
* This is typically used when the caller manually aborts connection
|
||||||
|
* attempts after a while.
|
||||||
|
*
|
||||||
|
* @property currentAttemptNr Current attempt number, starting at 1.
|
||||||
|
* @property totalNumAttempts Total number of attempts that will be done,
|
||||||
|
* or null if no total number is defined.
|
||||||
|
*/
|
||||||
|
data class EstablishingBtConnection(val currentAttemptNr: Int, val totalNumAttempts: Int?) :
|
||||||
|
ProgressStage("establishingBtConnection")
|
||||||
|
object PerformingConnectionHandshake : ProgressStage("performingConnectionHandshake")
|
||||||
|
|
||||||
|
// Pairing related stages.
|
||||||
|
object ComboPairingKeyAndPinRequested : ProgressStage("comboPairingKeyAndPinRequested")
|
||||||
|
object ComboPairingFinishing : ProgressStage("comboPairingFinishing")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report with updated progress information.
|
||||||
|
*
|
||||||
|
* @property stageNumber Current progress stage number, starting at 0.
|
||||||
|
* If stageNumber == numStages, then the stage is always
|
||||||
|
* [BasicProgressStage.Finished] or a subclass of [BasicProgressStage.Aborted].
|
||||||
|
* @property numStages Total number of stages in the progress sequence.
|
||||||
|
* @property stage Information about the current stage.
|
||||||
|
* @property overallProgress Numerical indicator for the overall progress.
|
||||||
|
* Valid range is 0.0 - 1.0, with 0.0 being the beginning of
|
||||||
|
* the progress, and 1.0 specifying the end.
|
||||||
|
*/
|
||||||
|
data class ProgressReport(val stageNumber: Int, val numStages: Int, val stage: ProgressStage, val overallProgress: Double)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class for reporting progress updates.
|
||||||
|
*
|
||||||
|
* "Progress" is defined here as a planned sequence of [ProgressStage] instances.
|
||||||
|
* These stages describe information about the current progress. Stage instances
|
||||||
|
* can contain varying information, such as the index of the factor that is
|
||||||
|
* currently being set in a basal profile, or the IUs of a bolus that were
|
||||||
|
* administered so far.
|
||||||
|
*
|
||||||
|
* A sequence always begins with [BasicProgressStage.Idle] and ends with either
|
||||||
|
* [BasicProgressStage.Finished] or a subclass of [BasicProgressStage.Aborted]. These are
|
||||||
|
* special in that they are never explicitly specified in the sequence. [BasicProgressStage.Idle]
|
||||||
|
* is always set as the current flow value when the reporter is created and when
|
||||||
|
* [reset] is called. The other two are passed to [setCurrentProgressStage], which
|
||||||
|
* then immediately forwards them in a [ProgressReport] instance, with that instance's
|
||||||
|
* stage number set to [numStages] (since both of these stages define the end of a sequence).
|
||||||
|
*
|
||||||
|
* In code that reports progress, the [setCurrentProgressStage] function is called
|
||||||
|
* to deliver updates to the reporter, which then forwards that update to subscribers
|
||||||
|
* of the [progressFlow]. The reporter takes care of checking where in the sequence
|
||||||
|
* that stage is. Stages are indexed by stage numbers, which start at 0. The size
|
||||||
|
* of the sequence equals [numStages].
|
||||||
|
*
|
||||||
|
* Updates to the flow are communicated as [ProgressReport] instances. They provide
|
||||||
|
* subscribers with the necessary information to show details about the current
|
||||||
|
* stage and to compute a progress percentage (useful for GUI progress bar elements).
|
||||||
|
*
|
||||||
|
* Example of how to use this class:
|
||||||
|
*
|
||||||
|
* First, the reporter is instantiated, like this:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* val reporter = ProgressReporter(listOf(
|
||||||
|
* BasicProgressStage.StartingConnectionSetup::class,
|
||||||
|
* BasicProgressStage.EstablishingBtConnection::class,
|
||||||
|
* BasicProgressStage.PerformingConnectionHandshake::class
|
||||||
|
* ))
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Code can then report an update like this:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* reporter.setCurrentProgressStage(BasicProgressStage.EstablishingBtConnection(1, 4))
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* This will cause the reporter to publish a [ProgressReport] instance through its
|
||||||
|
* [progressFlow], looking like this:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* ProgressReport(stageNumber = 1, numStages = 3, stage = BasicProgressStage.EstablishingBtConnection(1, 4))
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* This allows code to report progress without having to know what its current
|
||||||
|
* stage number is (so it does not have to concern itself about providing a correct
|
||||||
|
* progress percentage). Also, that way, code that reports progress can be combined.
|
||||||
|
* For example, if function A contains setCurrentProgressStage calls, then the
|
||||||
|
* function that called A can continue to report progress. And, the setCurrentProgressStage
|
||||||
|
* calls from A can also be used to report progress in an entirely different function.
|
||||||
|
* One actual example is the progress reported when a Bluetooth connection is being
|
||||||
|
* established. This is used both during pairing and when setting up a regular
|
||||||
|
* connection, without having to write separate progress report code for both.
|
||||||
|
*
|
||||||
|
* @param plannedSequence The planned progress sequence, as a list of ProgressStage
|
||||||
|
* classes. This never contains [BasicProgressStage.Idle],
|
||||||
|
* [BasicProgressStage.Finished], or a [BasicProgressStage.Aborted] subclass.
|
||||||
|
* @param context User defined contxt to pass to computeOverallProgressCallback.
|
||||||
|
* This can be updated via [reset] calls.
|
||||||
|
* @param computeOverallProgressCallback Callback for computing an overall progress
|
||||||
|
* indication out of the current stage. Valid range for the return value
|
||||||
|
* is 0.0 to 1.0. See [ProgressReport] for an explanation of the arguments.
|
||||||
|
* Default callback calculates (stageNumber / numStages) and uses the result.
|
||||||
|
*/
|
||||||
|
class ProgressReporter<Context>(
|
||||||
|
private val plannedSequence: List<KClassifier>,
|
||||||
|
private var context: Context,
|
||||||
|
private val computeOverallProgressCallback: (stageNumber: Int, numStages: Int, stage: ProgressStage, context: Context) -> Double =
|
||||||
|
{ stageNumber: Int, numStages: Int, _: ProgressStage, _: Context -> stageNumber.toDouble() / numStages.toDouble() }
|
||||||
|
) {
|
||||||
|
private var currentStageNumber = 0
|
||||||
|
private val mutableProgressFlow =
|
||||||
|
MutableStateFlow(ProgressReport(0, plannedSequence.size, BasicProgressStage.Idle, 0.0))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flow for getting progress reports.
|
||||||
|
*/
|
||||||
|
val progressFlow = mutableProgressFlow.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total number of stages in the sequence.
|
||||||
|
*/
|
||||||
|
val numStages = plannedSequence.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the reporter to its initial state.
|
||||||
|
*
|
||||||
|
* The flow's state will be set to a report whose stage is [BasicProgressStage.Idle].
|
||||||
|
*
|
||||||
|
* @param context User defined contxt to pass to computeOverallProgressCallback.
|
||||||
|
* Replaces the context passed to the constructor.
|
||||||
|
*/
|
||||||
|
fun reset(context: Context) {
|
||||||
|
this.context = context
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the reporter to its initial state.
|
||||||
|
*
|
||||||
|
* The flow's state will be set to a report whose stage is [BasicProgressStage.Idle].
|
||||||
|
*
|
||||||
|
* This overload works just like the other one, except that it keeps the context intact.
|
||||||
|
*/
|
||||||
|
fun reset() {
|
||||||
|
currentStageNumber = 0
|
||||||
|
mutableProgressFlow.value = ProgressReport(0, numStages, BasicProgressStage.Idle, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the current stage and triggers an update via a [ProgressReport] instance through the [progressFlow].
|
||||||
|
*
|
||||||
|
* If the process that is being tracked by this reported was cancelled
|
||||||
|
* or aborted due to an error, pass a subclass of [BasicProgressStage.Aborted]
|
||||||
|
* as the stage argument. This will trigger a report with the stage number
|
||||||
|
* set to the total number of stages (to signify that the work is over)
|
||||||
|
* and the stage set to the [BasicProgressStage.Aborted] subclass.
|
||||||
|
*
|
||||||
|
* If the process finished successfully, do the same as written above,
|
||||||
|
* except using [BasicProgressStage.Finished] as the stage instead.
|
||||||
|
*
|
||||||
|
* @param stage Stage of the progress to report.
|
||||||
|
*/
|
||||||
|
fun setCurrentProgressStage(stage: ProgressStage) {
|
||||||
|
when (stage) {
|
||||||
|
is BasicProgressStage.Finished,
|
||||||
|
is BasicProgressStage.Aborted -> {
|
||||||
|
currentStageNumber = numStages
|
||||||
|
mutableProgressFlow.value = ProgressReport(currentStageNumber, numStages, stage, 1.0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage::class != plannedSequence[currentStageNumber]) {
|
||||||
|
// Search forward first. Typically, this succeeds, since stages
|
||||||
|
// are reported in the order specified in the sequence.
|
||||||
|
var succeedingStageNumber = plannedSequence.subList(currentStageNumber + 1, numStages).indexOfFirst {
|
||||||
|
stage::class == it
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStageNumber = if (succeedingStageNumber == -1) {
|
||||||
|
// Unusual case: An _earlier_ stage was reported. This is essentially
|
||||||
|
// a backwards progress (= a regress?). It is not unthinkable that
|
||||||
|
// this can happen, but it should be rare. In that case, we have
|
||||||
|
// to search backwards in the sequence.
|
||||||
|
val precedingStageNumber = plannedSequence.subList(0, currentStageNumber).indexOfFirst {
|
||||||
|
stage::class == it
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the progress info was not found in the sequence, log this and exit.
|
||||||
|
// Do not throw; a missing progress info ID in the sequence is not
|
||||||
|
// a fatal error, so do not break the application because of it.
|
||||||
|
if (precedingStageNumber == -1) {
|
||||||
|
logger(LogLevel.WARN) { "Progress stage \"$stage\" not found in stage sequence; not passing it to flow" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
precedingStageNumber
|
||||||
|
} else {
|
||||||
|
// Need to add (currentStageNumber + 1) as an offset, since the indexOfFirst
|
||||||
|
// call returns indices that are based on the sub list, not the entire list.
|
||||||
|
succeedingStageNumber + (currentStageNumber + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutableProgressFlow.value = ProgressReport(
|
||||||
|
currentStageNumber, numStages, stage,
|
||||||
|
computeOverallProgressCallback(currentStageNumber, numStages, stage, context)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,329 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlinx.datetime.UtcOffset
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pump related data that is set during pairing and not changed afterwards.
|
||||||
|
*
|
||||||
|
* This data is created by [PumpIO.performPairing]. Once it is
|
||||||
|
* created, it does not change until the pump is unpaired, at
|
||||||
|
* which point it is erased. This data is managed by the
|
||||||
|
* [PumpStateStore] class, which stores / retrieves it.
|
||||||
|
*
|
||||||
|
* @property clientPumpCipher This cipher is used for authenticating
|
||||||
|
* packets going to the Combo.
|
||||||
|
* @property pumpClientCipher This cipher is used for verifying
|
||||||
|
* packets coming from the Combo.
|
||||||
|
* @property keyResponseAddress The address byte of a previously
|
||||||
|
* received KEY_RESPONSE packet. The source and destination
|
||||||
|
* address values inside this address byte must have been
|
||||||
|
* reordered to match the order that outgoing packets expect.
|
||||||
|
* That is: Source address stored in the upper, destination
|
||||||
|
* address in the lower 4 bit of the byte. (In incoming
|
||||||
|
* packets - and KEY_RESPONSE is an incoming packet - these
|
||||||
|
* two are ordered the other way round.)
|
||||||
|
* @property pumpID The pump ID from the ID_RESPONSE packet.
|
||||||
|
* This is useful for displaying the pump in a UI, since the
|
||||||
|
* Bluetooth address itself may not be very clear to the user.
|
||||||
|
*/
|
||||||
|
data class InvariantPumpData(
|
||||||
|
val clientPumpCipher: Cipher,
|
||||||
|
val pumpClientCipher: Cipher,
|
||||||
|
val keyResponseAddress: Byte,
|
||||||
|
val pumpID: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Convenience function to create an instance with default "null" values.
|
||||||
|
*
|
||||||
|
* Useful for an initial state.
|
||||||
|
*/
|
||||||
|
fun nullData() =
|
||||||
|
InvariantPumpData(
|
||||||
|
clientPumpCipher = Cipher(ByteArray(CIPHER_KEY_SIZE)),
|
||||||
|
pumpClientCipher = Cipher(ByteArray(CIPHER_KEY_SIZE)),
|
||||||
|
keyResponseAddress = 0x00.toByte(),
|
||||||
|
pumpID = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current state of an ongoing TBR (if any).
|
||||||
|
*
|
||||||
|
* Due to limitations of the Combo, it is necessary to remember this
|
||||||
|
* current state in case the client crashes and restarts, otherwise
|
||||||
|
* it is not possible to deduce the starting timestamp (incl. the
|
||||||
|
* UTC offset at the time of the TBR start) and whether or not a
|
||||||
|
* previously started TBR finished in the meantime. To work around
|
||||||
|
* this limitation, we store that information in the [PumpStateStore]
|
||||||
|
* in a persistent fashion.
|
||||||
|
*/
|
||||||
|
sealed class CurrentTbrState {
|
||||||
|
/**
|
||||||
|
* No TBR is currently ongoing. If the main screen shows TBR info
|
||||||
|
* while this is the current TBR state it means that an unknown
|
||||||
|
* TBR was started, for example by the user while the client
|
||||||
|
* application that uses comboctl was down.
|
||||||
|
*/
|
||||||
|
object NoTbrOngoing : CurrentTbrState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBR is currently ongoing. This stores a [Tbr] instance with
|
||||||
|
* its timestamp specifying when the TBR started. If the main
|
||||||
|
* screen shows no TBR info while this is the current TBR state,
|
||||||
|
* it means that the TBR ended already.
|
||||||
|
*/
|
||||||
|
data class TbrStarted(val tbr: Tbr) : CurrentTbrState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when accessing the stored state of a specific pump fails.
|
||||||
|
*
|
||||||
|
* @param pumpAddress Bluetooth address of the pump whose
|
||||||
|
* state could not be accessed or created.
|
||||||
|
* @param message The detail message.
|
||||||
|
* @param cause The exception that was thrown in the loop specifying
|
||||||
|
* want went wrong there.
|
||||||
|
*/
|
||||||
|
class PumpStateStoreAccessException(val pumpAddress: BluetoothAddress, message: String?, cause: Throwable?) :
|
||||||
|
ComboException(message, cause) {
|
||||||
|
constructor(pumpAddress: BluetoothAddress, message: String) : this(pumpAddress, message, null)
|
||||||
|
constructor(pumpAddress: BluetoothAddress, cause: Throwable) : this(pumpAddress, null, cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when trying to create a new pump state even though one already exists.
|
||||||
|
*
|
||||||
|
* @param pumpAddress Bluetooth address of the pump.
|
||||||
|
*/
|
||||||
|
class PumpStateAlreadyExistsException(val pumpAddress: BluetoothAddress) :
|
||||||
|
ComboException("Pump state for pump with address $pumpAddress already exists")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when trying to access new pump state that does not exist.
|
||||||
|
*
|
||||||
|
* @param pumpAddress Bluetooth address of the pump.
|
||||||
|
*/
|
||||||
|
class PumpStateDoesNotExistException(val pumpAddress: BluetoothAddress) :
|
||||||
|
ComboException("Pump state for pump with address $pumpAddress does not exist")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State store interface for a specific pump.
|
||||||
|
*
|
||||||
|
* This interface provides access to a store that persistently
|
||||||
|
* records the data of [InvariantPumpData] instances along with
|
||||||
|
* the current Tx nonce, UTC offset, and TBR state.
|
||||||
|
*
|
||||||
|
* As the name suggests, these states are recorded persistently,
|
||||||
|
* immediately, and ideally also atomically. If atomic storage cannot
|
||||||
|
* be guaranteed, then there must be some sort of data error detection
|
||||||
|
* in place to ensure that no corrupted data is retrieved. (For example,
|
||||||
|
* if the device running ComboCtl crashes or freezes while the data is
|
||||||
|
* being written into the store, the data may not be written completely.)
|
||||||
|
*
|
||||||
|
* There is one state for each paired pump. Each instance contains the
|
||||||
|
* [InvariantPumpData], which does not change after the pump was paired,
|
||||||
|
* the Tx nonce, which does change after each packet that is sent to
|
||||||
|
* the Combo, the UTC offset, which usually does not change often, and
|
||||||
|
* the TBR state, which changes whenever a TBR starts/ends. These parts
|
||||||
|
* of a pump's state are kept separate due to the difference in access
|
||||||
|
* patterns (that is, how often they are updated), since this allows for
|
||||||
|
* optimizations in implementations.
|
||||||
|
*
|
||||||
|
* Each state is associate with a pump via the pump's Bluetooth address.
|
||||||
|
*
|
||||||
|
* If a function or property access throws [PumpStateStoreAccessException],
|
||||||
|
* then the state is to be considered invalid, any existing connections
|
||||||
|
* to a pump associated with the state must be terminated, and the pump must
|
||||||
|
* be unpaired. This is because such an exception indicates an error in
|
||||||
|
* the underlying pump state store implementation that said implementation
|
||||||
|
* could not recover from. And this also implies that this pump's state inside
|
||||||
|
* the store is in an undefined state - it cannot be relied upon anymore.
|
||||||
|
* Internally, the implementation must delete any remaining state data when
|
||||||
|
* such an error occurs. Callers must then also unpair the pump at the Bluetooth
|
||||||
|
* level. The user must be told about this error, and instructed that the pump
|
||||||
|
* must be paired again.
|
||||||
|
*
|
||||||
|
* Different pump states can be accessed, created, deleted concurrently.
|
||||||
|
* However, operations on the same state must not happen concurrently.
|
||||||
|
* For example, it is valid to create a pump state while an existing [PumpIO]
|
||||||
|
* instance updates the Tx nonce of its associated state, but no two threads
|
||||||
|
* may update the Tx nonce at the same time, or try to access state data and
|
||||||
|
* delete the same state simultaneously, or access a pump state's Tx nonce
|
||||||
|
* while another thread writes a new UTC offset into the same pump state.
|
||||||
|
*
|
||||||
|
* The UTC offset that is stored for each pump here exists because the Combo
|
||||||
|
* is unaware of timezones or UTC offsets. All the time data it stores is
|
||||||
|
* in localtime. The UTC offset in this store specifies what UTC offset
|
||||||
|
* to associate any current Combo localtime timestamp with. Particularly
|
||||||
|
* for the bolus history this is very important, since it allows for properly
|
||||||
|
* handling daylight savings changes and timezone changes (for example because
|
||||||
|
* the user is on vacation in another timezone). The stored UTC offset is
|
||||||
|
* also necessary to be able to detect UTC offset changes even if they
|
||||||
|
* happen when the client is not running. The overall rule with regard
|
||||||
|
* to UTC offset changes and stored Combo localtime timestamps is that
|
||||||
|
* all currently stored timestamps use the currently stored UTC offset,
|
||||||
|
* and any timestamps that might be stored later on will use the new
|
||||||
|
* UTC offset. In practice, this means that all timestamps from the Combo's
|
||||||
|
* command mode history delta use the current UTC offset, and after the
|
||||||
|
* delta was fetched, the UTC offset is updated.
|
||||||
|
*
|
||||||
|
* Finally, the stored TBR state exists because of limitations in the Combo
|
||||||
|
* regarding ongoing TBR information. See [CurrentTbrState] for details.
|
||||||
|
*/
|
||||||
|
interface PumpStateStore {
|
||||||
|
/**
|
||||||
|
* Creates a new pump state and fills the state's invariant data.
|
||||||
|
*
|
||||||
|
* This is called during the pairing process. In regular
|
||||||
|
* connections, this is not used. It initializes a state for the pump
|
||||||
|
* with the given ID in the store. Before this call, trying to access
|
||||||
|
* the state with [getInvariantPumpData], [getCurrentTxNonce],
|
||||||
|
* [setCurrentTxNonce], [getCurrentUtcOffset], [getCurrentTbrState]
|
||||||
|
* fails with an exception. The new state's nonce is set to a null
|
||||||
|
* nonce (= all of its bytes set to zero). The UTC offset is set to
|
||||||
|
* the one from the current system timezone and system time. The
|
||||||
|
* TBR state is set to [CurrentTbrState.NoTbrOngoing].
|
||||||
|
*
|
||||||
|
* The state is removed by calling [deletePumpState].
|
||||||
|
*
|
||||||
|
* Subclasses must store the invariant pump data immediately and persistently.
|
||||||
|
*
|
||||||
|
* @param pumpAddress Bluetooth address of the pump to create a state for.
|
||||||
|
* @param invariantPumpData Invariant pump data to use in the new state.
|
||||||
|
* @param utcOffset Initial UTC offset value to use in the new state.
|
||||||
|
* @param tbrState Initial TBR state to use in the new state.
|
||||||
|
* @throws PumpStateAlreadyExistsException if there is already a state
|
||||||
|
* with the given Bluetooth address.
|
||||||
|
* @throws PumpStateStoreAccessException if writing the new state fails
|
||||||
|
* due to an error that occurred in the underlying implementation.
|
||||||
|
*/
|
||||||
|
fun createPumpState(
|
||||||
|
pumpAddress: BluetoothAddress,
|
||||||
|
invariantPumpData: InvariantPumpData,
|
||||||
|
utcOffset: UtcOffset,
|
||||||
|
tbrState: CurrentTbrState
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a pump state that is associated with the given address.
|
||||||
|
*
|
||||||
|
* If there is no such state, this returns false.
|
||||||
|
*
|
||||||
|
* NOTE: This does not throw.
|
||||||
|
*
|
||||||
|
* @param pumpAddress Bluetooth address of the pump whose corresponding
|
||||||
|
* state in the store shall be deleted.
|
||||||
|
* @return true if there was such a state, false otherwise.
|
||||||
|
*/
|
||||||
|
fun deletePumpState(pumpAddress: BluetoothAddress): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if there is a valid state associated with the given address.
|
||||||
|
*
|
||||||
|
* @return true if there is one, false otherwise.
|
||||||
|
*/
|
||||||
|
fun hasPumpState(pumpAddress: BluetoothAddress): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a set of Bluetooth addresses of the states in this store.
|
||||||
|
*/
|
||||||
|
fun getAvailablePumpStateAddresses(): Set<BluetoothAddress>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the [InvariantPumpData] from the state associated with the given address.
|
||||||
|
*
|
||||||
|
* @throws PumpStateDoesNotExistException if no pump state associated with
|
||||||
|
* the given address exists in the store.
|
||||||
|
* @throws PumpStateStoreAccessException if accessing the data fails
|
||||||
|
* due to an error that occurred in the underlying implementation.
|
||||||
|
*/
|
||||||
|
fun getInvariantPumpData(pumpAddress: BluetoothAddress): InvariantPumpData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current Tx [Nonce] from the state associated with the given address.
|
||||||
|
*
|
||||||
|
* @throws PumpStateDoesNotExistException if no pump state associated with
|
||||||
|
* the given address exists in the store.
|
||||||
|
* @throws PumpStateStoreAccessException if accessing the nonce fails
|
||||||
|
* due to an error that occurred in the underlying implementation.
|
||||||
|
*/
|
||||||
|
fun getCurrentTxNonce(pumpAddress: BluetoothAddress): Nonce
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the current Tx [Nonce] in the state associated with the given address.
|
||||||
|
*
|
||||||
|
* Subclasses must store the new Tx nonce immediately and persistently.
|
||||||
|
*
|
||||||
|
* @throws PumpStateDoesNotExistException if no pump state associated with
|
||||||
|
* the given address exists in the store.
|
||||||
|
* @throws PumpStateStoreAccessException if accessing the nonce fails
|
||||||
|
* due to an error that occurred in the underlying implementation.
|
||||||
|
*/
|
||||||
|
fun setCurrentTxNonce(pumpAddress: BluetoothAddress, currentTxNonce: Nonce)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current UTC offset that is to be used for all timestamps from now on.
|
||||||
|
*
|
||||||
|
* See the [PumpStateStore] documentation for details about this offset.
|
||||||
|
*
|
||||||
|
* @throws PumpStateDoesNotExistException if no pump state associated with
|
||||||
|
* the given address exists in the store.
|
||||||
|
* @throws PumpStateStoreAccessException if accessing the nonce fails
|
||||||
|
* due to an error that occurred in the underlying implementation.
|
||||||
|
*/
|
||||||
|
fun getCurrentUtcOffset(pumpAddress: BluetoothAddress): UtcOffset
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the current UTC offset that is to be used for all timestamps from now on.
|
||||||
|
*
|
||||||
|
* See the [PumpStateStore] documentation for details about this offset.
|
||||||
|
*
|
||||||
|
* @throws PumpStateDoesNotExistException if no pump state associated with
|
||||||
|
* the given address exists in the store.
|
||||||
|
* @throws PumpStateStoreAccessException if accessing the nonce fails
|
||||||
|
* due to an error that occurred in the underlying implementation.
|
||||||
|
*/
|
||||||
|
fun setCurrentUtcOffset(pumpAddress: BluetoothAddress, utcOffset: UtcOffset)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the TBR state that is currently known for the pump with the given [pumpAddress].
|
||||||
|
*
|
||||||
|
* See the [CurrentTbrState] documentation for details about this offset.
|
||||||
|
*
|
||||||
|
* @throws PumpStateDoesNotExistException if no pump state associated with
|
||||||
|
* the given address exists in the store.
|
||||||
|
* @throws PumpStateStoreAccessException if accessing the nonce fails
|
||||||
|
* due to an error that occurred in the underlying implementation.
|
||||||
|
*/
|
||||||
|
fun getCurrentTbrState(pumpAddress: BluetoothAddress): CurrentTbrState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the TBR state that is currently known for the pump with the given [pumpAddress].
|
||||||
|
*
|
||||||
|
* See the [CurrentTbrState] documentation for details about this offset.
|
||||||
|
*
|
||||||
|
* @throws PumpStateDoesNotExistException if no pump state associated with
|
||||||
|
* the given address exists in the store.
|
||||||
|
* @throws PumpStateStoreAccessException if accessing the nonce fails
|
||||||
|
* due to an error that occurred in the underlying implementation.
|
||||||
|
*/
|
||||||
|
fun setCurrentTbrState(pumpAddress: BluetoothAddress, currentTbrState: CurrentTbrState)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Increments the nonce of a pump state associated with the given address.
|
||||||
|
*
|
||||||
|
* @param pumpAddress Bluetooth address of the pump state.
|
||||||
|
* @param incrementAmount By how much the nonce is to be incremented.
|
||||||
|
* Must be at least 1.
|
||||||
|
*/
|
||||||
|
fun PumpStateStore.incrementTxNonce(pumpAddress: BluetoothAddress, incrementAmount: Int = 1): Nonce {
|
||||||
|
require(incrementAmount >= 1)
|
||||||
|
|
||||||
|
val currentTxNonce = this.getCurrentTxNonce(pumpAddress)
|
||||||
|
val newTxNonce = currentTxNonce.getIncrementedNonce(incrementAmount)
|
||||||
|
this.setCurrentTxNonce(pumpAddress, newTxNonce)
|
||||||
|
return newTxNonce
|
||||||
|
}
|
|
@ -0,0 +1,101 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class containing details about a TBR (temporary basal rate).
|
||||||
|
*
|
||||||
|
* This is typically associated with some event or record about a TBR that just
|
||||||
|
* started or stopped. The timestamp is stored as an [Instant] to preserve the
|
||||||
|
* timezone offset that was used at the time when the TBR started / stopped.
|
||||||
|
*
|
||||||
|
* The valid TBR percentage range is 0-500. 100 would mean 100% and is not actually
|
||||||
|
* a TBR, but is sometimes used to communicate a TBR cancel operation. Only integer
|
||||||
|
* multiples of 10 are valid (for example, 210 is valid, 209 isn't).
|
||||||
|
*
|
||||||
|
* If [percentage] is 100, the [durationInMinutes] is ignored. Otherwise, this
|
||||||
|
* argument must be in the 15-1440 range (= 15 minutes to 24 hours), and must
|
||||||
|
* be an integer multiple of 15.
|
||||||
|
*
|
||||||
|
* The constructor checks that [percentage] is valid. [durationInMinutes] is
|
||||||
|
* not checked, however, since there are cases where this class is used with
|
||||||
|
* TBRs that have a duration that is not an integer multiple of 15. In particular,
|
||||||
|
* this is relevant when cancelled / aborted TBRs are reported; their duration
|
||||||
|
* typically isn't an integer multiple of 15. It is recommended to call
|
||||||
|
* [checkDurationForCombo] before using the values of this TBR for programming
|
||||||
|
* the Combo's TBR.
|
||||||
|
*
|
||||||
|
* @property timestamp Timestamp when the TBR started/stopped.
|
||||||
|
* @property percentage TBR percentage.
|
||||||
|
* @property durationInMinutes Duration of the TBR, in minutes.
|
||||||
|
* @property type Type of this TBR.
|
||||||
|
*/
|
||||||
|
data class Tbr(val timestamp: Instant, val percentage: Int, val durationInMinutes: Int, val type: Type) {
|
||||||
|
enum class Type(val stringId: String) {
|
||||||
|
/**
|
||||||
|
* Normal TBR.
|
||||||
|
* */
|
||||||
|
NORMAL("normal"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 15-minute 0% TBR that is produced when the Combo is stopped.
|
||||||
|
* This communicates to callers that there is no insulin delivery
|
||||||
|
* when the pump is stopped.
|
||||||
|
*/
|
||||||
|
COMBO_STOPPED("comboStopped"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caller emulates a stopped pump by setting a special 0% TBR.
|
||||||
|
*
|
||||||
|
* Actually stopping the Combo has other side effects, so typically,
|
||||||
|
* if for example the pump's cannula is to be disconnected, this
|
||||||
|
* TBR type is used instead.
|
||||||
|
*/
|
||||||
|
EMULATED_COMBO_STOP("emulatedComboStop"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caller wanted to cancel a bolus but without actually setting a 100 percent TBR to avoid the W6 warning.
|
||||||
|
*
|
||||||
|
* Normally, a TBR is cancelled by replacing it with a 100% "TBR".
|
||||||
|
* Doing so however always triggers a W6 warning. As an alternative,
|
||||||
|
* for example, an alternating sequence of 90% and 100% TBRs can
|
||||||
|
* be used. Such TBRs would use this as their type.
|
||||||
|
*/
|
||||||
|
EMULATED_100_PERCENT("emulated100Percent"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBR set when a superbolus is delivered.
|
||||||
|
*/
|
||||||
|
SUPERBOLUS("superbolus");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val values = values()
|
||||||
|
/**
|
||||||
|
* Converts a string ID to a [Tbr.Type].
|
||||||
|
*
|
||||||
|
* @return TBR type, or null if there is no matching type.
|
||||||
|
*/
|
||||||
|
fun fromStringId(stringId: String) = values.firstOrNull { it.stringId == stringId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
require((percentage >= 0) && (percentage <= 500)) { "Invalid percentage $percentage; must be in the 0-500 range" }
|
||||||
|
require((percentage % 10) == 0) { "Invalid percentage $percentage; must be integer multiple of 10" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the [durationInMinutes] value and throws an [IllegalArgumentException] if it is not suited for the Combo.
|
||||||
|
*
|
||||||
|
* [durationInMinutes] is considered unsuitable if it is not an integer
|
||||||
|
* multiple of 15 and/or if it is not in the 15-1440 range.
|
||||||
|
*/
|
||||||
|
fun checkDurationForCombo() {
|
||||||
|
if (percentage == 100)
|
||||||
|
return
|
||||||
|
require((durationInMinutes >= 15) && (durationInMinutes <= (24 * 60))) {
|
||||||
|
"Invalid duration $durationInMinutes; must be in the 15 - ${24 * 60} range"
|
||||||
|
}
|
||||||
|
require((durationInMinutes % 15) == 0) { "Invalid duration $durationInMinutes; must be integer multiple of 15" }
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,656 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of the Two-Fish symmetric block cipher.
|
||||||
|
*
|
||||||
|
* This is based on the Java Twofish code from the Android Jobb tool:
|
||||||
|
*
|
||||||
|
* https://android.googlesource.com/platform/tools/base/+/master/jobb/src/main/java/Twofish
|
||||||
|
*
|
||||||
|
* which in turn is based on the Twofish implementation from Bouncy Castle.
|
||||||
|
*
|
||||||
|
* The three public API functions of interest are [processKey], [blockEncrypt],
|
||||||
|
* and [blockDecrypt]. Note that the latter two always process 16-byte blocks.
|
||||||
|
*/
|
||||||
|
object Twofish {
|
||||||
|
/**********************
|
||||||
|
* INTERNAL CONSTANTS *
|
||||||
|
**********************/
|
||||||
|
|
||||||
|
private const val BLOCK_SIZE = 16 // bytes in a data-block
|
||||||
|
private const val MAX_ROUNDS = 16 // max # rounds (for allocating subkeys)
|
||||||
|
|
||||||
|
// Subkey array indices
|
||||||
|
private const val INPUT_WHITEN = 0
|
||||||
|
private const val OUTPUT_WHITEN = INPUT_WHITEN + BLOCK_SIZE / 4
|
||||||
|
private const val ROUND_SUBKEYS = OUTPUT_WHITEN + BLOCK_SIZE / 4 // 2*(# rounds)
|
||||||
|
|
||||||
|
private const val TOTAL_SUBKEYS = ROUND_SUBKEYS + 2 * MAX_ROUNDS
|
||||||
|
|
||||||
|
private const val SK_STEP = 0x02020202
|
||||||
|
private const val SK_BUMP = 0x01010101
|
||||||
|
private const val SK_ROTL = 9
|
||||||
|
|
||||||
|
// Fixed 8x8 permutation S-boxes
|
||||||
|
private val P = arrayOf(
|
||||||
|
intArrayOf(
|
||||||
|
// p0
|
||||||
|
0xA9, 0x67, 0xB3, 0xE8,
|
||||||
|
0x04, 0xFD, 0xA3, 0x76,
|
||||||
|
0x9A, 0x92, 0x80, 0x78,
|
||||||
|
0xE4, 0xDD, 0xD1, 0x38,
|
||||||
|
0x0D, 0xC6, 0x35, 0x98,
|
||||||
|
0x18, 0xF7, 0xEC, 0x6C,
|
||||||
|
0x43, 0x75, 0x37, 0x26,
|
||||||
|
0xFA, 0x13, 0x94, 0x48,
|
||||||
|
0xF2, 0xD0, 0x8B, 0x30,
|
||||||
|
0x84, 0x54, 0xDF, 0x23,
|
||||||
|
0x19, 0x5B, 0x3D, 0x59,
|
||||||
|
0xF3, 0xAE, 0xA2, 0x82,
|
||||||
|
0x63, 0x01, 0x83, 0x2E,
|
||||||
|
0xD9, 0x51, 0x9B, 0x7C,
|
||||||
|
0xA6, 0xEB, 0xA5, 0xBE,
|
||||||
|
0x16, 0x0C, 0xE3, 0x61,
|
||||||
|
0xC0, 0x8C, 0x3A, 0xF5,
|
||||||
|
0x73, 0x2C, 0x25, 0x0B,
|
||||||
|
0xBB, 0x4E, 0x89, 0x6B,
|
||||||
|
0x53, 0x6A, 0xB4, 0xF1,
|
||||||
|
0xE1, 0xE6, 0xBD, 0x45,
|
||||||
|
0xE2, 0xF4, 0xB6, 0x66,
|
||||||
|
0xCC, 0x95, 0x03, 0x56,
|
||||||
|
0xD4, 0x1C, 0x1E, 0xD7,
|
||||||
|
0xFB, 0xC3, 0x8E, 0xB5,
|
||||||
|
0xE9, 0xCF, 0xBF, 0xBA,
|
||||||
|
0xEA, 0x77, 0x39, 0xAF,
|
||||||
|
0x33, 0xC9, 0x62, 0x71,
|
||||||
|
0x81, 0x79, 0x09, 0xAD,
|
||||||
|
0x24, 0xCD, 0xF9, 0xD8,
|
||||||
|
0xE5, 0xC5, 0xB9, 0x4D,
|
||||||
|
0x44, 0x08, 0x86, 0xE7,
|
||||||
|
0xA1, 0x1D, 0xAA, 0xED,
|
||||||
|
0x06, 0x70, 0xB2, 0xD2,
|
||||||
|
0x41, 0x7B, 0xA0, 0x11,
|
||||||
|
0x31, 0xC2, 0x27, 0x90,
|
||||||
|
0x20, 0xF6, 0x60, 0xFF,
|
||||||
|
0x96, 0x5C, 0xB1, 0xAB,
|
||||||
|
0x9E, 0x9C, 0x52, 0x1B,
|
||||||
|
0x5F, 0x93, 0x0A, 0xEF,
|
||||||
|
0x91, 0x85, 0x49, 0xEE,
|
||||||
|
0x2D, 0x4F, 0x8F, 0x3B,
|
||||||
|
0x47, 0x87, 0x6D, 0x46,
|
||||||
|
0xD6, 0x3E, 0x69, 0x64,
|
||||||
|
0x2A, 0xCE, 0xCB, 0x2F,
|
||||||
|
0xFC, 0x97, 0x05, 0x7A,
|
||||||
|
0xAC, 0x7F, 0xD5, 0x1A,
|
||||||
|
0x4B, 0x0E, 0xA7, 0x5A,
|
||||||
|
0x28, 0x14, 0x3F, 0x29,
|
||||||
|
0x88, 0x3C, 0x4C, 0x02,
|
||||||
|
0xB8, 0xDA, 0xB0, 0x17,
|
||||||
|
0x55, 0x1F, 0x8A, 0x7D,
|
||||||
|
0x57, 0xC7, 0x8D, 0x74,
|
||||||
|
0xB7, 0xC4, 0x9F, 0x72,
|
||||||
|
0x7E, 0x15, 0x22, 0x12,
|
||||||
|
0x58, 0x07, 0x99, 0x34,
|
||||||
|
0x6E, 0x50, 0xDE, 0x68,
|
||||||
|
0x65, 0xBC, 0xDB, 0xF8,
|
||||||
|
0xC8, 0xA8, 0x2B, 0x40,
|
||||||
|
0xDC, 0xFE, 0x32, 0xA4,
|
||||||
|
0xCA, 0x10, 0x21, 0xF0,
|
||||||
|
0xD3, 0x5D, 0x0F, 0x00,
|
||||||
|
0x6F, 0x9D, 0x36, 0x42,
|
||||||
|
0x4A, 0x5E, 0xC1, 0xE0
|
||||||
|
),
|
||||||
|
intArrayOf(
|
||||||
|
// p1
|
||||||
|
0x75, 0xF3, 0xC6, 0xF4,
|
||||||
|
0xDB, 0x7B, 0xFB, 0xC8,
|
||||||
|
0x4A, 0xD3, 0xE6, 0x6B,
|
||||||
|
0x45, 0x7D, 0xE8, 0x4B,
|
||||||
|
0xD6, 0x32, 0xD8, 0xFD,
|
||||||
|
0x37, 0x71, 0xF1, 0xE1,
|
||||||
|
0x30, 0x0F, 0xF8, 0x1B,
|
||||||
|
0x87, 0xFA, 0x06, 0x3F,
|
||||||
|
0x5E, 0xBA, 0xAE, 0x5B,
|
||||||
|
0x8A, 0x00, 0xBC, 0x9D,
|
||||||
|
0x6D, 0xC1, 0xB1, 0x0E,
|
||||||
|
0x80, 0x5D, 0xD2, 0xD5,
|
||||||
|
0xA0, 0x84, 0x07, 0x14,
|
||||||
|
0xB5, 0x90, 0x2C, 0xA3,
|
||||||
|
0xB2, 0x73, 0x4C, 0x54,
|
||||||
|
0x92, 0x74, 0x36, 0x51,
|
||||||
|
0x38, 0xB0, 0xBD, 0x5A,
|
||||||
|
0xFC, 0x60, 0x62, 0x96,
|
||||||
|
0x6C, 0x42, 0xF7, 0x10,
|
||||||
|
0x7C, 0x28, 0x27, 0x8C,
|
||||||
|
0x13, 0x95, 0x9C, 0xC7,
|
||||||
|
0x24, 0x46, 0x3B, 0x70,
|
||||||
|
0xCA, 0xE3, 0x85, 0xCB,
|
||||||
|
0x11, 0xD0, 0x93, 0xB8,
|
||||||
|
0xA6, 0x83, 0x20, 0xFF,
|
||||||
|
0x9F, 0x77, 0xC3, 0xCC,
|
||||||
|
0x03, 0x6F, 0x08, 0xBF,
|
||||||
|
0x40, 0xE7, 0x2B, 0xE2,
|
||||||
|
0x79, 0x0C, 0xAA, 0x82,
|
||||||
|
0x41, 0x3A, 0xEA, 0xB9,
|
||||||
|
0xE4, 0x9A, 0xA4, 0x97,
|
||||||
|
0x7E, 0xDA, 0x7A, 0x17,
|
||||||
|
0x66, 0x94, 0xA1, 0x1D,
|
||||||
|
0x3D, 0xF0, 0xDE, 0xB3,
|
||||||
|
0x0B, 0x72, 0xA7, 0x1C,
|
||||||
|
0xEF, 0xD1, 0x53, 0x3E,
|
||||||
|
0x8F, 0x33, 0x26, 0x5F,
|
||||||
|
0xEC, 0x76, 0x2A, 0x49,
|
||||||
|
0x81, 0x88, 0xEE, 0x21,
|
||||||
|
0xC4, 0x1A, 0xEB, 0xD9,
|
||||||
|
0xC5, 0x39, 0x99, 0xCD,
|
||||||
|
0xAD, 0x31, 0x8B, 0x01,
|
||||||
|
0x18, 0x23, 0xDD, 0x1F,
|
||||||
|
0x4E, 0x2D, 0xF9, 0x48,
|
||||||
|
0x4F, 0xF2, 0x65, 0x8E,
|
||||||
|
0x78, 0x5C, 0x58, 0x19,
|
||||||
|
0x8D, 0xE5, 0x98, 0x57,
|
||||||
|
0x67, 0x7F, 0x05, 0x64,
|
||||||
|
0xAF, 0x63, 0xB6, 0xFE,
|
||||||
|
0xF5, 0xB7, 0x3C, 0xA5,
|
||||||
|
0xCE, 0xE9, 0x68, 0x44,
|
||||||
|
0xE0, 0x4D, 0x43, 0x69,
|
||||||
|
0x29, 0x2E, 0xAC, 0x15,
|
||||||
|
0x59, 0xA8, 0x0A, 0x9E,
|
||||||
|
0x6E, 0x47, 0xDF, 0x34,
|
||||||
|
0x35, 0x6A, 0xCF, 0xDC,
|
||||||
|
0x22, 0xC9, 0xC0, 0x9B,
|
||||||
|
0x89, 0xD4, 0xED, 0xAB,
|
||||||
|
0x12, 0xA2, 0x0D, 0x52,
|
||||||
|
0xBB, 0x02, 0x2F, 0xA9,
|
||||||
|
0xD7, 0x61, 0x1E, 0xB4,
|
||||||
|
0x50, 0x04, 0xF6, 0xC2,
|
||||||
|
0x16, 0x25, 0x86, 0x56,
|
||||||
|
0x55, 0x09, 0xBE, 0x91
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Define the fixed p0/p1 permutations used in keyed S-box lookup.
|
||||||
|
// By changing the following constant definitions, the S-boxes will
|
||||||
|
// automatically get changed in the Twofish engine.
|
||||||
|
|
||||||
|
private const val P_00 = 1
|
||||||
|
private const val P_01 = 0
|
||||||
|
private const val P_02 = 0
|
||||||
|
private const val P_03 = P_01 xor 1
|
||||||
|
private const val P_04 = 1
|
||||||
|
|
||||||
|
private const val P_10 = 0
|
||||||
|
private const val P_11 = 0
|
||||||
|
private const val P_12 = 1
|
||||||
|
private const val P_13 = P_11 xor 1
|
||||||
|
private const val P_14 = 0
|
||||||
|
|
||||||
|
private const val P_20 = 1
|
||||||
|
private const val P_21 = 1
|
||||||
|
private const val P_22 = 0
|
||||||
|
private const val P_23 = P_21 xor 1
|
||||||
|
private const val P_24 = 0
|
||||||
|
|
||||||
|
private const val P_30 = 0
|
||||||
|
private const val P_31 = 1
|
||||||
|
private const val P_32 = 1
|
||||||
|
private const val P_33 = P_31 xor 1
|
||||||
|
private const val P_34 = 1
|
||||||
|
|
||||||
|
// Primitive polynomial for GF(256)
|
||||||
|
private const val GF256_FDBK: Int = 0x169
|
||||||
|
private const val GF256_FDBK_2: Int = 0x169 / 2
|
||||||
|
private const val GF256_FDBK_4: Int = 0x169 / 4
|
||||||
|
|
||||||
|
private val MDS = Array(4) { IntArray(256) { 0 } }
|
||||||
|
|
||||||
|
private const val RS_GF_FDBK = 0x14D // field generator
|
||||||
|
|
||||||
|
/**********************
|
||||||
|
* INTERNAL FUNCTIONS *
|
||||||
|
**********************/
|
||||||
|
|
||||||
|
private fun LFSR1(x: Int): Int =
|
||||||
|
(x shr 1) xor (if ((x and 0x01) != 0) GF256_FDBK_2 else 0)
|
||||||
|
|
||||||
|
private fun LFSR2(x: Int): Int =
|
||||||
|
(x shr 2) xor
|
||||||
|
(if ((x and 0x02) != 0) GF256_FDBK_2 else 0) xor
|
||||||
|
(if ((x and 0x01) != 0) GF256_FDBK_4 else 0)
|
||||||
|
|
||||||
|
private fun Mx_1(x: Int): Int = x
|
||||||
|
private fun Mx_X(x: Int): Int = x xor LFSR2(x) // 5B
|
||||||
|
private fun Mx_Y(x: Int): Int = x xor LFSR1(x) xor LFSR2(x) // EF
|
||||||
|
|
||||||
|
// Reed-Solomon code parameters: (12, 8) reversible code:<p>
|
||||||
|
// <pre>
|
||||||
|
// g(x) = x**4 + (a + 1/a) x**3 + a x**2 + (a + 1/a) x + 1
|
||||||
|
// </pre>
|
||||||
|
// where a = primitive root of field generator 0x14D
|
||||||
|
private fun RS_rem(x: Int): Int {
|
||||||
|
val b = (x ushr 24) and 0xFF
|
||||||
|
val g2 = ((b shl 1) xor (if ((b and 0x80) != 0) RS_GF_FDBK else 0)) and 0xFF
|
||||||
|
val g3 = (b ushr 1) xor (if ((b and 0x01) != 0) (RS_GF_FDBK ushr 1) else 0) xor g2
|
||||||
|
return (x shl 8) xor (g3 shl 24) xor (g2 shl 16) xor (g3 shl 8) xor b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use (12, 8) Reed-Solomon code over GF(256) to produce a key S-box
|
||||||
|
// 32-bit entity from two key material 32-bit entities.
|
||||||
|
//
|
||||||
|
// @param k0 1st 32-bit entity.
|
||||||
|
// @param k1 2nd 32-bit entity.
|
||||||
|
// @return Remainder polynomial generated using RS code
|
||||||
|
private fun RS_MDS_Encode(k0: Int, k1: Int): Int {
|
||||||
|
var r = k1
|
||||||
|
for (i in 0 until 4) // shift 1 byte at a time
|
||||||
|
r = RS_rem(r)
|
||||||
|
r = r xor k0
|
||||||
|
for (i in 0 until 4)
|
||||||
|
r = RS_rem(r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calcb0(x: Int) = x and 0xFF
|
||||||
|
private fun calcb1(x: Int) = (x ushr 8) and 0xFF
|
||||||
|
private fun calcb2(x: Int) = (x ushr 16) and 0xFF
|
||||||
|
private fun calcb3(x: Int) = (x ushr 24) and 0xFF
|
||||||
|
|
||||||
|
private fun F32(k64Cnt: Int, x: Int, k32: IntArray): Int {
|
||||||
|
var b0 = calcb0(x)
|
||||||
|
var b1 = calcb1(x)
|
||||||
|
var b2 = calcb2(x)
|
||||||
|
var b3 = calcb3(x)
|
||||||
|
|
||||||
|
val k0 = k32[0]
|
||||||
|
val k1 = k32[1]
|
||||||
|
val k2 = k32[2]
|
||||||
|
val k3 = k32[3]
|
||||||
|
|
||||||
|
var k64Cnt2LSB = k64Cnt and 3
|
||||||
|
|
||||||
|
if (k64Cnt2LSB == 1) {
|
||||||
|
return MDS[0][(P[P_01][b0] and 0xFF) xor calcb0(k0)] xor
|
||||||
|
MDS[1][(P[P_11][b1] and 0xFF) xor calcb1(k0)] xor
|
||||||
|
MDS[2][(P[P_21][b2] and 0xFF) xor calcb2(k0)] xor
|
||||||
|
MDS[3][(P[P_31][b3] and 0xFF) xor calcb3(k0)]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (k64Cnt2LSB == 0) { // same as 4
|
||||||
|
b0 = (P[P_04][b0] and 0xFF) xor calcb0(k3)
|
||||||
|
b1 = (P[P_14][b1] and 0xFF) xor calcb1(k3)
|
||||||
|
b2 = (P[P_24][b2] and 0xFF) xor calcb2(k3)
|
||||||
|
b3 = (P[P_34][b3] and 0xFF) xor calcb3(k3)
|
||||||
|
k64Cnt2LSB = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
if (k64Cnt2LSB == 3) {
|
||||||
|
b0 = (P[P_03][b0] and 0xFF) xor calcb0(k2)
|
||||||
|
b1 = (P[P_13][b1] and 0xFF) xor calcb1(k2)
|
||||||
|
b2 = (P[P_23][b2] and 0xFF) xor calcb2(k2)
|
||||||
|
b3 = (P[P_33][b3] and 0xFF) xor calcb3(k2)
|
||||||
|
k64Cnt2LSB = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if (k64Cnt2LSB == 2) { // 128-bit keys (optimize for this case)
|
||||||
|
return MDS[0][(P[P_01][(P[P_02][b0] and 0xFF) xor calcb0(k1)] and 0xFF) xor calcb0(k0)] xor
|
||||||
|
MDS[1][(P[P_11][(P[P_12][b1] and 0xFF) xor calcb1(k1)] and 0xFF) xor calcb1(k0)] xor
|
||||||
|
MDS[2][(P[P_21][(P[P_22][b2] and 0xFF) xor calcb2(k1)] and 0xFF) xor calcb2(k0)] xor
|
||||||
|
MDS[3][(P[P_31][(P[P_32][b3] and 0xFF) xor calcb3(k1)] and 0xFF) xor calcb3(k0)]
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Fe32(sBox: IntArray, x: Int, R: Int) =
|
||||||
|
sBox[0x000 + 2 * _b(x, R + 0) + 0] xor
|
||||||
|
sBox[0x000 + 2 * _b(x, R + 1) + 1] xor
|
||||||
|
sBox[0x200 + 2 * _b(x, R + 2) + 0] xor
|
||||||
|
sBox[0x200 + 2 * _b(x, R + 3) + 1]
|
||||||
|
|
||||||
|
private fun _b(x: Int, N: Int) =
|
||||||
|
when (N and 3) {
|
||||||
|
0 -> calcb0(x)
|
||||||
|
1 -> calcb1(x)
|
||||||
|
2 -> calcb2(x)
|
||||||
|
3 -> calcb3(x)
|
||||||
|
// NOTE: This else-branch is only here to shut up build errors.
|
||||||
|
// This case cannot occur because the bitwise AND above excludes
|
||||||
|
// all values outside of the 0-3 range.
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/*************************
|
||||||
|
* STATIC INITIALIZATION *
|
||||||
|
*************************/
|
||||||
|
|
||||||
|
init {
|
||||||
|
// precompute the MDS matrix
|
||||||
|
val m1 = IntArray(2)
|
||||||
|
val mX = IntArray(2)
|
||||||
|
val mY = IntArray(2)
|
||||||
|
|
||||||
|
for (i in 0 until 256) {
|
||||||
|
// compute all the matrix elements
|
||||||
|
|
||||||
|
val j0 = P[0][i] and 0xFF
|
||||||
|
m1[0] = j0
|
||||||
|
mX[0] = Mx_X(j0) and 0xFF
|
||||||
|
mY[0] = Mx_Y(j0) and 0xFF
|
||||||
|
|
||||||
|
val j1 = P[1][i] and 0xFF
|
||||||
|
m1[1] = j1
|
||||||
|
mX[1] = Mx_X(j1) and 0xFF
|
||||||
|
mY[1] = Mx_Y(j1) and 0xFF
|
||||||
|
|
||||||
|
MDS[0][i] = (m1[P_00] shl 0) or
|
||||||
|
(mX[P_00] shl 8) or
|
||||||
|
(mY[P_00] shl 16) or
|
||||||
|
(mY[P_00] shl 24)
|
||||||
|
|
||||||
|
MDS[1][i] = (mY[P_10] shl 0) or
|
||||||
|
(mY[P_10] shl 8) or
|
||||||
|
(mX[P_10] shl 16) or
|
||||||
|
(m1[P_10] shl 24)
|
||||||
|
|
||||||
|
MDS[2][i] = (mX[P_20] shl 0) or
|
||||||
|
(mY[P_20] shl 8) or
|
||||||
|
(m1[P_20] shl 16) or
|
||||||
|
(mY[P_20] shl 24)
|
||||||
|
|
||||||
|
MDS[3][i] = (mX[P_30] shl 0) or
|
||||||
|
(m1[P_30] shl 8) or
|
||||||
|
(mY[P_30] shl 16) or
|
||||||
|
(mX[P_30] shl 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************
|
||||||
|
* KEY PROCESSING *
|
||||||
|
******************/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class containing precomputed S-box and subkey values derived from a key.
|
||||||
|
*
|
||||||
|
* These values are computed by the [processKey] function.
|
||||||
|
* [blockEncrypt] and [blockDecrypt] expect an instance of this class,
|
||||||
|
* not a key directly.
|
||||||
|
*/
|
||||||
|
data class KeyObject(val sBox: IntArray, val subKeys: IntArray) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other == null) return false
|
||||||
|
if (this::class != other::class) return false
|
||||||
|
|
||||||
|
other as KeyObject
|
||||||
|
|
||||||
|
if (!sBox.contentEquals(other.sBox)) return false
|
||||||
|
if (!subKeys.contentEquals(other.subKeys)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = sBox.contentHashCode()
|
||||||
|
result = 31 * result + subKeys.contentHashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a Two-fish key and stores the computed values in the returned object.
|
||||||
|
*
|
||||||
|
* Since the S-box and subkey values stay the same during en/decryption, it
|
||||||
|
* makes sense to compute them once and store them for later reuse. This is
|
||||||
|
* what this function does
|
||||||
|
*
|
||||||
|
* @param key 64/128/192/256-bit key for processing.
|
||||||
|
* @return Object with the processed results.
|
||||||
|
*/
|
||||||
|
fun processKey(key: ByteArray): KeyObject {
|
||||||
|
require(key.size in intArrayOf(8, 16, 24, 32))
|
||||||
|
|
||||||
|
val k64Cnt = key.size / 8
|
||||||
|
val k32e = IntArray(4) // even 32-bit entities
|
||||||
|
val k32o = IntArray(4) // odd 32-bit entities
|
||||||
|
val sBoxKey = IntArray(4)
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
|
||||||
|
// split user key material into even and odd 32-bit entities and
|
||||||
|
// compute S-box keys using (12, 8) Reed-Solomon code over GF(256)
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
if (offset >= key.size)
|
||||||
|
break
|
||||||
|
|
||||||
|
val j = k64Cnt - 1 - i
|
||||||
|
|
||||||
|
k32e[i] = ((key[offset++].toPosInt() and 0xFF) shl 0) or
|
||||||
|
((key[offset++].toPosInt() and 0xFF) shl 8) or
|
||||||
|
((key[offset++].toPosInt() and 0xFF) shl 16) or
|
||||||
|
((key[offset++].toPosInt() and 0xFF) shl 24)
|
||||||
|
k32o[i] = ((key[offset++].toPosInt() and 0xFF) shl 0) or
|
||||||
|
((key[offset++].toPosInt() and 0xFF) shl 8) or
|
||||||
|
((key[offset++].toPosInt() and 0xFF) shl 16) or
|
||||||
|
((key[offset++].toPosInt() and 0xFF) shl 24)
|
||||||
|
sBoxKey[j] = RS_MDS_Encode(k32e[i], k32o[i]) // reverse order
|
||||||
|
}
|
||||||
|
|
||||||
|
// compute the round decryption subkeys for PHT. these same subkeys
|
||||||
|
// will be used in encryption but will be applied in reverse order.
|
||||||
|
var q = 0
|
||||||
|
val subKeys = IntArray(TOTAL_SUBKEYS)
|
||||||
|
for (i in 0 until (TOTAL_SUBKEYS / 2)) {
|
||||||
|
var A = F32(k64Cnt, q, k32e) // A uses even key entities
|
||||||
|
var B = F32(k64Cnt, q + SK_BUMP, k32o) // B uses odd key entities
|
||||||
|
B = (B shl 8) or (B ushr 24)
|
||||||
|
A += B
|
||||||
|
subKeys[2 * i + 0] = A // combine with a PHT
|
||||||
|
A += B
|
||||||
|
subKeys[2 * i + 1] = (A shl SK_ROTL) or (A ushr (32 - SK_ROTL))
|
||||||
|
q += SK_STEP
|
||||||
|
}
|
||||||
|
|
||||||
|
// fully expand the table for speed
|
||||||
|
val k0 = sBoxKey[0]
|
||||||
|
val k1 = sBoxKey[1]
|
||||||
|
val k2 = sBoxKey[2]
|
||||||
|
val k3 = sBoxKey[3]
|
||||||
|
val sBox = IntArray(4 * 256)
|
||||||
|
|
||||||
|
for (i in 0 until 256) {
|
||||||
|
var b0 = i
|
||||||
|
var b1 = i
|
||||||
|
var b2 = i
|
||||||
|
var b3 = i
|
||||||
|
|
||||||
|
var k64Cnt2LSB = k64Cnt and 3
|
||||||
|
|
||||||
|
if (k64Cnt2LSB == 1) {
|
||||||
|
sBox[0x000 + 2 * i + 0] = MDS[0][(P[P_01][b0] and 0xFF) xor calcb0(k0)]
|
||||||
|
sBox[0x000 + 2 * i + 1] = MDS[1][(P[P_11][b1] and 0xFF) xor calcb1(k0)]
|
||||||
|
sBox[0x200 + 2 * i + 0] = MDS[2][(P[P_21][b2] and 0xFF) xor calcb2(k0)]
|
||||||
|
sBox[0x200 + 2 * i + 1] = MDS[3][(P[P_31][b3] and 0xFF) xor calcb3(k0)]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (k64Cnt2LSB == 0) {
|
||||||
|
b0 = (P[P_04][b0] and 0xFF) xor calcb0(k3)
|
||||||
|
b1 = (P[P_14][b1] and 0xFF) xor calcb1(k3)
|
||||||
|
b2 = (P[P_24][b2] and 0xFF) xor calcb2(k3)
|
||||||
|
b3 = (P[P_34][b3] and 0xFF) xor calcb3(k3)
|
||||||
|
k64Cnt2LSB = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
if (k64Cnt2LSB == 3) {
|
||||||
|
b0 = (P[P_03][b0] and 0xFF) xor calcb0(k2)
|
||||||
|
b1 = (P[P_13][b1] and 0xFF) xor calcb1(k2)
|
||||||
|
b2 = (P[P_23][b2] and 0xFF) xor calcb2(k2)
|
||||||
|
b3 = (P[P_33][b3] and 0xFF) xor calcb3(k2)
|
||||||
|
k64Cnt2LSB = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if (k64Cnt2LSB == 2) {
|
||||||
|
sBox[0x000 + 2 * i + 0] = MDS[0][(P[P_01][(P[P_02][b0] and 0xFF) xor calcb0(k1)] and 0xFF) xor calcb0(k0)]
|
||||||
|
sBox[0x000 + 2 * i + 1] = MDS[1][(P[P_11][(P[P_12][b1] and 0xFF) xor calcb1(k1)] and 0xFF) xor calcb1(k0)]
|
||||||
|
sBox[0x200 + 2 * i + 0] = MDS[2][(P[P_21][(P[P_22][b2] and 0xFF) xor calcb2(k1)] and 0xFF) xor calcb2(k0)]
|
||||||
|
sBox[0x200 + 2 * i + 1] = MDS[3][(P[P_31][(P[P_32][b3] and 0xFF) xor calcb3(k1)] and 0xFF) xor calcb3(k0)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return KeyObject(sBox = sBox, subKeys = subKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
/***************************
|
||||||
|
* EN/DECRYPTION FUNCTIONS *
|
||||||
|
***************************/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a block of 16 plaintext bytes with the given key object.
|
||||||
|
*
|
||||||
|
* The 16 bytes are read from the given array at the given offset.
|
||||||
|
* This function always reads exactly 16 bytes.
|
||||||
|
*
|
||||||
|
* The key object is generated from a key by using [processKey].
|
||||||
|
*
|
||||||
|
* @param input Byte array with the input bytes of plaintext to encrypt.
|
||||||
|
* @param offset Offset in the input byte array to start reading bytes from.
|
||||||
|
* @param keyObject Key object to use for encryption.
|
||||||
|
* @return Byte array with the ciphertext version of the 16 input bytes.
|
||||||
|
*/
|
||||||
|
fun blockEncrypt(input: ByteArray, offset: Int, keyObject: KeyObject): ByteArray {
|
||||||
|
var inputOffset = offset
|
||||||
|
|
||||||
|
var x0 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 24)
|
||||||
|
var x1 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 24)
|
||||||
|
var x2 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 24)
|
||||||
|
var x3 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
|
||||||
|
((input[inputOffset].toPosInt() and 0xFF) shl 24)
|
||||||
|
|
||||||
|
val sBox = keyObject.sBox
|
||||||
|
val subKeys = keyObject.subKeys
|
||||||
|
|
||||||
|
x0 = x0 xor subKeys[INPUT_WHITEN + 0]
|
||||||
|
x1 = x1 xor subKeys[INPUT_WHITEN + 1]
|
||||||
|
x2 = x2 xor subKeys[INPUT_WHITEN + 2]
|
||||||
|
x3 = x3 xor subKeys[INPUT_WHITEN + 3]
|
||||||
|
|
||||||
|
var k = ROUND_SUBKEYS
|
||||||
|
|
||||||
|
for (R in 0 until MAX_ROUNDS step 2) {
|
||||||
|
var t0: Int
|
||||||
|
var t1: Int
|
||||||
|
|
||||||
|
t0 = Fe32(sBox, x0, 0)
|
||||||
|
t1 = Fe32(sBox, x1, 3)
|
||||||
|
x2 = x2 xor (t0 + t1 + subKeys[k++])
|
||||||
|
x2 = (x2 ushr 1) or (x2 shl 31)
|
||||||
|
x3 = (x3 shl 1) or (x3 ushr 31)
|
||||||
|
x3 = x3 xor (t0 + 2 * t1 + subKeys[k++])
|
||||||
|
|
||||||
|
t0 = Fe32(sBox, x2, 0)
|
||||||
|
t1 = Fe32(sBox, x3, 3)
|
||||||
|
x0 = x0 xor (t0 + t1 + subKeys[k++])
|
||||||
|
x0 = (x0 ushr 1) or (x0 shl 31)
|
||||||
|
x1 = (x1 shl 1) or (x1 ushr 31)
|
||||||
|
x1 = x1 xor (t0 + 2 * t1 + subKeys[k++])
|
||||||
|
}
|
||||||
|
|
||||||
|
x2 = x2 xor subKeys[OUTPUT_WHITEN + 0]
|
||||||
|
x3 = x3 xor subKeys[OUTPUT_WHITEN + 1]
|
||||||
|
x0 = x0 xor subKeys[OUTPUT_WHITEN + 2]
|
||||||
|
x1 = x1 xor subKeys[OUTPUT_WHITEN + 3]
|
||||||
|
|
||||||
|
return byteArrayOfInts(
|
||||||
|
x2, x2 ushr 8, x2 ushr 16, x2 ushr 24,
|
||||||
|
x3, x3 ushr 8, x3 ushr 16, x3 ushr 24,
|
||||||
|
x0, x0 ushr 8, x0 ushr 16, x0 ushr 24,
|
||||||
|
x1, x1 ushr 8, x1 ushr 16, x1 ushr 24
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a block of 16 ciphertext bytes with the given key object.
|
||||||
|
*
|
||||||
|
* The 16 bytes are read from the given array at the given offset.
|
||||||
|
* This function always reads exactly 16 bytes.
|
||||||
|
*
|
||||||
|
* The key object is generated from a key by using [processKey].
|
||||||
|
*
|
||||||
|
* @param input Byte array with the input bytes of ciphertext to decrypt.
|
||||||
|
* @param offset Offset in the input byte array to start reading bytes from.
|
||||||
|
* @param keyObject Key object to use for decryption.
|
||||||
|
* @return Byte array with the plaintext version of the 16 input bytes.
|
||||||
|
*/
|
||||||
|
fun blockDecrypt(input: ByteArray, offset: Int, keyObject: KeyObject): ByteArray {
|
||||||
|
var inputOffset = offset
|
||||||
|
|
||||||
|
var x2 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 24)
|
||||||
|
var x3 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 24)
|
||||||
|
var x0 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 24)
|
||||||
|
var x1 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
|
||||||
|
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
|
||||||
|
((input[inputOffset].toPosInt() and 0xFF) shl 24)
|
||||||
|
|
||||||
|
val sBox = keyObject.sBox
|
||||||
|
val subKeys = keyObject.subKeys
|
||||||
|
|
||||||
|
x2 = x2 xor subKeys[OUTPUT_WHITEN + 0]
|
||||||
|
x3 = x3 xor subKeys[OUTPUT_WHITEN + 1]
|
||||||
|
x0 = x0 xor subKeys[OUTPUT_WHITEN + 2]
|
||||||
|
x1 = x1 xor subKeys[OUTPUT_WHITEN + 3]
|
||||||
|
|
||||||
|
var k = TOTAL_SUBKEYS - 1
|
||||||
|
|
||||||
|
for (R in 0 until MAX_ROUNDS step 2) {
|
||||||
|
var t0: Int
|
||||||
|
var t1: Int
|
||||||
|
|
||||||
|
t0 = Fe32(sBox, x2, 0)
|
||||||
|
t1 = Fe32(sBox, x3, 3)
|
||||||
|
x1 = x1 xor (t0 + 2 * t1 + subKeys[k--])
|
||||||
|
x1 = (x1 ushr 1) or (x1 shl 31)
|
||||||
|
x0 = (x0 shl 1) or (x0 ushr 31)
|
||||||
|
x0 = x0 xor (t0 + t1 + subKeys[k--])
|
||||||
|
|
||||||
|
t0 = Fe32(sBox, x0, 0)
|
||||||
|
t1 = Fe32(sBox, x1, 3)
|
||||||
|
x3 = x3 xor (t0 + 2 * t1 + subKeys[k--])
|
||||||
|
x3 = (x3 ushr 1) or (x3 shl 31)
|
||||||
|
x2 = (x2 shl 1) or (x2 ushr 31)
|
||||||
|
x2 = x2 xor (t0 + t1 + subKeys[k--])
|
||||||
|
}
|
||||||
|
|
||||||
|
x0 = x0 xor subKeys[INPUT_WHITEN + 0]
|
||||||
|
x1 = x1 xor subKeys[INPUT_WHITEN + 1]
|
||||||
|
x2 = x2 xor subKeys[INPUT_WHITEN + 2]
|
||||||
|
x3 = x3 xor subKeys[INPUT_WHITEN + 3]
|
||||||
|
|
||||||
|
return byteArrayOfInts(
|
||||||
|
x0, x0 ushr 8, x0 ushr 16, x0 ushr 24,
|
||||||
|
x1, x1 ushr 8, x1 ushr 16, x1 ushr 24,
|
||||||
|
x2, x2 ushr 8, x2 ushr 16, x2 ushr 24,
|
||||||
|
x3, x3 ushr 8, x3 ushr 16, x3 ushr 24
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,283 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
|
||||||
|
// Utility function for cases when only the time and no date is known.
|
||||||
|
// monthNumber and dayOfMonth are set to 1 instead of 0 since 0 is
|
||||||
|
// outside of the valid range of these fields.
|
||||||
|
internal fun timeWithoutDate(hour: Int = 0, minute: Int = 0, second: Int = 0) =
|
||||||
|
LocalDateTime(
|
||||||
|
year = 0, monthNumber = 1, dayOfMonth = 1,
|
||||||
|
hour = hour, minute = minute, second = second, nanosecond = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun combinedDateTime(date: LocalDate, time: LocalDateTime) =
|
||||||
|
date.atTime(hour = time.hour, minute = time.minute, second = time.second, nanosecond = time.nanosecond)
|
||||||
|
|
||||||
|
// IMPORTANT: Only use this with local dates that always lie in the past or present,
|
||||||
|
// never in the future. Read the comment block right below for an explanation why.
|
||||||
|
internal fun LocalDate.withFixedYearFrom(reference: LocalDate): LocalDate {
|
||||||
|
// In cases where the Combo does not show years (just months and days), we may have
|
||||||
|
// to fix the local date by inserting a year number from some other timestamp
|
||||||
|
// (typically the current system time).
|
||||||
|
// If we do this, we have to address a corner case: Suppose that the un-fixed date
|
||||||
|
// has month 12 and day 29, but the current date is 2024-01-02. Just inserting 2024
|
||||||
|
// into the first date yields the date 2024-12-29. The screens that only show
|
||||||
|
// months and days (and not years) are in virtually all cases used for historical
|
||||||
|
// data, meaning that these dates cannot be in the future. The example just shown
|
||||||
|
// would produce a future date though (since 2024-12-29 > 2024-01-02). Therefore,
|
||||||
|
// if we see that the newly constructed local date is in the future relative to
|
||||||
|
// the reference date, we subtract 1 from the year.
|
||||||
|
val date = LocalDate(year = reference.year, month = this.month, dayOfMonth = this.dayOfMonth)
|
||||||
|
return if (date > reference) {
|
||||||
|
LocalDate(year = reference.year - 1, month = this.month, dayOfMonth = this.dayOfMonth)
|
||||||
|
} else
|
||||||
|
date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a ByteArray out of a sequence of integers.
|
||||||
|
*
|
||||||
|
* Producing a ByteArray with arrayOf() is only possible if the values
|
||||||
|
* are less than 128. For example, this is not possible, because 0xF0
|
||||||
|
* is >= 128:
|
||||||
|
*
|
||||||
|
* val b = byteArrayOf(0xF0, 0x01)
|
||||||
|
*
|
||||||
|
* This function allows for such cases.
|
||||||
|
*
|
||||||
|
* [Original code from here](https://stackoverflow.com/a/51404278).
|
||||||
|
*
|
||||||
|
* @param ints Integers to convert to bytes for the new array.
|
||||||
|
* @return The new ByteArray.
|
||||||
|
*/
|
||||||
|
internal fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of [byteArrayOfInts] which produces an ArrayList instead of an array.
|
||||||
|
*
|
||||||
|
* @param ints Integers to convert to bytes for the new arraylist.
|
||||||
|
* @return The new arraylist.
|
||||||
|
*/
|
||||||
|
internal fun byteArrayListOfInts(vararg ints: Int) = ArrayList(ints.map { it.toByte() })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a hexadecimal string representation of the bytes in the array.
|
||||||
|
*
|
||||||
|
* The string is formatted with a separator (one whitespace character by default)
|
||||||
|
* between the bytes. For example, the byte array 0x8F, 0xBC results in "8F BC".
|
||||||
|
*
|
||||||
|
* @return The string representation.
|
||||||
|
*/
|
||||||
|
internal fun ByteArray.toHexString(separator: String = " ") = this.joinToString(separator) { it.toHexString(width = 2, prependPrefix = false) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a hexadecimal string representation of the bytes in the list.
|
||||||
|
*
|
||||||
|
* The string is formatted with a separator (one whitespace character by default)
|
||||||
|
* between the bytes. For example, the byte list 0x8F, 0xBC results in "8F BC".
|
||||||
|
*
|
||||||
|
* @return The string representation.
|
||||||
|
*/
|
||||||
|
internal fun List<Byte>.toHexString(separator: String = " ") = this.joinToString(separator) { it.toHexString(width = 2, prependPrefix = false) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a hexadecimal string describing the "surroundings" of a byte in a list.
|
||||||
|
*
|
||||||
|
* This is useful for error messages about invalid bytes in data. For example,
|
||||||
|
* suppose that the 11th byte in this data block is invalid:
|
||||||
|
*
|
||||||
|
* 11 77 EE 44 77 EE 77 DD 00 77 DC 77 EE 55 CC
|
||||||
|
*
|
||||||
|
* Then with this function, it is possible to produce a string that highlights that
|
||||||
|
* byte, along with its surrounding bytes:
|
||||||
|
*
|
||||||
|
* "11 77 EE 44 77 EE 77 DD 00 77 [DC] 77 EE 55 CC"
|
||||||
|
*
|
||||||
|
* Such a surrounding is also referred to as a "context" in tools like GNU patch,
|
||||||
|
* which is why it is called like this here.
|
||||||
|
*
|
||||||
|
* @param offset Offset in the list where the byte is.
|
||||||
|
* @param contextSize The size of the context before and the one after the byte.
|
||||||
|
* For example, a size of 10 will include up to 10 bytes before and up
|
||||||
|
* to 10 bytes after the byte at offset (less if the byte is close to
|
||||||
|
* the beginning or end of the list).
|
||||||
|
* @return The string representation.
|
||||||
|
*/
|
||||||
|
internal fun List<Byte>.toHexStringWithContext(offset: Int, contextSize: Int = 10): String {
|
||||||
|
val byte = this[offset]
|
||||||
|
val beforeByteContext = this.subList(max(offset - contextSize, 0), offset)
|
||||||
|
val beforeByteContextStr = if (beforeByteContext.isEmpty()) "" else beforeByteContext.toHexString() + " "
|
||||||
|
val afterByteContext = this.subList(offset + 1, min(this.size, offset + 1 + contextSize))
|
||||||
|
val afterByteContextStr = if (afterByteContext.isEmpty()) "" else " " + afterByteContext.toHexString()
|
||||||
|
|
||||||
|
return "$beforeByteContextStr[${byte.toHexString(width = 2, prependPrefix = false)}]$afterByteContextStr"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Byte to Int conversion that treats all 8 bits of the byte as a positive value.
|
||||||
|
*
|
||||||
|
* Currently, support for unsigned byte (UByte) is still experimental
|
||||||
|
* in Kotlin. The existing Byte type is signed. This poses a problem
|
||||||
|
* when one needs to bitwise manipulate bytes, since the MSB will be
|
||||||
|
* interpreted as a sign bit, leading to unexpected outcomes. Example:
|
||||||
|
*
|
||||||
|
* Example byte: 0xA2 (in binary: 0b10100010)
|
||||||
|
*
|
||||||
|
* Code:
|
||||||
|
*
|
||||||
|
* val b = 0xA2.toByte()
|
||||||
|
* println("%08x".format(b.toInt()))
|
||||||
|
*
|
||||||
|
* Result:
|
||||||
|
*
|
||||||
|
* ffffffa2
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This is the result of the MSB of 0xA2 being interpreted as a sign
|
||||||
|
* bit. This in turn leads to 0xA2 being interpreted as the negative
|
||||||
|
* value -94. When cast to Int, a negative -94 Int value is produced.
|
||||||
|
* Due to the 2-complement logic, all upper bits are set, leading to
|
||||||
|
* the hex value 0xffffffa2. By masking out all except the lower
|
||||||
|
* 8 bits, the correct positive value is retained:
|
||||||
|
*
|
||||||
|
* println("%08x".format(b.toPosInt() xor 7))
|
||||||
|
*
|
||||||
|
* Result:
|
||||||
|
*
|
||||||
|
* 000000a2
|
||||||
|
*
|
||||||
|
* This is for example important when doing bit shifts:
|
||||||
|
*
|
||||||
|
* println("%08x".format(b.toInt() ushr 4))
|
||||||
|
* println("%08x".format(b.toPosInt() ushr 4))
|
||||||
|
* println("%08x".format(b.toInt() shl 4))
|
||||||
|
* println("%08x".format(b.toPosInt() shl 4))
|
||||||
|
*
|
||||||
|
* Result:
|
||||||
|
*
|
||||||
|
* 0ffffffa
|
||||||
|
* 0000000a
|
||||||
|
* fffffa20
|
||||||
|
* 00000a20
|
||||||
|
*
|
||||||
|
* toPosInt produces the correct results.
|
||||||
|
*/
|
||||||
|
internal fun Byte.toPosInt() = toInt() and 0xFF
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Byte to Long conversion that treats all 8 bits of the byte as a positive value.
|
||||||
|
*
|
||||||
|
* This behaves identically to [Byte.toPosInt], except it produces a Long instead
|
||||||
|
* of an Int value.
|
||||||
|
*/
|
||||||
|
internal fun Byte.toPosLong() = toLong() and 0xFF
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Int to Long conversion that treats all 32 bits of the Int as a positive value.
|
||||||
|
*
|
||||||
|
* This behaves just like [Byte.toPosLong], except it is applied on Int values,
|
||||||
|
* and extracts 32 bits instead of 8.
|
||||||
|
*/
|
||||||
|
internal fun Int.toPosLong() = toLong() and 0xFFFFFFFFL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a hex string out of an Int.
|
||||||
|
*
|
||||||
|
* String.format() is JVM specific, so we can't use it in multiplatform projects.
|
||||||
|
* Hence the existence of this function.
|
||||||
|
*
|
||||||
|
* @param width Width of the hex string. If the actual hex string is shorter
|
||||||
|
* than this, the excess characters to the left (the leading characters)
|
||||||
|
* are filled with zeros. If a "0x" prefix is added, the prefix length is
|
||||||
|
* not considered part of the hex string. For example, a width of 4 and
|
||||||
|
* a hex string of 0x45 will produce 0045 with no prefix and 0x0045 with
|
||||||
|
* prefix.
|
||||||
|
* @param prependPrefix If true, the "0x" prefix is prepended.
|
||||||
|
* @return Hex string representation of the Int.
|
||||||
|
*/
|
||||||
|
internal fun Int.toHexString(width: Int, prependPrefix: Boolean = true): String {
|
||||||
|
val prefix = if (prependPrefix) "0x" else ""
|
||||||
|
val hexstring = this.toString(16)
|
||||||
|
val numLeadingChars = max(width - hexstring.length, 0)
|
||||||
|
return prefix + "0".repeat(numLeadingChars) + hexstring
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a hex string out of a Byte.
|
||||||
|
*
|
||||||
|
* String.format() is JVM specific, so we can't use it in multiplatform projects.
|
||||||
|
* Hence the existence of this function.
|
||||||
|
*
|
||||||
|
* @param width Width of the hex string. If the actual hex string is shorter
|
||||||
|
* than this, the excess characters to the left (the leading characters)
|
||||||
|
* are filled with zeros. If a "0x" prefix is added, the prefix length is
|
||||||
|
* not considered part of the hex string. For example, a width of 4 and
|
||||||
|
* a hex string of 0x45 will produce 0045 with no prefix and 0x0045 with
|
||||||
|
* prefix.
|
||||||
|
* @param prependPrefix If true, the "0x" prefix is prepended.
|
||||||
|
* @return Hex string representation of the Byte.
|
||||||
|
*/
|
||||||
|
internal fun Byte.toHexString(width: Int, prependPrefix: Boolean = true): String {
|
||||||
|
val intValue = this.toPosInt()
|
||||||
|
val prefix = if (prependPrefix) "0x" else ""
|
||||||
|
val hexstring = intValue.toString(16)
|
||||||
|
val numLeadingChars = max(width - hexstring.length, 0)
|
||||||
|
return prefix + "0".repeat(numLeadingChars) + hexstring
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the given integer to string, using the rightmost digits as decimals.
|
||||||
|
*
|
||||||
|
* This is useful for fixed-point decimals, that is, integers that actually
|
||||||
|
* store decimal values of fixed precision. These are used for insulin
|
||||||
|
* dosages. For example, the integer 155 may actually mean 1.55. In that case,
|
||||||
|
* [numDecimals] is set to 2, indicating that the 2 last digits are the
|
||||||
|
* fractional part.
|
||||||
|
*
|
||||||
|
* @param numDecimals How many of the rightmost digits make up the fraction
|
||||||
|
* portion of the decimal.
|
||||||
|
* @return String representation of the decimal value.
|
||||||
|
*/
|
||||||
|
internal fun Int.toStringWithDecimal(numDecimals: Int): String {
|
||||||
|
require(numDecimals >= 0)
|
||||||
|
val intStr = this.toString()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
numDecimals == 0 -> intStr
|
||||||
|
intStr.length <= numDecimals -> "0." + "0".repeat(numDecimals - intStr.length) + intStr
|
||||||
|
else -> intStr.substring(0, intStr.length - numDecimals) + "." + intStr.substring(intStr.length - numDecimals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the given integer to string, using the rightmost digits as decimals.
|
||||||
|
*
|
||||||
|
* This behaves just like [Int.toStringWithDecimal], except it is applied on Long values.
|
||||||
|
*/
|
||||||
|
internal fun Long.toStringWithDecimal(numDecimals: Int): String {
|
||||||
|
require(numDecimals >= 0)
|
||||||
|
val longStr = this.toString()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
numDecimals == 0 -> longStr
|
||||||
|
longStr.length <= numDecimals -> "0." + "0".repeat(numDecimals - longStr.length) + longStr
|
||||||
|
else -> longStr.substring(0, longStr.length - numDecimals) + "." + longStr.substring(longStr.length - numDecimals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the elapsed time in milliseconds.
|
||||||
|
*
|
||||||
|
* This measures the elapsed time that started at some arbitrary point
|
||||||
|
* (typically Epoch, or the moment the system was booted). It does _not_
|
||||||
|
* necessarily return the current wall-clock time, and is only intended
|
||||||
|
* to be used for calculating intervals and to add timestamps to events
|
||||||
|
* such as when log lines are produced.
|
||||||
|
*/
|
||||||
|
internal fun getElapsedTimeInMs(): Long = Clock.System.now().toEpochMilliseconds()
|
|
@ -0,0 +1,101 @@
|
||||||
|
package info.nightscout.comboctl.main
|
||||||
|
|
||||||
|
import info.nightscout.comboctl.base.toStringWithDecimal
|
||||||
|
|
||||||
|
const val NUM_COMBO_BASAL_PROFILE_FACTORS = 24
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class containing the 24 basal profile factors.
|
||||||
|
*
|
||||||
|
* The factors are stored as integer-encoded-decimals. The
|
||||||
|
* last 3 digits of the integers make up the fractional portion.
|
||||||
|
* For example, integer factor 4100 actually means 4.1 IU.
|
||||||
|
*
|
||||||
|
* The Combo uses the following granularity:
|
||||||
|
* 0.00 IU to 0.05 IU : increment in 0.05 IU steps
|
||||||
|
* 0.05 IU to 1.00 IU : increment in 0.01 IU steps
|
||||||
|
* 1.00 IU to 10.00 IU : increment in 0.05 IU steps
|
||||||
|
* 10.00 IU and above : increment in 0.10 IU steps
|
||||||
|
*
|
||||||
|
* The [sourceFactors] argument must contain exactly 24
|
||||||
|
* integer-encoded-decimals. Any other amount will result
|
||||||
|
* in an [IllegalArgumentException]. Furthermore, all
|
||||||
|
* factors must be >= 0.
|
||||||
|
*
|
||||||
|
* [sourceFactors] is not taken as a reference. Instead,
|
||||||
|
* its 24 factors are copied into an internal list that
|
||||||
|
* is accessible via the [factors] property. If the factors
|
||||||
|
* from [sourceFactors] do not match the granularity mentioned
|
||||||
|
* above, they will be rounded before they are copied into
|
||||||
|
* the [factors] property. It is therefore advisable to
|
||||||
|
* look at that property after creating an instance of
|
||||||
|
* this class to see what the profile's factors that the
|
||||||
|
* Combo is using actually are like.
|
||||||
|
*
|
||||||
|
* @param sourceFactors The source for the basal profile's factors.
|
||||||
|
* @throws IllegalArgumentException if [sourceFactors] does not
|
||||||
|
* contain exactly 24 factors or if at least one of these
|
||||||
|
* factors is negative.
|
||||||
|
*/
|
||||||
|
class BasalProfile(sourceFactors: List<Int>) {
|
||||||
|
private val _factors = MutableList(NUM_COMBO_BASAL_PROFILE_FACTORS) { 0 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of basal profile factors (always 24).
|
||||||
|
*
|
||||||
|
* This mainly exists to make this class compatible with
|
||||||
|
* code that operates on collections.
|
||||||
|
*/
|
||||||
|
val size = NUM_COMBO_BASAL_PROFILE_FACTORS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List with the basal profile factors.
|
||||||
|
*
|
||||||
|
* These are a copy of the source factors that were
|
||||||
|
* passed to the constructor, rounded if necessary.
|
||||||
|
* See the [BasalProfile] documentation for details.
|
||||||
|
*/
|
||||||
|
val factors: List<Int> = _factors
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(sourceFactors.size == _factors.size)
|
||||||
|
|
||||||
|
sourceFactors.forEachIndexed { index, factor ->
|
||||||
|
require(factor >= 0) { "Source factor #$index has invalid negative value $factor" }
|
||||||
|
|
||||||
|
val granularity = when (factor) {
|
||||||
|
in 0..50 -> 50
|
||||||
|
in 50..1000 -> 10
|
||||||
|
in 1000..10000 -> 50
|
||||||
|
else -> 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round the factor with integer math
|
||||||
|
// to conform to the Combo granularity.
|
||||||
|
_factors[index] = ((factor + granularity / 2) / granularity) * granularity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString() = factors.mapIndexed { index, factor ->
|
||||||
|
"hour $index: factor ${factor.toStringWithDecimal(3)}"
|
||||||
|
}.joinToString("; ")
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other == null) return false
|
||||||
|
if (this::class != other::class) return false
|
||||||
|
|
||||||
|
other as BasalProfile
|
||||||
|
if (factors != other.factors) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return factors.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun get(index: Int) = factors[index]
|
||||||
|
|
||||||
|
operator fun iterator() = factors.iterator()
|
||||||
|
}
|
|
@ -0,0 +1,230 @@
|
||||||
|
package info.nightscout.comboctl.main
|
||||||
|
|
||||||
|
import info.nightscout.comboctl.base.DisplayFrame
|
||||||
|
import info.nightscout.comboctl.base.LogLevel
|
||||||
|
import info.nightscout.comboctl.base.Logger
|
||||||
|
import info.nightscout.comboctl.parser.AlertScreenException
|
||||||
|
import info.nightscout.comboctl.parser.ParsedScreen
|
||||||
|
import info.nightscout.comboctl.parser.parseDisplayFrame
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
|
||||||
|
private val logger = Logger.get("ParsedDisplayFrameStream")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combination of a [DisplayFrame] and the [ParsedScreen] that is the result of parsing that frame.
|
||||||
|
*/
|
||||||
|
data class ParsedDisplayFrame(val displayFrame: DisplayFrame, val parsedScreen: ParsedScreen)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class for parsing and processing a stream of incoming [DisplayFrame] data.
|
||||||
|
*
|
||||||
|
* This takes incoming [DisplayFrame] data through [feedDisplayFrame], parses these
|
||||||
|
* frames, and stores the frame along with its [ParsedScreen]. Consumers can get
|
||||||
|
* the result with [getParsedDisplayFrame]. If [feedDisplayFrame] is called before
|
||||||
|
* a previously parsed frame is retrieved, the previous un-retrieved copy gets
|
||||||
|
* overwritten. If no parsed frame is currently available, [getParsedDisplayFrame]
|
||||||
|
* suspends the calling coroutine until a parsed frame becomes available.
|
||||||
|
*
|
||||||
|
* [getParsedDisplayFrame] can also detect duplicate screens by comparing the
|
||||||
|
* last and current frame's [ParsedScreen] parsing results. In other words, duplicates
|
||||||
|
* are detected by comparing the parsed contents, not the frame pixels (unless both
|
||||||
|
* frames could not be parsed).
|
||||||
|
*
|
||||||
|
* [resetAll] resets all internal states and recreates the internal [Channel] that
|
||||||
|
* stores the last parsed frame. [resetDuplicate] resets the states associated with
|
||||||
|
* detecting duplicate screens.
|
||||||
|
*
|
||||||
|
* The [flow] offers a [SharedFlow] of the parsed display frames. This is useful
|
||||||
|
* for showing the frames on a GUI for example.
|
||||||
|
*
|
||||||
|
* During operation, [feedDisplayFrame], and [getParsedDisplayFrame]
|
||||||
|
* can be called concurrently, as can [resetAll] and [getParsedDisplayFrame].
|
||||||
|
* Other functions and call combinations lead to undefined behavior.
|
||||||
|
*
|
||||||
|
* This "stream" class is used instead of a more common Kotlin coroutine flow
|
||||||
|
* because the latter do not fit well in the whole Combo RT display dataflow
|
||||||
|
* model, where the ComboCtl code *pulls* parsed frames. Flows - specifically
|
||||||
|
* SharedFlows - are instead more suitable for *pushing* frames. Also, this
|
||||||
|
* class helps with diagnostics and debugging since it stores the actual
|
||||||
|
* frames along with their parsed screen counterparts, and there are no caches
|
||||||
|
* in between the display frames and the parsed screens which could lead to
|
||||||
|
* RT navigation errors due to the parsed screens indicating something that
|
||||||
|
* is not actually the current state and rather a past state instead.
|
||||||
|
*/
|
||||||
|
class ParsedDisplayFrameStream {
|
||||||
|
private val _flow = MutableSharedFlow<ParsedDisplayFrame?>(onBufferOverflow = BufferOverflow.DROP_OLDEST, replay = 1)
|
||||||
|
private var parsedDisplayFrameChannel = createChannel()
|
||||||
|
private var lastRetrievedParsedDisplayFrame: ParsedDisplayFrame? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [SharedFlow] publishing all incoming and newly parsed frames.
|
||||||
|
*
|
||||||
|
* This if [feedDisplayFrame] is called with a null argument.
|
||||||
|
*/
|
||||||
|
val flow: SharedFlow<ParsedDisplayFrame?> = _flow.asSharedFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets all internal states back to the initial conditions.
|
||||||
|
*
|
||||||
|
* The [flow]'s replay cache is reset by this as well. This also
|
||||||
|
* resets all duplicate detection related states, so calling
|
||||||
|
* [resetDuplicate] after this is redundant.
|
||||||
|
*
|
||||||
|
* This aborts an ongoing suspending [getParsedDisplayFrame] call.
|
||||||
|
*/
|
||||||
|
fun resetAll() {
|
||||||
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
|
_flow.resetReplayCache()
|
||||||
|
parsedDisplayFrameChannel.close()
|
||||||
|
parsedDisplayFrameChannel = createChannel()
|
||||||
|
lastRetrievedParsedDisplayFrame = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the internal states to reflect a given error.
|
||||||
|
*
|
||||||
|
* This behaves similar to [resetAll]. However, the internal Channel
|
||||||
|
* for parsed display frames is closed with the specified [cause], and
|
||||||
|
* is _not_ reopened afterwards. [resetAll] has to be called after
|
||||||
|
* this function to be able to use the [ParsedDisplayFrameStream] again.
|
||||||
|
* This is intentional; it makes sure any attempts at getting parsed
|
||||||
|
* display frames etc. fail until the user explicitly resets this stream.
|
||||||
|
*
|
||||||
|
* @param cause The throwable that caused the error. Any currently
|
||||||
|
* suspended [getParsedDisplayFrame] call will be aborted and this
|
||||||
|
* cause will be thrown from that function.
|
||||||
|
*/
|
||||||
|
fun abortDueToError(cause: Throwable?) {
|
||||||
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
|
_flow.resetReplayCache()
|
||||||
|
parsedDisplayFrameChannel.close(cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the states that are associated with duplicate screen detection.
|
||||||
|
*
|
||||||
|
* See [getParsedDisplayFrame] for details about duplicate screen detection.
|
||||||
|
*/
|
||||||
|
fun resetDuplicate() {
|
||||||
|
lastRetrievedParsedDisplayFrame = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feeds a new [DisplayFrame] into this stream, parses it, and stores the parsed frame.
|
||||||
|
*
|
||||||
|
* The parsed frame is stored as a [ParsedDisplayFrame] instance. If there is already
|
||||||
|
* such an instance stored, that previous one is overwritten. This also publishes
|
||||||
|
* the new [ParsedDisplayFrame] instance through [flow]. This can also stored a null
|
||||||
|
* reference to signal that frames are currently unavailable.
|
||||||
|
*
|
||||||
|
* [resetAll] erases the stored frame.
|
||||||
|
*
|
||||||
|
* This and [getParsedDisplayFrame] can be called concurrently.
|
||||||
|
*/
|
||||||
|
fun feedDisplayFrame(displayFrame: DisplayFrame?) {
|
||||||
|
val newParsedDisplayFrame = displayFrame?.let {
|
||||||
|
ParsedDisplayFrame(it, parseDisplayFrame(it))
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedDisplayFrameChannel.trySend(newParsedDisplayFrame)
|
||||||
|
_flow.tryEmit(newParsedDisplayFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a frame has already been stored by a [feedDisplayFrame] call.
|
||||||
|
*
|
||||||
|
* [getParsedDisplayFrame] retrieves a stored frame, so after such a call, this
|
||||||
|
* would return false again until a new frame is stored with [feedDisplayFrame].
|
||||||
|
*
|
||||||
|
* This is not thread safe; it is not safe to call this and [feedDisplayFrame] /
|
||||||
|
* [getParsedDisplayFrame] simultaneously.
|
||||||
|
*/
|
||||||
|
fun hasStoredDisplayFrame(): Boolean =
|
||||||
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
|
!(parsedDisplayFrameChannel.isEmpty)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the last [ParsedDisplayFrame] that was stored by [feedDisplayFrame].
|
||||||
|
*
|
||||||
|
* If no such frame was stored, this suspends until one is stored or [resetAll]
|
||||||
|
* is called. In the latter case, [ClosedReceiveChannelException] is thrown.
|
||||||
|
*
|
||||||
|
* If [filterDuplicates] is set to true, this function compares the current
|
||||||
|
* parsed frame with the last. If the currently stored frame is found to be
|
||||||
|
* equal to the last one, it is considered a duplicate, and gets silently
|
||||||
|
* dropped. This function then waits for a new frame, suspending the coroutine
|
||||||
|
* until [feedDisplayFrame] is called with a new frame.
|
||||||
|
*
|
||||||
|
* In some cases, the last frame that is stored in this class for purposes
|
||||||
|
* of duplicate detection is not valid anymore and will lead to incorrect
|
||||||
|
* duplicate detection behavior. In such cases, [resetDuplicate] can be called.
|
||||||
|
* This erases the internal last frame.
|
||||||
|
*
|
||||||
|
* [processAlertScreens] specifies whether this function should pre-check the
|
||||||
|
* contents of the [ParsedScreen]. If set to true, it will see if the parsed
|
||||||
|
* screen is a [ParsedScreen.AlertScreen]. If so, it extracts the contents of
|
||||||
|
* the alert screen and throws an [AlertScreenException]. If instead
|
||||||
|
* [processAlertScreens] is set to false, alert screens are treated just like
|
||||||
|
* any other ones.
|
||||||
|
*
|
||||||
|
* @return The last frame stored by [feedDisplayFrame].
|
||||||
|
* @throws ClosedReceiveChannelException if [resetAll] is called while this
|
||||||
|
* suspends the coroutine and waits for a new frame.
|
||||||
|
* @throws AlertScreenException if [processAlertScreens] is set to true and
|
||||||
|
* an alert screen is detected.
|
||||||
|
* @throws PacketReceiverException thrown when the [TransportLayer.IO] packet
|
||||||
|
* receiver loop failed due to an exception. Said exception is wrapped in
|
||||||
|
* a PacketReceiverException and forwarded all the way to this function
|
||||||
|
* call, which will keep throwing that cause until [resetAll] is called
|
||||||
|
* to reset the internal states.
|
||||||
|
*/
|
||||||
|
suspend fun getParsedDisplayFrame(filterDuplicates: Boolean = false, processAlertScreens: Boolean = true): ParsedDisplayFrame? {
|
||||||
|
while (true) {
|
||||||
|
val thisParsedDisplayFrame = parsedDisplayFrameChannel.receive()
|
||||||
|
val lastParsedDisplayFrame = lastRetrievedParsedDisplayFrame
|
||||||
|
|
||||||
|
if (filterDuplicates && (lastParsedDisplayFrame != null) && (thisParsedDisplayFrame != null)) {
|
||||||
|
val lastParsedScreen = lastParsedDisplayFrame.parsedScreen
|
||||||
|
val thisParsedScreen = thisParsedDisplayFrame.parsedScreen
|
||||||
|
val lastDisplayFrame = lastParsedDisplayFrame.displayFrame
|
||||||
|
val thisDisplayFrame = thisParsedDisplayFrame.displayFrame
|
||||||
|
|
||||||
|
// If both last and current screen could not be parsed, we can't compare
|
||||||
|
// any parsed contents. Resort to comparing pixels in that case instead.
|
||||||
|
// Normally though we compare contents, since this is faster, and sometimes,
|
||||||
|
// the pixels change but the contents don't (example: a frame showing the
|
||||||
|
// time with a blinking ":" character).
|
||||||
|
val isDuplicate = if ((lastParsedScreen is ParsedScreen.UnrecognizedScreen) && (thisParsedScreen is ParsedScreen.UnrecognizedScreen))
|
||||||
|
(lastDisplayFrame == thisDisplayFrame)
|
||||||
|
else
|
||||||
|
(lastParsedScreen == thisParsedScreen)
|
||||||
|
|
||||||
|
if (isDuplicate)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRetrievedParsedDisplayFrame = thisParsedDisplayFrame
|
||||||
|
|
||||||
|
// Blinked-out screens are unusable; skip them, otherwise
|
||||||
|
// they may mess up RT navigation.
|
||||||
|
if ((thisParsedDisplayFrame != null) && thisParsedDisplayFrame.parsedScreen.isBlinkedOut) {
|
||||||
|
logger(LogLevel.DEBUG) { "Screen is blinked out (contents: ${thisParsedDisplayFrame.parsedScreen}); skipping" }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processAlertScreens && (thisParsedDisplayFrame != null)) {
|
||||||
|
if (thisParsedDisplayFrame.parsedScreen is ParsedScreen.AlertScreen)
|
||||||
|
throw AlertScreenException(thisParsedDisplayFrame.parsedScreen.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return thisParsedDisplayFrame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannel() =
|
||||||
|
Channel<ParsedDisplayFrame?>(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
|
}
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,476 @@
|
||||||
|
package info.nightscout.comboctl.main
|
||||||
|
|
||||||
|
import info.nightscout.comboctl.base.BasicProgressStage
|
||||||
|
import info.nightscout.comboctl.base.BluetoothAddress
|
||||||
|
import info.nightscout.comboctl.base.BluetoothInterface
|
||||||
|
import info.nightscout.comboctl.base.ComboException
|
||||||
|
import info.nightscout.comboctl.base.Constants
|
||||||
|
import info.nightscout.comboctl.base.LogLevel
|
||||||
|
import info.nightscout.comboctl.base.Logger
|
||||||
|
import info.nightscout.comboctl.base.PairingPIN
|
||||||
|
import info.nightscout.comboctl.base.ProgressReporter
|
||||||
|
import info.nightscout.comboctl.base.PumpIO
|
||||||
|
import info.nightscout.comboctl.base.PumpStateStore
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
private val logger = Logger.get("PumpManager")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager class for acquiring and creating [Pump] instances.
|
||||||
|
*
|
||||||
|
* This is the main class for accessing pumps. It manages a list
|
||||||
|
* of paired pumps and handles discovery and pairing. Applications
|
||||||
|
* use this class as the primary ComboCtl interface, along with [Pump].
|
||||||
|
* [bluetoothInterface] is used for device discovery and for creating
|
||||||
|
* Bluetooth device instances that are then passed to newly paired
|
||||||
|
* [Pump] instances. [pumpStateStore] contains the pump states for
|
||||||
|
* all paired pumps.
|
||||||
|
*
|
||||||
|
* Before an instance of this class can actually be used, [setup]
|
||||||
|
* must be called.
|
||||||
|
*/
|
||||||
|
class PumpManager(
|
||||||
|
private val bluetoothInterface: BluetoothInterface,
|
||||||
|
private val pumpStateStore: PumpStateStore
|
||||||
|
) {
|
||||||
|
// Coroutine mutex. This is used to prevent race conditions while
|
||||||
|
// accessing acquiredPumps and the pumpStateStore. The mutex is needed
|
||||||
|
// when acquiring pumps (accesses the store and the acquiredPumps map),
|
||||||
|
// releasing pumps (accesses the acquiredPumps map), when a new pump
|
||||||
|
// is found during discovery (accesses the store), and when a pump is
|
||||||
|
// unpaired (accesses the store).
|
||||||
|
// Note that a coroutine mutex is rather slow. But since the calls
|
||||||
|
// that use it are not used very often, this is not an issue.
|
||||||
|
private val pumpStateAccessMutex = Mutex()
|
||||||
|
|
||||||
|
// List of Pump instances acquired by acquirePump() calls.
|
||||||
|
private val acquiredPumps = mutableMapOf<BluetoothAddress, Pump>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage for when discovery is aborted due to an error.
|
||||||
|
*/
|
||||||
|
object DiscoveryError : BasicProgressStage.Aborted("discoveryError")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when an attempt is made to acquire an already acquired pump.
|
||||||
|
*
|
||||||
|
* Pumps cannot be acquired multiple times simulatenously. This is a safety
|
||||||
|
* measure to prevent multiple [Pump] instances from accessing the same pump,
|
||||||
|
* which would lead to undefined behavior (partially also because this would
|
||||||
|
* cause chaos in [pumpStateStore]). See [PumpManager.acquirePump] for more.
|
||||||
|
*
|
||||||
|
* @param pumpAddress Bluetooth address of the pump that was already acquired.
|
||||||
|
*/
|
||||||
|
class PumpAlreadyAcquiredException(val pumpAddress: BluetoothAddress) :
|
||||||
|
ComboException("Pump with address $pumpAddress was already acquired")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when a pump has not been paired and a function requires a paired pump.
|
||||||
|
*
|
||||||
|
* @param pumpAddress Bluetooth address of the pump that's not paired.
|
||||||
|
*/
|
||||||
|
class PumpNotPairedException(val pumpAddress: BluetoothAddress) :
|
||||||
|
ComboException("Pump with address $pumpAddress has not been paired")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Possible results from a [pairWithNewPump] call.
|
||||||
|
*/
|
||||||
|
sealed class PairingResult {
|
||||||
|
data class Success(
|
||||||
|
val bluetoothAddress: BluetoothAddress,
|
||||||
|
val pumpID: String
|
||||||
|
) : PairingResult()
|
||||||
|
|
||||||
|
class ExceptionDuringPairing(val exception: Exception) : PairingResult()
|
||||||
|
object DiscoveryManuallyStopped : PairingResult()
|
||||||
|
object DiscoveryError : PairingResult()
|
||||||
|
object DiscoveryTimeout : PairingResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
logger(LogLevel.INFO) { "Pump manager started" }
|
||||||
|
|
||||||
|
// Install a filter to make sure we only ever get notified about Combo pumps.
|
||||||
|
bluetoothInterface.deviceFilterCallback = { deviceAddress -> isCombo(deviceAddress) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up this PumpManager instance.
|
||||||
|
*
|
||||||
|
* Once this is called, the [onPumpUnpaired] callback will be invoked
|
||||||
|
* whenever a pump is unpaired (this includes unpairing via the system's
|
||||||
|
* Bluetooth settings). Once this is invoked, the states associated with
|
||||||
|
* the unpaired pump will already have been wiped from the pump state store.
|
||||||
|
* That callback is mainly useful for UI updates. Note however that this
|
||||||
|
* callback is typically called from some background thread. Switching to
|
||||||
|
* a different context with [kotlinx.coroutines.withContext] may be necessary.
|
||||||
|
*
|
||||||
|
* This also checks the available states in the pump state store and compares
|
||||||
|
* this with the list of paired device addresses returned by the
|
||||||
|
* [BluetoothInterface.getPairedDeviceAddresses] function to check for pumps
|
||||||
|
* that may have been unpaired while ComboCtl was not running. This makes sure
|
||||||
|
* that there are no stale states inside the store which otherwise would impact
|
||||||
|
* the event handling and cause other IO issues (especially in future pairing
|
||||||
|
* attempts). If that check reveals states in [pumpStateStore] that do not
|
||||||
|
* have a corresponding device in the list of paired device addresses, then
|
||||||
|
* those stale pump states are erased.
|
||||||
|
*
|
||||||
|
* This must be called before using [pairWithNewPump] or [acquirePump].
|
||||||
|
*
|
||||||
|
* @param onPumpUnpaired Callback for when a previously paired pump is unpaired.
|
||||||
|
* This is typically called from some background thread. Switching to
|
||||||
|
* a different context with [withContext] may be necessary.
|
||||||
|
*/
|
||||||
|
fun setup(onPumpUnpaired: (pumpAddress: BluetoothAddress) -> Unit = { }) {
|
||||||
|
bluetoothInterface.onDeviceUnpaired = { deviceAddress ->
|
||||||
|
onPumpUnpaired(deviceAddress)
|
||||||
|
// Explicitly wipe the pump state to make sure that no stale pump state remains.
|
||||||
|
pumpStateStore.deletePumpState(deviceAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pairedDeviceAddresses = bluetoothInterface.getPairedDeviceAddresses()
|
||||||
|
logger(LogLevel.DEBUG) { "${pairedDeviceAddresses.size} paired Bluetooth device(s)" }
|
||||||
|
|
||||||
|
val availablePumpStates = pumpStateStore.getAvailablePumpStateAddresses()
|
||||||
|
logger(LogLevel.DEBUG) { "${availablePumpStates.size} available pump state(s)" }
|
||||||
|
|
||||||
|
for (deviceAddress in pairedDeviceAddresses) {
|
||||||
|
logger(LogLevel.DEBUG) { "Paired Bluetooth device: $deviceAddress" }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (pumpStateAddress in availablePumpStates) {
|
||||||
|
logger(LogLevel.DEBUG) { "Got state for pump with address $pumpStateAddress" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to keep the list of paired devices and the list of pump states in sync
|
||||||
|
// to keep the pairing process working properly (avoiding pairing attempts when
|
||||||
|
// at Bluetooth level the pump is already paired but at the pump state store level
|
||||||
|
// it isn't) and to prevent incorrect connection attempts from happening
|
||||||
|
// (avoiding connection attempts when at Bluetooth level the pump isn't paired
|
||||||
|
// but at the pump state level it seems like it is).
|
||||||
|
|
||||||
|
// Check for pump states that correspond to those pumps that are no longer paired.
|
||||||
|
// This can happen if the user unpaired the Combo in the Bluetooth settings
|
||||||
|
// while the pump manager wasn't running.
|
||||||
|
for (pumpStateAddress in availablePumpStates) {
|
||||||
|
val pairedDevicePresent = pairedDeviceAddresses.contains(pumpStateAddress)
|
||||||
|
if (!pairedDevicePresent) {
|
||||||
|
logger(LogLevel.DEBUG) { "There is no paired device for pump state with address $pumpStateAddress; deleting state" }
|
||||||
|
pumpStateStore.deletePumpState(pumpStateAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val pairingProgressReporter = ProgressReporter(
|
||||||
|
listOf(
|
||||||
|
BasicProgressStage.ScanningForPumpStage::class,
|
||||||
|
BasicProgressStage.EstablishingBtConnection::class,
|
||||||
|
BasicProgressStage.PerformingConnectionHandshake::class,
|
||||||
|
BasicProgressStage.ComboPairingKeyAndPinRequested::class,
|
||||||
|
BasicProgressStage.ComboPairingFinishing::class
|
||||||
|
),
|
||||||
|
Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [kotlinx.coroutines.flow.StateFlow] for reporting progress during the [pairWithNewPump] call.
|
||||||
|
*
|
||||||
|
* See the [ProgressReporter] documentation for details.
|
||||||
|
*/
|
||||||
|
val pairingProgressFlow = pairingProgressReporter.progressFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the state of the [pairingProgressFlow] back to [BasicProgressStage.Idle].
|
||||||
|
*
|
||||||
|
* This is useful in case the user wants to try again to pair with a pump.
|
||||||
|
* By resetting the state, it is easier to manage UI elements when such
|
||||||
|
* a pairing retry is attempted, especially if the UI orients itself on
|
||||||
|
* the stage field of the [pairingProgressFlow] value, which is
|
||||||
|
* a [info.nightscout.comboctl.base.ProgressReport].
|
||||||
|
*/
|
||||||
|
fun resetPairingProgress() = pairingProgressReporter.reset()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts device discovery and pairs with a pump once one is discovered.
|
||||||
|
*
|
||||||
|
* This function suspends the calling coroutine until a device is found,
|
||||||
|
* the coroutine is cancelled, an error happens during discovery, or
|
||||||
|
* discovery timeouts.
|
||||||
|
*
|
||||||
|
* This manages the Bluetooth device discovery and the pairing
|
||||||
|
* process with new pumps. Once an unpaired pump is discovered,
|
||||||
|
* the Bluetooth implementation pairs with it, using the
|
||||||
|
* [Constants.BT_PAIRING_PIN] PIN code (not to be confused with
|
||||||
|
* the 10-digit Combo PIN).
|
||||||
|
*
|
||||||
|
* When the Bluetooth-level pairing is done, additional processing is
|
||||||
|
* necessary: The Combo-level pairing must be performed, which also sets
|
||||||
|
* up a state in the [PumpStateStore] for the discovered pump.
|
||||||
|
* [onPairingPIN] is called when the Combo-level pairing process
|
||||||
|
* reaches a point where the user must be asked for the 10-digit PIN.
|
||||||
|
*
|
||||||
|
* Note that [onPairingPIN] is called by a coroutine that is run on
|
||||||
|
* a different thread than the one that called this function . With
|
||||||
|
* some UI frameworks like JavaFX, it is invalid to operate UI controls
|
||||||
|
* in coroutines that are not associated with a particular UI coroutine
|
||||||
|
* context. Consider using [kotlinx.coroutines.withContext] in
|
||||||
|
* [onPairingPIN] for this reason.
|
||||||
|
*
|
||||||
|
* Before the pairing starts, this function compares the list of paired
|
||||||
|
* Bluetooth device addresses with the list of pump states in the
|
||||||
|
* [pumpStateStore]. This is similar to the checks done in [setup], except
|
||||||
|
* it is reversed: Each paired device that has no corresponding pump
|
||||||
|
* state in the [pumpStateStore] is unpaired before the new pairing begins.
|
||||||
|
* This is useful to prevent situations where a Combo isn't actually paired,
|
||||||
|
* but [pairWithNewPump] doesn't detect them, because at a Bluetooth level,
|
||||||
|
* that Combo _is_ still paired (that is, the OS has it listed among its
|
||||||
|
* paired devices).
|
||||||
|
*
|
||||||
|
* @param discoveryDuration How long the discovery shall go on,
|
||||||
|
* in seconds. Must be a value between 1 and 300.
|
||||||
|
* @param onPairingPIN Suspending block that asks the user for
|
||||||
|
* the 10-digit pairing PIN during the pairing process.
|
||||||
|
* @throws info.nightscout.comboctl.base.BluetoothException if discovery
|
||||||
|
* fails due to an underlying Bluetooth issue.
|
||||||
|
*/
|
||||||
|
suspend fun pairWithNewPump(
|
||||||
|
discoveryDuration: Int,
|
||||||
|
onPairingPIN: suspend (newPumpAddress: BluetoothAddress, previousAttemptFailed: Boolean) -> PairingPIN
|
||||||
|
): PairingResult {
|
||||||
|
val deferred = CompletableDeferred<PairingResult>()
|
||||||
|
|
||||||
|
lateinit var result: PairingResult
|
||||||
|
|
||||||
|
// Before doing the actual pairing, unpair devices that have no corresponding pump state.
|
||||||
|
|
||||||
|
val pairedDeviceAddresses = bluetoothInterface.getPairedDeviceAddresses()
|
||||||
|
logger(LogLevel.DEBUG) { "${pairedDeviceAddresses.size} paired Bluetooth device(s)" }
|
||||||
|
|
||||||
|
val availablePumpStates = pumpStateStore.getAvailablePumpStateAddresses()
|
||||||
|
logger(LogLevel.DEBUG) { "${availablePumpStates.size} available pump state(s)" }
|
||||||
|
|
||||||
|
// Check for paired pumps that have no corresponding state. This can happen if
|
||||||
|
// the state was deleted and the application crashed before it could unpair the
|
||||||
|
// pump, or if some other application paired the pump. Those devices get unpaired.
|
||||||
|
for (pairedDeviceAddress in pairedDeviceAddresses) {
|
||||||
|
val pumpStatePresent = availablePumpStates.contains(pairedDeviceAddress)
|
||||||
|
if (!pumpStatePresent) {
|
||||||
|
if (isCombo(pairedDeviceAddress)) {
|
||||||
|
logger(LogLevel.DEBUG) { "There is no pump state for paired pump with address $pairedDeviceAddresses; unpairing" }
|
||||||
|
val bluetoothDevice = bluetoothInterface.getDevice(pairedDeviceAddress)
|
||||||
|
bluetoothDevice.unpair()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpairing unknown devices done. Actual pairing of a new pump continues now.
|
||||||
|
|
||||||
|
pairingProgressReporter.reset(Unit)
|
||||||
|
|
||||||
|
// Spawn an internal coroutine scope since we need to launch new coroutines during discovery & pairing.
|
||||||
|
coroutineScope {
|
||||||
|
val thisScope = this
|
||||||
|
try {
|
||||||
|
pairingProgressReporter.setCurrentProgressStage(BasicProgressStage.ScanningForPumpStage)
|
||||||
|
|
||||||
|
bluetoothInterface.startDiscovery(
|
||||||
|
sdpServiceName = Constants.BT_SDP_SERVICE_NAME,
|
||||||
|
sdpServiceProvider = "ComboCtl SDP service",
|
||||||
|
sdpServiceDescription = "ComboCtl",
|
||||||
|
btPairingPin = Constants.BT_PAIRING_PIN,
|
||||||
|
discoveryDuration = discoveryDuration,
|
||||||
|
onDiscoveryStopped = { reason ->
|
||||||
|
when (reason) {
|
||||||
|
BluetoothInterface.DiscoveryStoppedReason.MANUALLY_STOPPED ->
|
||||||
|
deferred.complete(PairingResult.DiscoveryManuallyStopped)
|
||||||
|
BluetoothInterface.DiscoveryStoppedReason.DISCOVERY_ERROR ->
|
||||||
|
deferred.complete(PairingResult.DiscoveryError)
|
||||||
|
BluetoothInterface.DiscoveryStoppedReason.DISCOVERY_TIMEOUT ->
|
||||||
|
deferred.complete(PairingResult.DiscoveryTimeout)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFoundNewPairedDevice = { deviceAddress ->
|
||||||
|
thisScope.launch {
|
||||||
|
pumpStateAccessMutex.withLock {
|
||||||
|
try {
|
||||||
|
logger(LogLevel.DEBUG) { "Found pump with address $deviceAddress" }
|
||||||
|
|
||||||
|
if (pumpStateStore.hasPumpState(deviceAddress)) {
|
||||||
|
logger(LogLevel.DEBUG) { "Skipping added pump since it has already been paired" }
|
||||||
|
} else {
|
||||||
|
performPairing(deviceAddress, onPairingPIN, pairingProgressReporter)
|
||||||
|
|
||||||
|
val pumpID = pumpStateStore.getInvariantPumpData(deviceAddress).pumpID
|
||||||
|
logger(LogLevel.DEBUG) { "Paired pump with address $deviceAddress ; pump ID = $pumpID" }
|
||||||
|
|
||||||
|
deferred.complete(PairingResult.Success(deviceAddress, pumpID))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger(LogLevel.ERROR) { "Caught exception while pairing to pump with address $deviceAddress: $e" }
|
||||||
|
deferred.completeExceptionally(e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = deferred.await()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
logger(LogLevel.DEBUG) { "Pairing cancelled" }
|
||||||
|
pairingProgressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled)
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
pairingProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(e))
|
||||||
|
result = PairingResult.ExceptionDuringPairing(e)
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
bluetoothInterface.stopDiscovery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (result) {
|
||||||
|
// Report Finished/Aborted _after_ discovery was stopped
|
||||||
|
// (otherwise it isn't really finished/aborted yet).
|
||||||
|
is PairingResult.Success ->
|
||||||
|
pairingProgressReporter.setCurrentProgressStage(BasicProgressStage.Finished)
|
||||||
|
is PairingResult.DiscoveryTimeout ->
|
||||||
|
pairingProgressReporter.setCurrentProgressStage(BasicProgressStage.Timeout)
|
||||||
|
is PairingResult.DiscoveryManuallyStopped ->
|
||||||
|
pairingProgressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled)
|
||||||
|
is PairingResult.DiscoveryError ->
|
||||||
|
pairingProgressReporter.setCurrentProgressStage(DiscoveryError)
|
||||||
|
// The other cases are covered by the catch clauses above.
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a set of Bluetooth addresses of the paired pumps.
|
||||||
|
*
|
||||||
|
* This equals the list of addresses of all the pump states in the
|
||||||
|
* [PumpStateStore] assigned to this PumpManager instance.
|
||||||
|
*/
|
||||||
|
fun getPairedPumpAddresses() = pumpStateStore.getAvailablePumpStateAddresses()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the ID of the paired pump with the given address.
|
||||||
|
*
|
||||||
|
* @return String with the pump ID.
|
||||||
|
* @throws PumpStateDoesNotExistException if no pump state associated with
|
||||||
|
* the given address exists in the store.
|
||||||
|
* @throws PumpStateStoreAccessException if accessing the data fails
|
||||||
|
* due to an error that occurred in the underlying implementation.
|
||||||
|
*/
|
||||||
|
fun getPumpID(pumpAddress: BluetoothAddress) =
|
||||||
|
pumpStateStore.getInvariantPumpData(pumpAddress).pumpID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquires a Pump instance for a pump with the given Bluetooth address.
|
||||||
|
*
|
||||||
|
* Pumps can only be acquired once at a time. This is a safety measure to
|
||||||
|
* prevent multiple [Pump] instances from accessing the same pump, which
|
||||||
|
* would lead to undefined behavior. An acquired pump must be un-acquired
|
||||||
|
* by calling [releasePump]. Attempting to acquire an already acquired
|
||||||
|
* pump is an error and will cause this function to throw an exception
|
||||||
|
* ([PumpAlreadyAcquiredException]).
|
||||||
|
*
|
||||||
|
* The pump must have been paired before it can be acquired. If this is
|
||||||
|
* not done, an [PumpNotPairedException] is thrown.
|
||||||
|
*
|
||||||
|
* For details about [initialBasalProfile] and [onEvent], consult the
|
||||||
|
* [Pump] documentation.
|
||||||
|
*
|
||||||
|
* @param pumpAddress Bluetooth address of the pump to acquire.
|
||||||
|
* @param initialBasalProfile Basal profile to use as the initial profile,
|
||||||
|
* or null if no initial profile shall be used.
|
||||||
|
* @param onEvent Callback to inform caller about events that happen
|
||||||
|
* during a connection, like when the battery is going low, or when
|
||||||
|
* a TBR started.
|
||||||
|
* @throws PumpAlreadyAcquiredException if the pump was already acquired.
|
||||||
|
* @throws PumpNotPairedException if the pump was not yet paired.
|
||||||
|
* @throws info.nightscout.comboctl.base.BluetoothException if getting
|
||||||
|
* a [info.nightscout.comboctl.base.BluetoothDevice] for this pump fails.
|
||||||
|
*/
|
||||||
|
suspend fun acquirePump(
|
||||||
|
pumpAddress: BluetoothAddress,
|
||||||
|
initialBasalProfile: BasalProfile? = null,
|
||||||
|
onEvent: (event: Pump.Event) -> Unit = { }
|
||||||
|
) =
|
||||||
|
pumpStateAccessMutex.withLock {
|
||||||
|
if (acquiredPumps.contains(pumpAddress))
|
||||||
|
throw PumpAlreadyAcquiredException(pumpAddress)
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) { "Getting Pump instance for pump $pumpAddress" }
|
||||||
|
|
||||||
|
if (!pumpStateStore.hasPumpState(pumpAddress))
|
||||||
|
throw PumpNotPairedException(pumpAddress)
|
||||||
|
|
||||||
|
val bluetoothDevice = bluetoothInterface.getDevice(pumpAddress)
|
||||||
|
|
||||||
|
val pump = Pump(bluetoothDevice, pumpStateStore, initialBasalProfile, onEvent)
|
||||||
|
|
||||||
|
acquiredPumps[pumpAddress] = pump
|
||||||
|
|
||||||
|
pump // Return the Pump instance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases (= un-acquires) a previously acquired pump with the given address.
|
||||||
|
*
|
||||||
|
* If no such pump was previously acquired, this function does nothing.
|
||||||
|
*
|
||||||
|
* @param acquiredPumpAddress Bluetooth address of the pump to release.
|
||||||
|
*/
|
||||||
|
suspend fun releasePump(acquiredPumpAddress: BluetoothAddress) {
|
||||||
|
pumpStateAccessMutex.withLock {
|
||||||
|
if (!acquiredPumps.contains(acquiredPumpAddress)) {
|
||||||
|
logger(LogLevel.DEBUG) { "A pump with address $acquiredPumpAddress wasn't previously acquired; ignoring call" }
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
|
||||||
|
acquiredPumps.remove(acquiredPumpAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for Combo devices based on their address.
|
||||||
|
// The first 3 bytes of a Combo are always the same.
|
||||||
|
private fun isCombo(deviceAddress: BluetoothAddress) =
|
||||||
|
(deviceAddress[0] == 0x00.toByte()) &&
|
||||||
|
(deviceAddress[1] == 0x0E.toByte()) &&
|
||||||
|
(deviceAddress[2] == 0x2F.toByte())
|
||||||
|
|
||||||
|
private suspend fun performPairing(
|
||||||
|
pumpAddress: BluetoothAddress,
|
||||||
|
onPairingPIN: suspend (newPumpAddress: BluetoothAddress, previousAttemptFailed: Boolean) -> PairingPIN,
|
||||||
|
progressReporter: ProgressReporter<Unit>?
|
||||||
|
) {
|
||||||
|
// NOTE: Pairing can be aborted either by calling stopDiscovery()
|
||||||
|
// or by cancelling the coroutine that runs this functions.
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) { "About to perform pairing with pump $pumpAddress" }
|
||||||
|
|
||||||
|
val bluetoothDevice = bluetoothInterface.getDevice(pumpAddress)
|
||||||
|
logger(LogLevel.DEBUG) { "Got Bluetooth device instance for pump" }
|
||||||
|
|
||||||
|
val pumpIO = PumpIO(pumpStateStore, bluetoothDevice, onNewDisplayFrame = {}, onPacketReceiverException = {})
|
||||||
|
|
||||||
|
if (pumpIO.isPaired()) {
|
||||||
|
logger(LogLevel.INFO) { "Not pairing discovered pump $pumpAddress since it is already paired" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) { "Pump instance ready for pairing" }
|
||||||
|
|
||||||
|
pumpIO.performPairing(bluetoothInterface.getAdapterFriendlyName(), progressReporter, onPairingPIN)
|
||||||
|
|
||||||
|
logger(LogLevel.DEBUG) { "Successfully paired with pump $pumpAddress" }
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,15 @@
|
||||||
|
package info.nightscout.comboctl.parser
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reservoir state as shown on display.
|
||||||
|
*/
|
||||||
|
enum class ReservoirState {
|
||||||
|
EMPTY,
|
||||||
|
LOW,
|
||||||
|
FULL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class with the contents of the RT quickinfo screen.
|
||||||
|
*/
|
||||||
|
data class Quickinfo(val availableUnits: Int, val reservoirState: ReservoirState)
|
|
@ -0,0 +1,319 @@
|
||||||
|
package info.nightscout.comboctl.parser
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDs of known titles.
|
||||||
|
*
|
||||||
|
* Used during parsing to identify the titles in a language-
|
||||||
|
* independent manner by using the parsed screen title as a
|
||||||
|
* key in the [knownScreenTitles] table below.
|
||||||
|
*/
|
||||||
|
enum class TitleID {
|
||||||
|
QUICK_INFO,
|
||||||
|
TBR_PERCENTAGE,
|
||||||
|
TBR_DURATION,
|
||||||
|
HOUR,
|
||||||
|
MINUTE,
|
||||||
|
YEAR,
|
||||||
|
MONTH,
|
||||||
|
DAY,
|
||||||
|
BOLUS_DATA,
|
||||||
|
ERROR_DATA,
|
||||||
|
DAILY_TOTALS,
|
||||||
|
TBR_DATA
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known screen titles in various languages, associated to corresponding IDs.
|
||||||
|
*
|
||||||
|
* This table is useful for converting parsed screen titles to
|
||||||
|
* IDs, which are language-independent and thus considerably
|
||||||
|
* more useful for identifying screens.
|
||||||
|
*
|
||||||
|
* The titles are written in uppercase, since this shows
|
||||||
|
* subtle nuances in characters better.
|
||||||
|
*/
|
||||||
|
val knownScreenTitles = mapOf(
|
||||||
|
// English
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"TBR PERCENTAGE" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"TBR DURATION" to TitleID.TBR_DURATION,
|
||||||
|
"HOUR" to TitleID.HOUR,
|
||||||
|
"MINUTE" to TitleID.MINUTE,
|
||||||
|
"YEAR" to TitleID.YEAR,
|
||||||
|
"MONTH" to TitleID.MONTH,
|
||||||
|
"DAY" to TitleID.DAY,
|
||||||
|
"BOLUS DATA" to TitleID.BOLUS_DATA,
|
||||||
|
"ERROR DATA" to TitleID.ERROR_DATA,
|
||||||
|
"DAILY TOTALS" to TitleID.DAILY_TOTALS,
|
||||||
|
"TBR DATA" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Spanish
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"PORCENTAJE DBT" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"DURACIÓN DE DBT" to TitleID.TBR_DURATION,
|
||||||
|
"HORA" to TitleID.HOUR,
|
||||||
|
"MINUTO" to TitleID.MINUTE,
|
||||||
|
"AÑO" to TitleID.YEAR,
|
||||||
|
"MES" to TitleID.MONTH,
|
||||||
|
"DÍA" to TitleID.DAY,
|
||||||
|
"DATOS DE BOLO" to TitleID.BOLUS_DATA,
|
||||||
|
"DATOS DE ERROR" to TitleID.ERROR_DATA,
|
||||||
|
"TOTALES DIARIOS" to TitleID.DAILY_TOTALS,
|
||||||
|
"DATOS DE DBT" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// French
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"VALEUR DU DBT" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"DURÉE DU DBT" to TitleID.TBR_DURATION,
|
||||||
|
"HEURE" to TitleID.HOUR,
|
||||||
|
"MINUTES" to TitleID.MINUTE,
|
||||||
|
"ANNÉE" to TitleID.YEAR,
|
||||||
|
"MOIS" to TitleID.MONTH,
|
||||||
|
"JOUR" to TitleID.DAY,
|
||||||
|
"BOLUS" to TitleID.BOLUS_DATA,
|
||||||
|
"ERREURS" to TitleID.ERROR_DATA,
|
||||||
|
"QUANTITÉS JOURN." to TitleID.DAILY_TOTALS,
|
||||||
|
"DBT" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Italian
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"PERCENTUALE PBT" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"DURATA PBT" to TitleID.TBR_DURATION,
|
||||||
|
"IMPOSTARE ORA" to TitleID.HOUR,
|
||||||
|
"IMPOSTARE MINUTI" to TitleID.MINUTE,
|
||||||
|
"IMPOSTARE ANNO" to TitleID.YEAR,
|
||||||
|
"IMPOSTARE MESE" to TitleID.MONTH,
|
||||||
|
"IMPOSTARE GIORNO" to TitleID.DAY,
|
||||||
|
"MEMORIA BOLI" to TitleID.BOLUS_DATA,
|
||||||
|
"MEMORIA ALLARMI" to TitleID.ERROR_DATA,
|
||||||
|
"TOTALI GIORNATA" to TitleID.DAILY_TOTALS,
|
||||||
|
"MEMORIA PBT" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Russian
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"ПPOЦEHT BБC" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"ПPOДOЛЖИT. BБC" to TitleID.TBR_DURATION,
|
||||||
|
"ЧАCЫ" to TitleID.HOUR,
|
||||||
|
"МИHУTЫ" to TitleID.MINUTE,
|
||||||
|
"ГOД" to TitleID.YEAR,
|
||||||
|
"МECЯЦ" to TitleID.MONTH,
|
||||||
|
"ДEHЬ" to TitleID.DAY,
|
||||||
|
"ДАHHЫE O БOЛЮCE" to TitleID.BOLUS_DATA,
|
||||||
|
"ДАHHЫE OБ O ИБ." to TitleID.ERROR_DATA,
|
||||||
|
"CУTOЧHЫE ДOЗЫ" to TitleID.DAILY_TOTALS,
|
||||||
|
"ДАHHЫE O BБC" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Turkish
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"GBH YÜZDESİ" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"GBH SÜRESİ" to TitleID.TBR_DURATION,
|
||||||
|
"SAAT" to TitleID.HOUR,
|
||||||
|
"DAKİKA" to TitleID.MINUTE,
|
||||||
|
"YIL" to TitleID.YEAR,
|
||||||
|
"AY" to TitleID.MONTH,
|
||||||
|
"GÜN" to TitleID.DAY,
|
||||||
|
"BOLUS VERİLERİ" to TitleID.BOLUS_DATA,
|
||||||
|
"HATA VERİLERİ" to TitleID.ERROR_DATA,
|
||||||
|
"GÜNLÜK TOPLAM" to TitleID.DAILY_TOTALS,
|
||||||
|
"GBH VERİLERİ" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Polish
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"PROCENT TDP" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"CZAS TRWANIA TDP" to TitleID.TBR_DURATION,
|
||||||
|
"GODZINA" to TitleID.HOUR,
|
||||||
|
"MINUTA" to TitleID.MINUTE,
|
||||||
|
"ROK" to TitleID.YEAR,
|
||||||
|
"MIESIĄC" to TitleID.MONTH,
|
||||||
|
"DZIEŃ" to TitleID.DAY,
|
||||||
|
"DANE BOLUSA" to TitleID.BOLUS_DATA,
|
||||||
|
"DANE BŁĘDU" to TitleID.ERROR_DATA,
|
||||||
|
"DZIEN. D. CAŁK." to TitleID.DAILY_TOTALS,
|
||||||
|
"DANE TDP" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Czech
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"PROCENTO DBD" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"TRVÁNÍ DBD" to TitleID.TBR_DURATION,
|
||||||
|
"HODINA" to TitleID.HOUR,
|
||||||
|
"MINUTA" to TitleID.MINUTE,
|
||||||
|
"ROK" to TitleID.YEAR,
|
||||||
|
"MĚSÍC" to TitleID.MONTH,
|
||||||
|
"DEN" to TitleID.DAY,
|
||||||
|
"ÚDAJE BOLUSŮ" to TitleID.BOLUS_DATA,
|
||||||
|
"ÚDAJE CHYB" to TitleID.ERROR_DATA,
|
||||||
|
"CELK. DEN. DÁVKY" to TitleID.DAILY_TOTALS,
|
||||||
|
"ÚDAJE DBD" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Hungarian
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"TBR SZÁZALÉK" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"TBR IDŐTARTAM" to TitleID.TBR_DURATION,
|
||||||
|
"ÓRA" to TitleID.HOUR,
|
||||||
|
"PERC" to TitleID.MINUTE,
|
||||||
|
"ÉV" to TitleID.YEAR,
|
||||||
|
"HÓNAP" to TitleID.MONTH,
|
||||||
|
"NAP" to TitleID.DAY,
|
||||||
|
"BÓLUSADATOK" to TitleID.BOLUS_DATA,
|
||||||
|
"HIBAADATOK" to TitleID.ERROR_DATA,
|
||||||
|
"NAPI TELJES" to TitleID.DAILY_TOTALS,
|
||||||
|
"TBR-ADATOK" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Slovak
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"PERCENTO DBD" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"TRVANIE DBD" to TitleID.TBR_DURATION,
|
||||||
|
"HODINA" to TitleID.HOUR,
|
||||||
|
"MINÚTA" to TitleID.MINUTE,
|
||||||
|
"ROK" to TitleID.YEAR,
|
||||||
|
"MESIAC" to TitleID.MONTH,
|
||||||
|
"DEŇ" to TitleID.DAY,
|
||||||
|
"BOLUSOVÉ DÁTA" to TitleID.BOLUS_DATA,
|
||||||
|
"DÁTA O CHYBÁCH" to TitleID.ERROR_DATA,
|
||||||
|
"SÚČTY DŇA" to TitleID.DAILY_TOTALS,
|
||||||
|
"DBD DÁTA" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Romanian
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"PROCENT RBT" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"DURATA RBT" to TitleID.TBR_DURATION,
|
||||||
|
"ORĂ" to TitleID.HOUR,
|
||||||
|
"MINUT" to TitleID.MINUTE,
|
||||||
|
"AN" to TitleID.YEAR,
|
||||||
|
"LUNĂ" to TitleID.MONTH,
|
||||||
|
"ZI" to TitleID.DAY,
|
||||||
|
"DATE BOLUS" to TitleID.BOLUS_DATA,
|
||||||
|
"DATE EROARE" to TitleID.ERROR_DATA,
|
||||||
|
"TOTALURI ZILNICE" to TitleID.DAILY_TOTALS,
|
||||||
|
"DATE RBT" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Croatian
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"POSTOTAK PBD-A" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"TRAJANJE PBD-A" to TitleID.TBR_DURATION,
|
||||||
|
"SAT" to TitleID.HOUR,
|
||||||
|
"MINUTE" to TitleID.MINUTE,
|
||||||
|
"GODINA" to TitleID.YEAR,
|
||||||
|
"MJESEC" to TitleID.MONTH,
|
||||||
|
"DAN" to TitleID.DAY,
|
||||||
|
"PODACI O BOLUSU" to TitleID.BOLUS_DATA,
|
||||||
|
"PODACI O GREŠK." to TitleID.ERROR_DATA,
|
||||||
|
"UKUPNE DNEV.DOZE" to TitleID.DAILY_TOTALS,
|
||||||
|
"PODACI O PBD-U" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Dutch
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"TBD-PERCENTAGE" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"TBD-DUUR" to TitleID.TBR_DURATION,
|
||||||
|
"UREN" to TitleID.HOUR,
|
||||||
|
"MINUTEN" to TitleID.MINUTE,
|
||||||
|
"JAAR" to TitleID.YEAR,
|
||||||
|
"MAAND" to TitleID.MONTH,
|
||||||
|
"DAG" to TitleID.DAY,
|
||||||
|
"BOLUSGEGEVENS" to TitleID.BOLUS_DATA,
|
||||||
|
"FOUTENGEGEVENS" to TitleID.ERROR_DATA,
|
||||||
|
"DAGTOTALEN" to TitleID.DAILY_TOTALS,
|
||||||
|
"TBD-GEGEVENS" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Greek
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"ПOΣOΣTO П.B.P." to TitleID.TBR_PERCENTAGE,
|
||||||
|
"ΔIАPKEIА П.B.P." to TitleID.TBR_DURATION,
|
||||||
|
"ΩPА" to TitleID.HOUR,
|
||||||
|
"ΛEПTO" to TitleID.MINUTE,
|
||||||
|
"ETOΣ" to TitleID.YEAR,
|
||||||
|
"МHNАΣ" to TitleID.MONTH,
|
||||||
|
"HМEPА" to TitleID.DAY,
|
||||||
|
"ΔEΔOМENА ΔOΣEΩN" to TitleID.BOLUS_DATA,
|
||||||
|
"ΔEΔOМ. ΣΦАΛМАTΩN" to TitleID.ERROR_DATA,
|
||||||
|
"HМEPHΣIO ΣΥNOΛO" to TitleID.DAILY_TOTALS,
|
||||||
|
"ΔEΔOМENА П.B.P." to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Finnish
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"TBA - PROSENTTI" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"TBA - KESTO" to TitleID.TBR_DURATION,
|
||||||
|
"TUNTI" to TitleID.HOUR,
|
||||||
|
"MINUUTTI" to TitleID.MINUTE,
|
||||||
|
"VUOSI" to TitleID.YEAR,
|
||||||
|
"KUUKAUSI" to TitleID.MONTH,
|
||||||
|
"PÄIVÄ" to TitleID.DAY,
|
||||||
|
"BOLUSTIEDOT" to TitleID.BOLUS_DATA,
|
||||||
|
"HÄLYTYSTIEDOT" to TitleID.ERROR_DATA,
|
||||||
|
"PÄIV. KOK.ANNOS" to TitleID.DAILY_TOTALS,
|
||||||
|
"TBA - TIEDOT" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Norwegian
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"MBD-PROSENT" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"MBD-VARIGHET" to TitleID.TBR_DURATION,
|
||||||
|
"TIME" to TitleID.HOUR,
|
||||||
|
"MINUTT" to TitleID.MINUTE,
|
||||||
|
"ÅR" to TitleID.YEAR,
|
||||||
|
"MÅNED" to TitleID.MONTH,
|
||||||
|
"DAG" to TitleID.DAY,
|
||||||
|
"BOLUSDATA" to TitleID.BOLUS_DATA,
|
||||||
|
"FEILDATA" to TitleID.ERROR_DATA,
|
||||||
|
"DØGNMENGDE" to TitleID.DAILY_TOTALS,
|
||||||
|
"MBD-DATA" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Portuguese
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"DBT PERCENTAGEM" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"DBT DURAÇÃO" to TitleID.TBR_DURATION,
|
||||||
|
"HORA" to TitleID.HOUR,
|
||||||
|
"MINUTO" to TitleID.MINUTE,
|
||||||
|
"ANO" to TitleID.YEAR,
|
||||||
|
"MÊS" to TitleID.MONTH,
|
||||||
|
"DIA" to TitleID.DAY,
|
||||||
|
"DADOS DE BOLUS" to TitleID.BOLUS_DATA,
|
||||||
|
// on some newer pumps translations have changed, so a menu can have multiple names
|
||||||
|
"DADOS DE ERROS" to TitleID.ERROR_DATA, "DADOS DE ALARMES" to TitleID.ERROR_DATA,
|
||||||
|
"TOTAIS DIÁRIOS" to TitleID.DAILY_TOTALS,
|
||||||
|
"DADOS DBT" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Swedish
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"TBD PROCENT" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"TBD DURATION" to TitleID.TBR_DURATION,
|
||||||
|
"TIMME" to TitleID.HOUR,
|
||||||
|
"MINUT" to TitleID.MINUTE,
|
||||||
|
"ÅR" to TitleID.YEAR,
|
||||||
|
"MÅNAD" to TitleID.MONTH,
|
||||||
|
"DAG" to TitleID.DAY,
|
||||||
|
"BOLUSDATA" to TitleID.BOLUS_DATA,
|
||||||
|
"FELDATA" to TitleID.ERROR_DATA,
|
||||||
|
"DYGNSHISTORIK" to TitleID.DAILY_TOTALS,
|
||||||
|
"TBD DATA" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Danish
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"MBR-PROCENT" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"MBR-VARIGHED" to TitleID.TBR_DURATION,
|
||||||
|
"TIME" to TitleID.HOUR,
|
||||||
|
"MINUT" to TitleID.MINUTE,
|
||||||
|
"ÅR" to TitleID.YEAR,
|
||||||
|
"MÅNED" to TitleID.MONTH,
|
||||||
|
"DAG" to TitleID.DAY,
|
||||||
|
"BOLUSDATA" to TitleID.BOLUS_DATA,
|
||||||
|
"FEJLDATA" to TitleID.ERROR_DATA,
|
||||||
|
"DAGLIG TOTAL" to TitleID.DAILY_TOTALS,
|
||||||
|
"MBR-DATA" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// German
|
||||||
|
"QUICK INFO" to TitleID.QUICK_INFO,
|
||||||
|
"TBR WERT" to TitleID.TBR_PERCENTAGE,
|
||||||
|
"TBR DAUER" to TitleID.TBR_DURATION,
|
||||||
|
"STUNDE" to TitleID.HOUR,
|
||||||
|
"MINUTE" to TitleID.MINUTE,
|
||||||
|
"JAHR" to TitleID.YEAR,
|
||||||
|
"MONAT" to TitleID.MONTH,
|
||||||
|
"TAG" to TitleID.DAY,
|
||||||
|
"BOLUSINFORMATION" to TitleID.BOLUS_DATA,
|
||||||
|
"FEHLERMELDUNGEN" to TitleID.ERROR_DATA,
|
||||||
|
"TAGESGESAMTMENGE" to TitleID.DAILY_TOTALS,
|
||||||
|
"TBR-INFORMATION" to TitleID.TBR_DATA,
|
||||||
|
|
||||||
|
// Some pumps came preconfigured with a different quick info name
|
||||||
|
"ACCU CHECK SPIRIT" to TitleID.QUICK_INFO
|
||||||
|
)
|
|
@ -0,0 +1,222 @@
|
||||||
|
package info.nightscout.comboctl.parser
|
||||||
|
|
||||||
|
import info.nightscout.comboctl.base.DISPLAY_FRAME_HEIGHT
|
||||||
|
import info.nightscout.comboctl.base.DISPLAY_FRAME_WIDTH
|
||||||
|
import info.nightscout.comboctl.base.DisplayFrame
|
||||||
|
import kotlin.math.sign
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structure containing details about a match discovered in a [DisplayFrame].
|
||||||
|
*
|
||||||
|
* The match is referred to as a "token", similar to lexical tokens
|
||||||
|
* in lexical analyzers.
|
||||||
|
*
|
||||||
|
* This is the result of a pattern search in a display frame.
|
||||||
|
*
|
||||||
|
* @property pattern The pattern for which a match was found.
|
||||||
|
* @property glyph [Glyph] associated with the pattern.
|
||||||
|
* @property x X-coordinate of the location of the token in the display frame.
|
||||||
|
* @property y Y-coordinate of the location of the token in the display frame.
|
||||||
|
*/
|
||||||
|
data class Token(
|
||||||
|
val pattern: Pattern,
|
||||||
|
val glyph: Glyph,
|
||||||
|
val x: Int,
|
||||||
|
val y: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of tokens found in the display frame by the [findTokens] function.
|
||||||
|
*/
|
||||||
|
typealias Tokens = List<Token>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the region at the given coordinates matches the given pattern.
|
||||||
|
*
|
||||||
|
* This is used for finding tokerns in a frame.
|
||||||
|
*
|
||||||
|
* @param displayFrame [DisplayFrame] that contains the region to match the pattern against.
|
||||||
|
* @param pattern Pattern to match with the region in the display frame.
|
||||||
|
* @param x X-coordinate of the region in the display frame.
|
||||||
|
* @param y Y-coordinate of the region in the display frame.
|
||||||
|
* @return true if the region matches the pattern. false in case of mismatch
|
||||||
|
* or if the coordinates would place the pattern (partially) outside
|
||||||
|
* of the bounds of the display frame.
|
||||||
|
*/
|
||||||
|
fun checkIfPatternMatchesAt(displayFrame: DisplayFrame, pattern: Pattern, x: Int, y: Int): Boolean {
|
||||||
|
if ((x < 0) || (y < 0) ||
|
||||||
|
((x + pattern.width) > DISPLAY_FRAME_WIDTH) ||
|
||||||
|
((y + pattern.height) > DISPLAY_FRAME_HEIGHT))
|
||||||
|
return false
|
||||||
|
|
||||||
|
// Simple naive brute force match.
|
||||||
|
// TODO: See if a two-dimensional variant of the Boyer-Moore algorithm can be used instead.
|
||||||
|
|
||||||
|
for (py in 0 until pattern.height) {
|
||||||
|
for (px in 0 until pattern.width) {
|
||||||
|
val patternPixel = pattern.pixels[px + py * pattern.width]
|
||||||
|
val framePixel = displayFrame.getPixelAt(
|
||||||
|
x + px,
|
||||||
|
y + py
|
||||||
|
)
|
||||||
|
|
||||||
|
if (patternPixel != framePixel)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look for regions in the display frame that can be turned into tokens.
|
||||||
|
*
|
||||||
|
* This will first do a pattern matching search, and then try to filter out matches that
|
||||||
|
* overlap with other matches and are considered to be unnecessary / undesirable by the
|
||||||
|
* internal heuristic (for example, a part of the multiwave bolus pattern also looks like
|
||||||
|
* the character L, but in case of such an overlap, we are interested in the former).
|
||||||
|
* The remaining matches are output as tokens.
|
||||||
|
*
|
||||||
|
* @param displayFrame [DisplayFrame] to search for tokens.
|
||||||
|
* @return Tokens found in this frame.
|
||||||
|
*/
|
||||||
|
fun findTokens(displayFrame: DisplayFrame): Tokens {
|
||||||
|
val tokens = mutableListOf<Token>()
|
||||||
|
|
||||||
|
// Scan through the display frame and look for tokens.
|
||||||
|
|
||||||
|
var y = 0
|
||||||
|
|
||||||
|
while (y < DISPLAY_FRAME_HEIGHT) {
|
||||||
|
var x = 0
|
||||||
|
|
||||||
|
while (x < DISPLAY_FRAME_WIDTH) {
|
||||||
|
for ((glyph, pattern) in glyphPatterns) {
|
||||||
|
if (checkIfPatternMatchesAt(displayFrame, pattern, x, y)) {
|
||||||
|
// Current region in the display frame matches this pattern.
|
||||||
|
// Create a token out of the pattern, glyph, and coordinates,
|
||||||
|
// add the token to the list of found tokens, and move past the
|
||||||
|
// matched pattern horizontally. (There's no point in advancing
|
||||||
|
// pixel by pixel horizontally since the next pattern.width pixels
|
||||||
|
// are guaranteed to be part of the already discovered token).
|
||||||
|
tokens.add(Token(pattern, glyph, x, y))
|
||||||
|
x += pattern.width - 1 // -1 since the x value is incremented below.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for overlaps. The pattern matching is not automatically unambiguous.
|
||||||
|
// For example, one of the corners of the multiwave bolus icon also matches
|
||||||
|
// the small 'L' character pattern. Try to resolve overlaps here and remove
|
||||||
|
// tokens if required. (In the example above, the 'L' pattern match is not
|
||||||
|
// needed and can be discarded - the match of interest there is the multiwave
|
||||||
|
// bolus icon pattern match.)
|
||||||
|
|
||||||
|
val tokensToRemove = mutableSetOf<Token>()
|
||||||
|
|
||||||
|
// First, determine what tokens to remove.
|
||||||
|
for (tokenB in tokens) {
|
||||||
|
for (tokenA in tokens) {
|
||||||
|
// Get the coordinates of the top-left (x1,y1) and bottom-right (x2,y2)
|
||||||
|
// corners of the bounding rectangles of both matches. The (x2,y2)
|
||||||
|
// coordinates are inclusive, that is, still inside the rectangle, and
|
||||||
|
// at the rectangle's bottom right corner. (That's why there's the -1
|
||||||
|
// in the calculations below; it avoids a fencepost error.)
|
||||||
|
|
||||||
|
val tokenAx1 = tokenA.x
|
||||||
|
val tokenAy1 = tokenA.y
|
||||||
|
val tokenAx2 = tokenA.x + tokenA.pattern.width - 1
|
||||||
|
val tokenAy2 = tokenA.y + tokenA.pattern.height - 1
|
||||||
|
|
||||||
|
val tokenBx1 = tokenB.x
|
||||||
|
val tokenBy1 = tokenB.y
|
||||||
|
val tokenBx2 = tokenB.x + tokenB.pattern.width - 1
|
||||||
|
val tokenBy2 = tokenB.y + tokenB.pattern.height - 1
|
||||||
|
|
||||||
|
/* Overlap detection:
|
||||||
|
|
||||||
|
Given two rectangles A and B:
|
||||||
|
|
||||||
|
Example of non-overlap:
|
||||||
|
|
||||||
|
< A >
|
||||||
|
< B >
|
||||||
|
|---xd2---|
|
||||||
|
|
||||||
|
|-----------------------------------------xd1-----------------------------------------|
|
||||||
|
|
||||||
|
Example of overlap:
|
||||||
|
|
||||||
|
< A >
|
||||||
|
< B >
|
||||||
|
|----------xd2----------|
|
||||||
|
|
||||||
|
|------------------------xd1------------------------|
|
||||||
|
|
||||||
|
xd1 = distance from A.x1 to B.x2
|
||||||
|
xd2 = distance from A.x2 to B.x1
|
||||||
|
|
||||||
|
If B is fully to the right of A, then both xd1 and xd2 are positive.
|
||||||
|
If B is fully to the left of A, then both xd1 and xd2 are negative.
|
||||||
|
If xd1 is positive and xd2 is negative (or vice versa), then A and B are overlapping in the X direction.
|
||||||
|
|
||||||
|
The same tests are done in Y direction.
|
||||||
|
|
||||||
|
If A and B overlap in both X and Y direction, they overlap overall.
|
||||||
|
|
||||||
|
It follows that:
|
||||||
|
if (xd1 is positive and xd2 is negative) or (xd1 is negative and xd2 is positive) and
|
||||||
|
(yd1 is positive and yd2 is negative) or (yd1 is negative and yd2 is positive) -> A and B overlap.
|
||||||
|
|
||||||
|
The (xd1 is positive and xd2 is negative) or (xd1 is negative and xd2 is positive) check
|
||||||
|
can be shorted to: (sign(xd1) != sign(xd2)). The same applies to the checks in the Y direction.
|
||||||
|
|
||||||
|
-> Final check: if (sign(xd1) != sign(xd2)) and (sign(yd1) != sign(yd2)) -> A and B overlap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
val xd1 = (tokenBx2 - tokenAx1)
|
||||||
|
val xd2 = (tokenBx1 - tokenAx2)
|
||||||
|
val yd1 = (tokenBy2 - tokenAy1)
|
||||||
|
val yd2 = (tokenBy1 - tokenAy2)
|
||||||
|
|
||||||
|
val tokensOverlap = (xd1.sign != xd2.sign) && (yd1.sign != yd2.sign)
|
||||||
|
|
||||||
|
if (tokensOverlap) {
|
||||||
|
// Heuristic for checking if one of the two overlapping tokens
|
||||||
|
// needs to be removed:
|
||||||
|
//
|
||||||
|
// 1. If one token has a large pattern and the other doesn't,
|
||||||
|
// keep the large one and discard the smaller one. Parts of larger
|
||||||
|
// patterns can be misintepreted as some of the smaller patterns,
|
||||||
|
// which is the reason for this heuristic.
|
||||||
|
// 2. If one token has a larger numSetPixels value than the other,
|
||||||
|
// pick that one. A higher number of set pixels is considered to
|
||||||
|
// indicate a more "complex" or "informative" pattern. For example,
|
||||||
|
// the 2 blocks of 2x2 pixels at the top ends of the large 'U'
|
||||||
|
// character token can also be interpreted as a large dot token.
|
||||||
|
// However, the large dot token has 4 set pixels, while the large
|
||||||
|
// 'U' character token has many more, so the latter "wins".
|
||||||
|
if (tokenA.glyph.isLarge && !tokenB.glyph.isLarge)
|
||||||
|
tokensToRemove.add(tokenB)
|
||||||
|
else if (!tokenA.glyph.isLarge && tokenB.glyph.isLarge)
|
||||||
|
tokensToRemove.add(tokenA)
|
||||||
|
else if (tokenA.pattern.numSetPixels > tokenB.pattern.numSetPixels)
|
||||||
|
tokensToRemove.add(tokenB)
|
||||||
|
else if (tokenA.pattern.numSetPixels < tokenB.pattern.numSetPixels)
|
||||||
|
tokensToRemove.add(tokenA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The actual token removal.
|
||||||
|
if (tokensToRemove.isNotEmpty())
|
||||||
|
tokens.removeAll(tokensToRemove)
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.text.Charsets
|
||||||
|
|
||||||
|
class CRCTest {
|
||||||
|
@Test
|
||||||
|
fun verifyChecksum() {
|
||||||
|
val inputData = "0123456789abcdef".toByteArray(Charsets.UTF_8).toList()
|
||||||
|
|
||||||
|
val expectedChecksum = 0x02A2
|
||||||
|
val actualChecksum = calculateCRC16MCRF4XX(inputData)
|
||||||
|
assertEquals(expectedChecksum, actualChecksum)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.text.Charsets
|
||||||
|
|
||||||
|
class CipherTest {
|
||||||
|
@Test
|
||||||
|
fun checkWeakKeyGeneration() {
|
||||||
|
// Generate a weak key out of the PIN 012-345-6789.
|
||||||
|
|
||||||
|
val PIN = PairingPIN(intArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9))
|
||||||
|
|
||||||
|
try {
|
||||||
|
val expectedWeakKey = byteArrayListOfInts(
|
||||||
|
0x30, 0x31, 0x32, 0x33,
|
||||||
|
0x34, 0x35, 0x36, 0x37,
|
||||||
|
0x38, 0x39, 0xcf, 0xce,
|
||||||
|
0xcd, 0xcc, 0xcb, 0xca
|
||||||
|
)
|
||||||
|
val actualWeakKey = generateWeakKeyFromPIN(PIN)
|
||||||
|
assertEquals(expectedWeakKey, actualWeakKey.toList())
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
ex.printStackTrace()
|
||||||
|
throw Error("Unexpected exception: $ex")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkEncryptDecrypt() {
|
||||||
|
// Encrypt and decrypt the text "0123456789abcdef".
|
||||||
|
// Verify that the encrypted version is what we expect,
|
||||||
|
// and that decrypting that version yields the original text.
|
||||||
|
// For this test, we use a key that is simply the value 48
|
||||||
|
// (= ASCII index of the character '0'), repeated 16 times.
|
||||||
|
// (16 is the number of bytes in a 128-bit key.)
|
||||||
|
|
||||||
|
val inputData = "0123456789abcdef".toByteArray(Charsets.UTF_8)
|
||||||
|
|
||||||
|
val key = ByteArray(CIPHER_KEY_SIZE)
|
||||||
|
key.fill('0'.code.toByte())
|
||||||
|
|
||||||
|
val cipher = Cipher(key)
|
||||||
|
|
||||||
|
val expectedEncryptedData = byteArrayListOfInts(
|
||||||
|
0xb3, 0x58, 0x09, 0xd0,
|
||||||
|
0xe3, 0xb4, 0xa0, 0x2e,
|
||||||
|
0x1a, 0xbb, 0x6b, 0x1a,
|
||||||
|
0xfa, 0xeb, 0x31, 0xc8
|
||||||
|
)
|
||||||
|
val actualEncryptedData = cipher.encrypt(inputData)
|
||||||
|
assertEquals(expectedEncryptedData, actualEncryptedData.toList())
|
||||||
|
|
||||||
|
val decryptedData = cipher.decrypt(actualEncryptedData)
|
||||||
|
assertEquals(inputData.toList(), decryptedData.toList())
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,207 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
// Payload which contains some bytes that equal "special" or "reserved" bytes.
|
||||||
|
// These bytes are 0xCC and 0x77.
|
||||||
|
val payloadDataWithSpecialBytes = byteArrayListOfInts(
|
||||||
|
0x11, 0x22, 0x11,
|
||||||
|
0xCC,
|
||||||
|
0x11,
|
||||||
|
0x77,
|
||||||
|
0x44,
|
||||||
|
0x77, 0xCC,
|
||||||
|
0x00,
|
||||||
|
0xCC, 0x77,
|
||||||
|
0x55
|
||||||
|
)
|
||||||
|
|
||||||
|
// The frame version of the payload above, with the frame delimiter 0xCC at
|
||||||
|
// the beginning and end, plus the colliding payload bytes in escaped form.
|
||||||
|
val frameDataWithEscapedSpecialBytes = byteArrayListOfInts(
|
||||||
|
0xCC,
|
||||||
|
0x11, 0x22, 0x11,
|
||||||
|
0x77, 0xDD, // 0x77 0xDD is the escaped form of 0xCC
|
||||||
|
0x11,
|
||||||
|
0x77, 0xEE, // 0xEE 0xDD is the escaped form of 0x77
|
||||||
|
0x44,
|
||||||
|
0x77, 0xEE, 0x77, 0xDD,
|
||||||
|
0x00,
|
||||||
|
0x77, 0xDD, 0x77, 0xEE,
|
||||||
|
0x55,
|
||||||
|
0xCC
|
||||||
|
)
|
||||||
|
|
||||||
|
class ComboFrameTest {
|
||||||
|
@Test
|
||||||
|
fun produceEscapedFrameData() {
|
||||||
|
// Frame the payload and check that the framing is done correctly.
|
||||||
|
|
||||||
|
val producedEscapedFrameData = payloadDataWithSpecialBytes.toComboFrame()
|
||||||
|
assertEquals(frameDataWithEscapedSpecialBytes, producedEscapedFrameData)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseEscapedFrameData() {
|
||||||
|
// Parse escaped frame data and check that the original payload is recovered.
|
||||||
|
|
||||||
|
val parser = ComboFrameParser()
|
||||||
|
parser.pushData(frameDataWithEscapedSpecialBytes)
|
||||||
|
|
||||||
|
val parsedPayloadData = parser.parseFrame()
|
||||||
|
assertTrue(parsedPayloadData != null)
|
||||||
|
assertEquals(payloadDataWithSpecialBytes, parsedPayloadData)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsePartialFrameData() {
|
||||||
|
// Frame data can come in partial chunks. The parser has to accumulate the
|
||||||
|
// chunks and try to find a complete frame within the accumulated data.
|
||||||
|
// If none can be found, parseFrame() returns null. Otherwise, it extracts
|
||||||
|
// the data within the two delimiters of the frame, un-escapes any escaped
|
||||||
|
// bytes, and returns the recovered payload from that frame.
|
||||||
|
|
||||||
|
val parser = ComboFrameParser()
|
||||||
|
|
||||||
|
// Three chunks of partial frame data. Only after all three have been pushed
|
||||||
|
// into the parser can it find a complete frame. In fact, it then has
|
||||||
|
// accumulated data containing even _two_ complete frames.
|
||||||
|
|
||||||
|
// The first chunk, which starts the first frame. No complete frame yet.
|
||||||
|
val partialFrameData1 = byteArrayListOfInts(
|
||||||
|
0xCC,
|
||||||
|
0x11, 0x22, 0x33
|
||||||
|
)
|
||||||
|
// Next chunk, contains more bytes of the first frame, but still no end
|
||||||
|
// of that frame.
|
||||||
|
val partialFrameData2 = byteArrayListOfInts(
|
||||||
|
0x44, 0x55
|
||||||
|
)
|
||||||
|
// Last chunk. It not only contains the second delimiter of the first frame,
|
||||||
|
// but also a complete second frame.
|
||||||
|
val partialFrameData3 = byteArrayListOfInts(
|
||||||
|
0xCC,
|
||||||
|
0xCC,
|
||||||
|
0x66, 0x88, 0x99,
|
||||||
|
0xCC
|
||||||
|
)
|
||||||
|
// The two frames contained in the three chunks above have these two payloads.
|
||||||
|
val payloadFromPartialData1 = byteArrayListOfInts(0x11, 0x22, 0x33, 0x44, 0x55)
|
||||||
|
val payloadFromPartialData2 = byteArrayListOfInts(0x66, 0x88, 0x99)
|
||||||
|
|
||||||
|
// Push the first chunk into the parsed frame. We don't expect
|
||||||
|
// it to actually parse something yet.
|
||||||
|
parser.pushData(partialFrameData1)
|
||||||
|
var parsedPayloadData = parser.parseFrame()
|
||||||
|
assertEquals(null, parsedPayloadData)
|
||||||
|
|
||||||
|
// Push the second chunk. We still don't expect a parsed frame,
|
||||||
|
// since the second chunk does not complete the first frame yet.
|
||||||
|
parser.pushData(partialFrameData2)
|
||||||
|
parsedPayloadData = parser.parseFrame()
|
||||||
|
assertEquals(null, parsedPayloadData)
|
||||||
|
|
||||||
|
// Push the last chunk. With that chunk, the parser accumulated
|
||||||
|
// enough data to parse the first frame and an additional frame.
|
||||||
|
// Therefore, we expect the next two parseFrame() calls to
|
||||||
|
// return a non-null value - the expected payloads.
|
||||||
|
parser.pushData(partialFrameData3)
|
||||||
|
parsedPayloadData = parser.parseFrame()
|
||||||
|
assertEquals(payloadFromPartialData1, parsedPayloadData!!)
|
||||||
|
parsedPayloadData = parser.parseFrame()
|
||||||
|
assertEquals(payloadFromPartialData2, parsedPayloadData!!)
|
||||||
|
|
||||||
|
// There is no accumulated data left for parsing, so we
|
||||||
|
// expect the parseFrame() call to return null.
|
||||||
|
parsedPayloadData = parser.parseFrame()
|
||||||
|
assertEquals(null, parsedPayloadData)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsePartialFrameDataWithSpecialBytes() {
|
||||||
|
// Test the parser with partial chunks again. But this time,
|
||||||
|
// the payload within the frames contain special bytes, so
|
||||||
|
// the chunks contain escaped bytes.
|
||||||
|
|
||||||
|
val parser = ComboFrameParser()
|
||||||
|
|
||||||
|
// First partial chunk. It ends with an escape byte (0x77). The
|
||||||
|
// parser cannot do anything with that escape byte alone, since
|
||||||
|
// only with a followup byte can it be determined what byte was
|
||||||
|
// escaped.
|
||||||
|
val partialFrameDataWithSpecialBytes1 = byteArrayListOfInts(
|
||||||
|
0xCC,
|
||||||
|
0x11, 0x22, 0x77
|
||||||
|
)
|
||||||
|
// Second partial chunk. Completes the frame, and provides the
|
||||||
|
// missing byte that, together with the escape byte from the
|
||||||
|
// previous chunk, combines to 0x77 0xEE, which is the escaped
|
||||||
|
// form of the payload byte 0x77.
|
||||||
|
val partialFrameDataWithSpecialBytes2 = byteArrayListOfInts(
|
||||||
|
0xEE, 0x33, 0xCC
|
||||||
|
)
|
||||||
|
// The payload in the frame that is transported over the chunks.
|
||||||
|
val payloadFromPartialDataWithSpecialBytes = byteArrayListOfInts(0x11, 0x22, 0x77, 0x33)
|
||||||
|
|
||||||
|
// Push the first chunk. We don't expect the parser to return
|
||||||
|
// anything yet.
|
||||||
|
parser.pushData(partialFrameDataWithSpecialBytes1)
|
||||||
|
var parsedPayloadData = parser.parseFrame()
|
||||||
|
assertEquals(null, parsedPayloadData)
|
||||||
|
|
||||||
|
// Push the second chunk. The frame is now complete. The parser
|
||||||
|
// should now find the frame and extract the payload, in correct
|
||||||
|
// un-escaped form.
|
||||||
|
parser.pushData(partialFrameDataWithSpecialBytes2)
|
||||||
|
parsedPayloadData = parser.parseFrame()
|
||||||
|
assertEquals(payloadFromPartialDataWithSpecialBytes, parsedPayloadData!!)
|
||||||
|
|
||||||
|
// There is no accumulated data left for parsing, so we
|
||||||
|
// expect the parseFrame() call to return null.
|
||||||
|
parsedPayloadData = parser.parseFrame()
|
||||||
|
assertEquals(null, parsedPayloadData)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseNonDelimiterOutsideOfFrame() {
|
||||||
|
// Outside of frames, only the frame delimiter byte 0x77 is expected.
|
||||||
|
// That's because the Combo frames are tightly packed, like this:
|
||||||
|
//
|
||||||
|
// 0xCC <frame 1 bytes> 0xCC 0xCC <frame 2 bytes> 0xCC ...
|
||||||
|
|
||||||
|
val parser = ComboFrameParser()
|
||||||
|
|
||||||
|
// This is invalid data, since 0x11 lies before the 0xCC frame delimiter,
|
||||||
|
// meaning that the 0x11 byte is "outside of a frame".
|
||||||
|
val frameDataWithNonDelimiterOutsideOfFrame = byteArrayListOfInts(
|
||||||
|
0x11, 0xCC
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.pushData(frameDataWithNonDelimiterOutsideOfFrame)
|
||||||
|
|
||||||
|
assertFailsWith<FrameParseException> { parser.parseFrame() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseInvalidEscapeByteCombination() {
|
||||||
|
// In frame data, the escape byte 0x77 is followed by the byte 0xDD
|
||||||
|
// or 0xEE (the escaped form of bytes 0xCC and 0x77, respectively).
|
||||||
|
// Any other byte immediately following 0x77 is invalid, since no
|
||||||
|
// escaping is defined then. For example, 0x77 0x88 does not describe
|
||||||
|
// anything.
|
||||||
|
|
||||||
|
val parser = ComboFrameParser()
|
||||||
|
|
||||||
|
// 0x77 0xAA is an invalid sequence.
|
||||||
|
val frameDataWithInvalidEscapeByteCombination = byteArrayListOfInts(
|
||||||
|
0xCC, 0x77, 0xAA, 0xCC
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.pushData(frameDataWithInvalidEscapeByteCombination)
|
||||||
|
|
||||||
|
assertFailsWith<FrameParseException> { parser.parseFrame() }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,236 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
// The frame contents contained in the original frame rows from the Combo below.
|
||||||
|
// This is used as a reference to see if frame conversion and assembly is correct.
|
||||||
|
val referenceDisplayFramePixels = listOf(
|
||||||
|
" ███ ███ ███ █████ ███ ",
|
||||||
|
" █ █ █ █ █ █ █ █ █ █ ",
|
||||||
|
"█ █ █ █ █ ████ █ █ ",
|
||||||
|
"█ ██ █ █ █ █ ████ ",
|
||||||
|
"█ █ █ █ █ █ ",
|
||||||
|
" █ █ █ █ █ █ █ ",
|
||||||
|
" ███ █████ █████ ███ ██ ",
|
||||||
|
" ",
|
||||||
|
" ████ ████ ████ ",
|
||||||
|
" ███████ ██ ██ ██ ██ ██ ██ ",
|
||||||
|
" ███████ ██ ██ ██ ██ ██ ██ ",
|
||||||
|
" ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
|
||||||
|
" ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
|
||||||
|
" ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
|
||||||
|
"███████ ██ ██ ██ ██ ████ ████ ██ ██ ██ ██ ",
|
||||||
|
"███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ",
|
||||||
|
"██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ",
|
||||||
|
"██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
|
||||||
|
"██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
|
||||||
|
"██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
|
||||||
|
"██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
|
||||||
|
"██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
|
||||||
|
"██ ██ ██ ██ ███ ████ ███ ████ ████ ████ ██ ██ ██ ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" "
|
||||||
|
)
|
||||||
|
|
||||||
|
val originalRtDisplayFrameRows = listOf(
|
||||||
|
byteArrayListOfInts(
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1E, 0x29,
|
||||||
|
0x49, 0x49, 0x06, 0x00, 0x39, 0x45, 0x45, 0x45, 0x27, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x46, 0x49, 0x51, 0x61, 0x42, 0x00, 0x46, 0x49,
|
||||||
|
0x51, 0x61, 0x42, 0x00, 0x00, 0x1C, 0x22, 0x49, 0x4F, 0x41, 0x22, 0x1C
|
||||||
|
),
|
||||||
|
byteArrayListOfInts(
|
||||||
|
0x00, 0x00, 0x00, 0x80, 0x80, 0x80, 0xF8, 0xF8, 0x00, 0x38, 0xF8, 0xC0,
|
||||||
|
0x00, 0x00, 0x00, 0xF8, 0xF8, 0x00, 0x00, 0xF8, 0xF8, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x1C, 0xBE, 0xE3, 0x41, 0x41, 0xE3, 0xBE, 0x1C, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x1C, 0xBE, 0xE3, 0x41, 0x41, 0xE3, 0xBE, 0x1C, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0xFE, 0x03, 0x01,
|
||||||
|
0x01, 0x03, 0xFE, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0xF0, 0x30, 0x30, 0x30,
|
||||||
|
0xFE, 0xFE, 0x06, 0x06, 0x06, 0xFE, 0xFE, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0
|
||||||
|
),
|
||||||
|
byteArrayListOfInts(
|
||||||
|
0x00, 0x00, 0x7F, 0x7F, 0x00, 0x01, 0x7F, 0x7F, 0x00, 0x00, 0x01, 0x0F,
|
||||||
|
0x7E, 0x70, 0x00, 0x3F, 0x7F, 0x40, 0x40, 0x7F, 0x3F, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x1F, 0x3F, 0x60, 0x40, 0x40, 0x60, 0x3F, 0x1F, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x1F, 0x3F, 0x60, 0x40, 0x40, 0x60, 0x3F, 0x1F, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x70, 0x70, 0x70, 0x00, 0x00, 0x00, 0x1F, 0x3F, 0x60, 0x40,
|
||||||
|
0x40, 0x60, 0x3F, 0x1F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x40, 0x7F, 0x42, 0x00, 0x00, 0x7F, 0x7F, 0x00, 0x00, 0x00,
|
||||||
|
0x7F, 0x7F, 0x00, 0x00, 0x00, 0x7F, 0x7F, 0x00, 0x00, 0x00, 0x7F, 0x7F
|
||||||
|
),
|
||||||
|
byteArrayListOfInts(
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class DisplayFrameTest {
|
||||||
|
@Test
|
||||||
|
fun checkPixelAddressing() {
|
||||||
|
// Construct a simple display frame with 2 pixels set and the rest
|
||||||
|
// being empty. Pixel 1 is at coordinates (x 1 y 0), pixel 2 is at
|
||||||
|
// coordinates (x 0 y 1).
|
||||||
|
val framePixels = BooleanArray(NUM_DISPLAY_FRAME_PIXELS) { false }
|
||||||
|
framePixels[1 + 0 * DISPLAY_FRAME_WIDTH] = true
|
||||||
|
framePixels[0 + 1 * DISPLAY_FRAME_WIDTH] = true
|
||||||
|
|
||||||
|
val displayFrame = DisplayFrame(framePixels)
|
||||||
|
|
||||||
|
// Verify that all pixels except the two specific ones are empty.
|
||||||
|
for (y in 0 until DISPLAY_FRAME_HEIGHT) {
|
||||||
|
for (x in 0 until DISPLAY_FRAME_WIDTH) {
|
||||||
|
when (Pair(x, y)) {
|
||||||
|
Pair(1, 0) -> assertEquals(true, displayFrame.getPixelAt(x, y))
|
||||||
|
Pair(0, 1) -> assertEquals(true, displayFrame.getPixelAt(x, y))
|
||||||
|
else -> assertEquals(false, displayFrame.getPixelAt(x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkDisplayFrameAssembly() {
|
||||||
|
val assembler = DisplayFrameAssembler()
|
||||||
|
var displayFrame: DisplayFrame?
|
||||||
|
|
||||||
|
// The Combo splits a frame into 4 rows and transmits in one application layer
|
||||||
|
// RT_DISPLAY packet each. We simulate this by keeping the actual pixel data
|
||||||
|
// of these packets in the originalRtDisplayFrameRows array. If the assembler
|
||||||
|
// works correctly, then feeding these four byte lists into it will produce
|
||||||
|
// a complete display frame.
|
||||||
|
//
|
||||||
|
// The index here is 0x17. The index is how we can see if a row belongs to
|
||||||
|
// the same frame, or if a new frame started. If we get a row with an index
|
||||||
|
// that is different than the previous one, then we have to discard any
|
||||||
|
// previously collected rows and start anew. Here, we supply 4 rows of the
|
||||||
|
// same index, so we expect a complete frame.
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x17, 0, originalRtDisplayFrameRows[0])
|
||||||
|
assertTrue(displayFrame == null)
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x17, 1, originalRtDisplayFrameRows[1])
|
||||||
|
assertTrue(displayFrame == null)
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x17, 2, originalRtDisplayFrameRows[2])
|
||||||
|
assertTrue(displayFrame == null)
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x17, 3, originalRtDisplayFrameRows[3])
|
||||||
|
assertFalse(displayFrame == null)
|
||||||
|
|
||||||
|
// Check that the assembled frame is correct.
|
||||||
|
compareWithReference(displayFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkChangingRTDisplayPayloadIndex() {
|
||||||
|
val assembler = DisplayFrameAssembler()
|
||||||
|
var displayFrame: DisplayFrame?
|
||||||
|
|
||||||
|
// This is similar to the test above, except that we only provide 3 rows
|
||||||
|
// of the frame with index 0x17, and then suddenly deliver a row with index
|
||||||
|
// 0x18. We expect the assembler to discard any previously collected rows
|
||||||
|
// and restart from scratch. We do provide four rows with index 0x18, so
|
||||||
|
// the assembler is supposed to deliver a completed frame then.
|
||||||
|
|
||||||
|
// The first rows with index 0x17.
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x17, 0, originalRtDisplayFrameRows[0])
|
||||||
|
assertTrue(displayFrame == null)
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x17, 1, originalRtDisplayFrameRows[1])
|
||||||
|
assertTrue(displayFrame == null)
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x17, 2, originalRtDisplayFrameRows[2])
|
||||||
|
assertTrue(displayFrame == null)
|
||||||
|
|
||||||
|
// First row with index 0x18. This should reset the assembler's contents,
|
||||||
|
// restarting its assembly from scratch.
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x18, 0, originalRtDisplayFrameRows[0])
|
||||||
|
assertTrue(displayFrame == null)
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x18, 1, originalRtDisplayFrameRows[1])
|
||||||
|
assertTrue(displayFrame == null)
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x18, 2, originalRtDisplayFrameRows[2])
|
||||||
|
assertTrue(displayFrame == null)
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x18, 3, originalRtDisplayFrameRows[3])
|
||||||
|
assertFalse(displayFrame == null)
|
||||||
|
|
||||||
|
// Check that the completed frame is OK.
|
||||||
|
compareWithReference(displayFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkDisplayFrameOutOfOrderAssembly() {
|
||||||
|
val assembler = DisplayFrameAssembler()
|
||||||
|
var displayFrame: DisplayFrame?
|
||||||
|
|
||||||
|
// Similar to the checkDisplayFrameAssembly, except that rows
|
||||||
|
// are supplied to the assembler out-of-order.
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x17, 2, originalRtDisplayFrameRows[2])
|
||||||
|
assertTrue(displayFrame == null)
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x17, 1, originalRtDisplayFrameRows[1])
|
||||||
|
assertTrue(displayFrame == null)
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x17, 3, originalRtDisplayFrameRows[3])
|
||||||
|
assertTrue(displayFrame == null)
|
||||||
|
|
||||||
|
displayFrame = assembler.processRTDisplayPayload(0x17, 0, originalRtDisplayFrameRows[0])
|
||||||
|
assertFalse(displayFrame == null)
|
||||||
|
|
||||||
|
// Check that the assembled frame is correct.
|
||||||
|
compareWithReference(displayFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dumpDisplayFrameContents(displayFrame: DisplayFrame) {
|
||||||
|
for (y in 0 until DISPLAY_FRAME_HEIGHT) {
|
||||||
|
for (x in 0 until DISPLAY_FRAME_WIDTH) {
|
||||||
|
val displayFramePixel = displayFrame.getPixelAt(x, y)
|
||||||
|
print(if (displayFramePixel) '█' else ' ')
|
||||||
|
}
|
||||||
|
println("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareWithReference(displayFrame: DisplayFrame) {
|
||||||
|
for (y in 0 until DISPLAY_FRAME_HEIGHT) {
|
||||||
|
for (x in 0 until DISPLAY_FRAME_WIDTH) {
|
||||||
|
val referencePixel = (referenceDisplayFramePixels[y][x] != ' ')
|
||||||
|
val displayFramePixel = displayFrame.getPixelAt(x, y)
|
||||||
|
|
||||||
|
val equal = (referencePixel == displayFramePixel)
|
||||||
|
if (!equal) {
|
||||||
|
println("Mismatch at x $x y $y")
|
||||||
|
dumpDisplayFrameContents(displayFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(equal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,153 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertSame
|
||||||
|
|
||||||
|
class GraphTest {
|
||||||
|
@Test
|
||||||
|
fun checkGraphConstruction() {
|
||||||
|
// Check basic graph construction. Create 4 nodes, with values 1 through 4.
|
||||||
|
// Connect:
|
||||||
|
// - node 1 to node 2
|
||||||
|
// - node 2 to nodes 1 and 3
|
||||||
|
// - node 3 to node 4
|
||||||
|
// - node 4 to node 2
|
||||||
|
//
|
||||||
|
// Then check (a) the number of nodes in the graph,
|
||||||
|
// (b) how many edges each node has, and (c) what
|
||||||
|
// nodes the edges lead to.
|
||||||
|
|
||||||
|
Graph<Int, String>().apply {
|
||||||
|
val n1 = node(1)
|
||||||
|
val n2 = node(2)
|
||||||
|
val n3 = node(3)
|
||||||
|
val n4 = node(4)
|
||||||
|
n1.connectTo(n2, "e12")
|
||||||
|
n2.connectTo(n1, "e21")
|
||||||
|
n2.connectTo(n3, "e23")
|
||||||
|
n3.connectTo(n4, "e34")
|
||||||
|
n4.connectTo(n2, "e42")
|
||||||
|
|
||||||
|
// Check number of nodes.
|
||||||
|
assertEquals(4, nodes.size)
|
||||||
|
|
||||||
|
// Check number of edges per node.
|
||||||
|
assertEquals(1, n1.edges.size)
|
||||||
|
assertEquals(2, n2.edges.size)
|
||||||
|
assertEquals(1, n3.edges.size)
|
||||||
|
assertEquals(1, n4.edges.size)
|
||||||
|
|
||||||
|
// Check the nodes the edges lead to.
|
||||||
|
assertSame(n2, n1.edges[0].targetNode)
|
||||||
|
assertSame(n1, n2.edges[0].targetNode)
|
||||||
|
assertSame(n3, n2.edges[1].targetNode)
|
||||||
|
assertSame(n4, n3.edges[0].targetNode)
|
||||||
|
assertSame(n2, n4.edges[0].targetNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkShortestPath() {
|
||||||
|
// Check the result of findShortestPath(). For this,
|
||||||
|
// construct a graph with cycles and multiple ways
|
||||||
|
// to get from one node to another. This graph has
|
||||||
|
// 4 nodes, and one ways to get from node 1 to node 4
|
||||||
|
// & _two_ ways from node 4 to 1.
|
||||||
|
//
|
||||||
|
// Path from node 1 to 4: n1 -> n2 -> n3 -> n4
|
||||||
|
// First path from node 4 to 1: n4 -> n3 -> n2 -> n1
|
||||||
|
// Second path from node 4 to 1: n4 -> n2 -> n1
|
||||||
|
//
|
||||||
|
// findShortestPath() should find the second path,
|
||||||
|
// since it is the shortest one.
|
||||||
|
|
||||||
|
Graph<Int, String>().apply {
|
||||||
|
val n1 = node(1)
|
||||||
|
val n2 = node(2)
|
||||||
|
val n3 = node(3)
|
||||||
|
val n4 = node(4)
|
||||||
|
n1.connectTo(n2, "e12")
|
||||||
|
n2.connectTo(n1, "e21")
|
||||||
|
n2.connectTo(n3, "e23")
|
||||||
|
n3.connectTo(n4, "e34")
|
||||||
|
n4.connectTo(n2, "e42")
|
||||||
|
n4.connectTo(n3, "e43")
|
||||||
|
n3.connectTo(n2, "e32")
|
||||||
|
|
||||||
|
val pathFromN1ToN4 = findShortestPath(1, 4)!!
|
||||||
|
assertEquals(3, pathFromN1ToN4.size)
|
||||||
|
assertEquals("e12", pathFromN1ToN4[0].edgeValue)
|
||||||
|
assertEquals(2, pathFromN1ToN4[0].targetNodeValue)
|
||||||
|
assertEquals("e23", pathFromN1ToN4[1].edgeValue)
|
||||||
|
assertEquals(3, pathFromN1ToN4[1].targetNodeValue)
|
||||||
|
assertEquals("e34", pathFromN1ToN4[2].edgeValue)
|
||||||
|
assertEquals(4, pathFromN1ToN4[2].targetNodeValue)
|
||||||
|
|
||||||
|
val pathFromN4ToN1 = findShortestPath(4, 1)!!
|
||||||
|
assertEquals(2, pathFromN4ToN1.size)
|
||||||
|
assertEquals("e42", pathFromN4ToN1[0].edgeValue)
|
||||||
|
assertEquals(2, pathFromN4ToN1[0].targetNodeValue)
|
||||||
|
assertEquals("e21", pathFromN4ToN1[1].edgeValue)
|
||||||
|
assertEquals(1, pathFromN4ToN1[1].targetNodeValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkNonExistentShortestPath() {
|
||||||
|
// Check what happens when trying to find a shortest path
|
||||||
|
// between two nodes that have no path that connects the two.
|
||||||
|
// The test graph connects node 1 to nodes 2 and 3, but since
|
||||||
|
// the edges are directional, getting from nodes 2 and 3 to
|
||||||
|
// node 1 is not possible. Consequently, a path from node 2
|
||||||
|
// to node 3 cannot be found. findShortestPath() should
|
||||||
|
// detect this and return null.
|
||||||
|
|
||||||
|
Graph<Int, String>().apply {
|
||||||
|
val n1 = node(1)
|
||||||
|
val n2 = node(2)
|
||||||
|
val n3 = node(3)
|
||||||
|
|
||||||
|
n1.connectTo(n2, "e12")
|
||||||
|
n1.connectTo(n3, "e13")
|
||||||
|
|
||||||
|
val path = findShortestPath(2, 3)
|
||||||
|
assertNull(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkShortestPathSearchEdgePredicate() {
|
||||||
|
// Check the effect of an edge predicate. Establisch a small
|
||||||
|
// 3-node graph with nodes 1,2,3 and add a shortcut from
|
||||||
|
// node 1 to node 3. Try to find the shortest path from
|
||||||
|
// 1 to 3, without and with a predicate. We expect the
|
||||||
|
// predicate to skip the edge that goes from node 1 to 3.
|
||||||
|
|
||||||
|
Graph<Int, String>().apply {
|
||||||
|
val n1 = node(1)
|
||||||
|
val n2 = node(2)
|
||||||
|
val n3 = node(3)
|
||||||
|
|
||||||
|
n1.connectTo(n2, "e12")
|
||||||
|
n2.connectTo(n3, "e23")
|
||||||
|
n1.connectTo(n3, "e13")
|
||||||
|
|
||||||
|
val pathWithoutPredicate = findShortestPath(1, 3)
|
||||||
|
assertNotNull(pathWithoutPredicate)
|
||||||
|
assertEquals(1, pathWithoutPredicate.size)
|
||||||
|
assertEquals("e13", pathWithoutPredicate[0].edgeValue)
|
||||||
|
assertEquals(3, pathWithoutPredicate[0].targetNodeValue)
|
||||||
|
|
||||||
|
val pathWithPredicate = findShortestPath(1, 3) { it != "e13" }
|
||||||
|
assertNotNull(pathWithPredicate)
|
||||||
|
assertEquals(2, pathWithPredicate.size)
|
||||||
|
assertEquals("e12", pathWithPredicate[0].edgeValue)
|
||||||
|
assertEquals(2, pathWithPredicate[0].targetNodeValue)
|
||||||
|
assertEquals("e23", pathWithPredicate[1].edgeValue)
|
||||||
|
assertEquals(3, pathWithPredicate[1].targetNodeValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class NonceTest {
|
||||||
|
@Test
|
||||||
|
fun checkDefaultNonceIncrement() {
|
||||||
|
// Increment the nonce by the default amount of 1.
|
||||||
|
|
||||||
|
val firstNonce = Nonce(listOf(0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
|
||||||
|
val secondNonce = firstNonce.getIncrementedNonce()
|
||||||
|
val expectedSecondNonce = Nonce(listOf(0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
|
||||||
|
|
||||||
|
assertEquals(expectedSecondNonce, secondNonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkExplicitNonceIncrement() {
|
||||||
|
// Increment the nonce by the explicit amount of 76000.
|
||||||
|
|
||||||
|
val firstNonce = Nonce(listOf(0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
|
||||||
|
val secondNonce = firstNonce.getIncrementedNonce(incrementAmount = 76000)
|
||||||
|
val expectedSecondNonce = Nonce(listOf(0xF0.toByte(), 0x29, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
|
||||||
|
|
||||||
|
assertEquals(expectedSecondNonce, secondNonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkNonceWraparound() {
|
||||||
|
// Increment a nonce that is high enough to cause a wrap-around.
|
||||||
|
|
||||||
|
val firstNonce = Nonce(listOf(
|
||||||
|
0xFA.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(),
|
||||||
|
0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(),
|
||||||
|
0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()
|
||||||
|
))
|
||||||
|
val secondNonce = firstNonce.getIncrementedNonce(incrementAmount = 10)
|
||||||
|
val expectedSecondNonce = Nonce(listOf(0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
|
||||||
|
|
||||||
|
assertEquals(expectedSecondNonce, secondNonce)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,461 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import info.nightscout.comboctl.base.testUtils.TestBluetoothDevice
|
||||||
|
import info.nightscout.comboctl.base.testUtils.TestPumpStateStore
|
||||||
|
import info.nightscout.comboctl.base.testUtils.runBlockingWithWatchdog
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.fail
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
|
||||||
|
class PairingSessionTest {
|
||||||
|
enum class PacketDirection {
|
||||||
|
SEND,
|
||||||
|
RECEIVE
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PairingTestSequenceEntry(val direction: PacketDirection, val packet: TransportLayer.Packet) {
|
||||||
|
override fun toString(): String {
|
||||||
|
return if (packet.command == TransportLayer.Command.DATA) {
|
||||||
|
try {
|
||||||
|
// Use the ApplicationLayer.Packet constructor instead
|
||||||
|
// of the toAppLayerPacket() function, since the latter
|
||||||
|
// performs additional sanity checks. These checks are
|
||||||
|
// unnecessary here - we just want to dump the packet
|
||||||
|
// contents to a string.
|
||||||
|
val appLayerPacket = ApplicationLayer.Packet(packet)
|
||||||
|
"direction: $direction app layer packet: $appLayerPacket"
|
||||||
|
} catch (ignored: Throwable) {
|
||||||
|
"direction: $direction tp layer packet: $packet"
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
"direction: $direction tp layer packet: $packet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class PairingTestComboIO(val pairingTestSequence: List<PairingTestSequenceEntry>) : ComboIO {
|
||||||
|
private var curSequenceIndex = 0
|
||||||
|
private val barrier = Channel<Unit>(capacity = Channel.CONFLATED)
|
||||||
|
|
||||||
|
var expectedEndOfSequenceReached: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
var testErrorOccurred: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
// The pairingTestSequence contains entries for when a packet
|
||||||
|
// is expected to be sent and to be received in this simulated
|
||||||
|
// Combo<->Client communication. When the "sender" transmits
|
||||||
|
// packets, the "receiver" is supposed to wait. This is accomplished
|
||||||
|
// by letting getNextSequenceEntry() suspend its coroutine until
|
||||||
|
// _another_ getNextSequenceEntry() call advances the sequence
|
||||||
|
// so that the first call's expected packet direction matches.
|
||||||
|
// For example: coroutine A simulates the receiver, B the sender.
|
||||||
|
// A calls getNextSequenceEntry(). The next sequence entry has
|
||||||
|
// "SEND" as its packet direction, meaning that at this point, the
|
||||||
|
// sender is supposed to be active. Consequently, A is suspended
|
||||||
|
// by getNextSequenceEntry(). B calls getNextSequenceEntry() and
|
||||||
|
// advances the sequence until an entry is reached with packet
|
||||||
|
// direction "RECEIVE". This now suspends B. A is woken up by
|
||||||
|
// the barrier and resumes its work etc.
|
||||||
|
// The "barrier" is actually a Channel which "transmits" Unit
|
||||||
|
// values. We aren't actually interested in these "values", just
|
||||||
|
// in the ability of Channel to suspend coroutines.
|
||||||
|
private suspend fun getNextSequenceEntry(expectedPacketDirection: PacketDirection): PairingTestSequenceEntry {
|
||||||
|
while (true) {
|
||||||
|
// Suspend indefinitely if we reached the expected
|
||||||
|
// end of sequence. See send() below for details.
|
||||||
|
if (expectedEndOfSequenceReached)
|
||||||
|
barrier.receive()
|
||||||
|
|
||||||
|
if (curSequenceIndex >= pairingTestSequence.size) {
|
||||||
|
testErrorOccurred = true
|
||||||
|
throw ComboException("End of test sequence unexpectedly reached")
|
||||||
|
}
|
||||||
|
|
||||||
|
val sequenceEntry = pairingTestSequence[curSequenceIndex]
|
||||||
|
if (sequenceEntry.direction != expectedPacketDirection) {
|
||||||
|
// Wait until we get the signal from a send() or receive()
|
||||||
|
// call that we can resume here.
|
||||||
|
barrier.receive()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
curSequenceIndex++
|
||||||
|
|
||||||
|
return sequenceEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun send(dataToSend: List<Byte>) {
|
||||||
|
try {
|
||||||
|
val sequenceEntry = getNextSequenceEntry(PacketDirection.SEND)
|
||||||
|
System.err.println("Next sequence entry: $sequenceEntry")
|
||||||
|
|
||||||
|
val expectedPacketData = sequenceEntry.packet.toByteList()
|
||||||
|
assertEquals(expectedPacketData, dataToSend)
|
||||||
|
|
||||||
|
// Check if this is the last packet in the sequence.
|
||||||
|
// That's CTRL_DISCONNECT. If it is, switch to a
|
||||||
|
// special mode that suspends receive() calls indefinitely.
|
||||||
|
// This is necessary because the packet receiver inside
|
||||||
|
// the transport layer IO class will keep trying to receive
|
||||||
|
// packets from the Combo even though our sequence here
|
||||||
|
// ended and thus has no more data that can be "received".
|
||||||
|
if (sequenceEntry.packet.command == TransportLayer.Command.DATA) {
|
||||||
|
try {
|
||||||
|
// Don't use toAppLayerPacket() here. Instead, use
|
||||||
|
// the ApplicationLayer.Packet constructor directly.
|
||||||
|
// This way we circumvent error code checks, which
|
||||||
|
// are undesirable in this very case.
|
||||||
|
val appLayerPacket = ApplicationLayer.Packet(sequenceEntry.packet)
|
||||||
|
if (appLayerPacket.command == ApplicationLayer.Command.CTRL_DISCONNECT)
|
||||||
|
expectedEndOfSequenceReached = true
|
||||||
|
} catch (ignored: Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal to the other, suspended coroutine that it can resume now.
|
||||||
|
barrier.trySend(Unit)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
testErrorOccurred = true
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun receive(): List<Byte> {
|
||||||
|
try {
|
||||||
|
val sequenceEntry = getNextSequenceEntry(PacketDirection.RECEIVE)
|
||||||
|
System.err.println("Next sequence entry: $sequenceEntry")
|
||||||
|
|
||||||
|
// Signal to the other, suspended coroutine that it can resume now.
|
||||||
|
barrier.trySend(Unit)
|
||||||
|
return sequenceEntry.packet.toByteList()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
testErrorOccurred = true
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verifyPairingProcess() {
|
||||||
|
// Test the pairing coroutine by feeding in data that was recorded from
|
||||||
|
// pairing an actual Combo with Ruffy (using an nVidia SHIELD Tablet as
|
||||||
|
// client). Check that the outgoing packets match those that Ruffy sent
|
||||||
|
// to the Combo.
|
||||||
|
|
||||||
|
val testBtFriendlyName = "SHIELD Tablet"
|
||||||
|
val testPIN = PairingPIN(intArrayOf(2, 6, 0, 6, 8, 1, 9, 2, 7, 3))
|
||||||
|
|
||||||
|
val expectedTestSequence = listOf(
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.SEND,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.REQUEST_PAIRING_CONNECTION,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0xf0.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0xB2, 0x11),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.RECEIVE,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.PAIRING_CONNECTION_REQUEST_ACCEPTED,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x0f.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0x00, 0xF0, 0x6D),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.SEND,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.REQUEST_KEYS,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0xf0.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0x81, 0x41),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.SEND,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.GET_AVAILABLE_KEYS,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0xf0.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0x90, 0x71),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.RECEIVE,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.KEY_RESPONSE,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x01.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(
|
||||||
|
0x54, 0x9E, 0xF7, 0x7D, 0x8D, 0x27, 0x48, 0x0C, 0x1D, 0x11, 0x43, 0xB8, 0xF7, 0x08, 0x92, 0x7B,
|
||||||
|
0xF0, 0xA3, 0x75, 0xF3, 0xB4, 0x5F, 0xE2, 0xF3, 0x46, 0x63, 0xCD, 0xDD, 0xC4, 0x96, 0x37, 0xAC),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x25, 0xA0, 0x26, 0x47, 0x29, 0x37, 0xFF, 0x66))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.SEND,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.REQUEST_ID,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x10.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(
|
||||||
|
0x08, 0x29, 0x00, 0x00, 0x53, 0x48, 0x49, 0x45, 0x4C, 0x44, 0x20, 0x54, 0x61, 0x62, 0x6C, 0x65, 0x74),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x99, 0xED, 0x58, 0x29, 0x54, 0x6A, 0xBB, 0x35))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.RECEIVE,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.ID_RESPONSE,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x01.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(
|
||||||
|
0x59, 0x99, 0xD4, 0x01, 0x50, 0x55, 0x4D, 0x50, 0x5F, 0x31, 0x30, 0x32, 0x33, 0x30, 0x39, 0x34, 0x37),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x6E, 0xF4, 0x4D, 0xFE, 0x35, 0x6E, 0xFE, 0xB4))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.SEND,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.REQUEST_REGULAR_CONNECTION,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x10.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0xCF, 0xEE, 0x61, 0xF2, 0x83, 0xD3, 0xDC, 0x39))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.RECEIVE,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.REGULAR_CONNECTION_REQUEST_ACCEPTED,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x01.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x40, 0x00, 0xB3, 0x41, 0x84, 0x55, 0x5F, 0x12))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Application layer CTRL_CONNECT
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.SEND,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.DATA,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = true,
|
||||||
|
address = 0x10.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0x10, 0x00, 0x55, 0x90, 0x39, 0x30, 0x00, 0x00),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0xEF, 0xB9, 0x9E, 0xB6, 0x7B, 0x30, 0x7A, 0xCB))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Application layer CTRL_CONNECT
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.RECEIVE,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.DATA,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = true,
|
||||||
|
address = 0x01.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0x10, 0x00, 0x55, 0xA0, 0x00, 0x00),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0xF4, 0x4D, 0xB8, 0xB3, 0xC1, 0x2E, 0xDE, 0x97))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Response due to the last packet's reliability bit set to true
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.SEND,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.ACK_RESPONSE,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x10.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x76, 0x01, 0xB6, 0xAB, 0x48, 0xDB, 0x4E, 0x87))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Application layer CTRL_CONNECT
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.SEND,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.DATA,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = true,
|
||||||
|
reliabilityBit = true,
|
||||||
|
address = 0x10.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0x10, 0x00, 0x65, 0x90, 0xB7),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0xEC, 0xA6, 0x4D, 0x59, 0x1F, 0xD3, 0xF4, 0xCD))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Application layer CTRL_CONNECT_RESPONSE
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.RECEIVE,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.DATA,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = true,
|
||||||
|
reliabilityBit = true,
|
||||||
|
address = 0x01.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0x10, 0x00, 0x65, 0xA0, 0x00, 0x00, 0x01, 0x00),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x9D, 0xB3, 0x3F, 0x84, 0x87, 0x49, 0xE3, 0xAC))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Response due to the last packet's reliability bit set to true
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.SEND,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.ACK_RESPONSE,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = true,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x10.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x15, 0xA9, 0x9A, 0x64, 0x9C, 0x57, 0xD2, 0x72))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Application layer CTRL_BIND
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.SEND,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.DATA,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = true,
|
||||||
|
address = 0x10.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0x10, 0x00, 0x95, 0x90, 0x48),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x39, 0x8E, 0x57, 0xCC, 0xEE, 0x68, 0x41, 0xBB))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Application layer CTRL_BIND_RESPONSE
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.RECEIVE,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.DATA,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = true,
|
||||||
|
address = 0x01.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0x10, 0x00, 0x95, 0xA0, 0x00, 0x00, 0x48),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0xF0, 0x49, 0xD4, 0x91, 0x01, 0x26, 0x33, 0xEF))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Response due to the last packet's reliability bit set to true
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.SEND,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.ACK_RESPONSE,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x10.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x38, 0x3D, 0x52, 0x56, 0x73, 0xBF, 0x59, 0xD8))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.SEND,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.REQUEST_REGULAR_CONNECTION,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x10.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x1D, 0xD4, 0xD5, 0xC6, 0x03, 0x3E, 0x0A, 0xBE))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.RECEIVE,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.REGULAR_CONNECTION_REQUEST_ACCEPTED,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x01.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x34, 0xD2, 0x8B, 0x40, 0x27, 0x44, 0x82, 0x89))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Application layer CTRL_DISCONNECT
|
||||||
|
PairingTestSequenceEntry(
|
||||||
|
PacketDirection.SEND,
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.DATA,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = true,
|
||||||
|
address = 0x10.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0x10, 0x00, 0x5A, 0x00, 0x03, 0x00),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x9D, 0xF4, 0x0F, 0x24, 0x44, 0xE3, 0x52, 0x03))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val testIO = PairingTestComboIO(expectedTestSequence)
|
||||||
|
val testPumpStateStore = TestPumpStateStore()
|
||||||
|
val testBluetoothDevice = TestBluetoothDevice(testIO)
|
||||||
|
val pumpIO = PumpIO(testPumpStateStore, testBluetoothDevice, onNewDisplayFrame = {}, onPacketReceiverException = {})
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
pumpIO.performPairing(
|
||||||
|
testBtFriendlyName,
|
||||||
|
null
|
||||||
|
) { _, _ -> testPIN }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testIO.testErrorOccurred)
|
||||||
|
fail("Failure in background coroutine")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,176 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class ProgressReporterTest {
|
||||||
|
// NOTE: In the tests here, the progress sequences are fairly
|
||||||
|
// arbitrary, and do _not_ reflect how actual sequences used
|
||||||
|
// in pairing etc. look like.
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBasicProgress() {
|
||||||
|
val progressReporter = ProgressReporter<Unit>(
|
||||||
|
listOf(
|
||||||
|
BasicProgressStage.EstablishingBtConnection::class,
|
||||||
|
BasicProgressStage.PerformingConnectionHandshake::class,
|
||||||
|
BasicProgressStage.ComboPairingKeyAndPinRequested::class,
|
||||||
|
BasicProgressStage.ComboPairingFinishing::class
|
||||||
|
),
|
||||||
|
Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(0, 4, BasicProgressStage.Idle, 0.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
|
||||||
|
progressReporter.setCurrentProgressStage(
|
||||||
|
BasicProgressStage.EstablishingBtConnection(1, 3)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(0, 4, BasicProgressStage.EstablishingBtConnection(1, 3), 0.0 / 4.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
|
||||||
|
progressReporter.setCurrentProgressStage(
|
||||||
|
BasicProgressStage.EstablishingBtConnection(2, 3)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(0, 4, BasicProgressStage.EstablishingBtConnection(2, 3), 0.0 / 4.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
|
||||||
|
progressReporter.setCurrentProgressStage(BasicProgressStage.PerformingConnectionHandshake)
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(1, 4, BasicProgressStage.PerformingConnectionHandshake, 1.0 / 4.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
|
||||||
|
progressReporter.setCurrentProgressStage(BasicProgressStage.ComboPairingKeyAndPinRequested)
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(2, 4, BasicProgressStage.ComboPairingKeyAndPinRequested, 2.0 / 4.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
|
||||||
|
progressReporter.setCurrentProgressStage(BasicProgressStage.ComboPairingFinishing)
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(3, 4, BasicProgressStage.ComboPairingFinishing, 3.0 / 4.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
|
||||||
|
progressReporter.setCurrentProgressStage(BasicProgressStage.Finished)
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(4, 4, BasicProgressStage.Finished, 4.0 / 4.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSkippedSteps() {
|
||||||
|
val progressReporter = ProgressReporter<Unit>(
|
||||||
|
listOf(
|
||||||
|
BasicProgressStage.EstablishingBtConnection::class,
|
||||||
|
BasicProgressStage.PerformingConnectionHandshake::class,
|
||||||
|
BasicProgressStage.ComboPairingKeyAndPinRequested::class,
|
||||||
|
BasicProgressStage.ComboPairingFinishing::class
|
||||||
|
),
|
||||||
|
Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(0, 4, BasicProgressStage.Idle, 0.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
|
||||||
|
progressReporter.setCurrentProgressStage(
|
||||||
|
BasicProgressStage.EstablishingBtConnection(1, 3)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(0, 4, BasicProgressStage.EstablishingBtConnection(1, 3), 0.0 / 4.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
|
||||||
|
progressReporter.setCurrentProgressStage(BasicProgressStage.ComboPairingFinishing)
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(3, 4, BasicProgressStage.ComboPairingFinishing, 3.0 / 4.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
|
||||||
|
progressReporter.setCurrentProgressStage(BasicProgressStage.Finished)
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(4, 4, BasicProgressStage.Finished, 4.0 / 4.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBackwardsProgress() {
|
||||||
|
val progressReporter = ProgressReporter<Unit>(
|
||||||
|
listOf(
|
||||||
|
BasicProgressStage.EstablishingBtConnection::class,
|
||||||
|
BasicProgressStage.PerformingConnectionHandshake::class,
|
||||||
|
BasicProgressStage.ComboPairingKeyAndPinRequested::class,
|
||||||
|
BasicProgressStage.ComboPairingFinishing::class
|
||||||
|
),
|
||||||
|
Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(0, 4, BasicProgressStage.Idle, 0.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
|
||||||
|
progressReporter.setCurrentProgressStage(BasicProgressStage.ComboPairingFinishing)
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(3, 4, BasicProgressStage.ComboPairingFinishing, 3.0 / 4.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
|
||||||
|
progressReporter.setCurrentProgressStage(
|
||||||
|
BasicProgressStage.EstablishingBtConnection(1, 3)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(0, 4, BasicProgressStage.EstablishingBtConnection(1, 3), 0.0 / 4.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
|
||||||
|
progressReporter.setCurrentProgressStage(BasicProgressStage.Finished)
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(4, 4, BasicProgressStage.Finished, 4.0 / 4.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAbort() {
|
||||||
|
val progressReporter = ProgressReporter<Unit>(
|
||||||
|
listOf(
|
||||||
|
BasicProgressStage.EstablishingBtConnection::class,
|
||||||
|
BasicProgressStage.PerformingConnectionHandshake::class,
|
||||||
|
BasicProgressStage.ComboPairingKeyAndPinRequested::class,
|
||||||
|
BasicProgressStage.ComboPairingFinishing::class
|
||||||
|
),
|
||||||
|
Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(0, 4, BasicProgressStage.Idle, 0.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
|
||||||
|
progressReporter.setCurrentProgressStage(
|
||||||
|
BasicProgressStage.EstablishingBtConnection(1, 3)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(0, 4, BasicProgressStage.EstablishingBtConnection(1, 3), 0.0 / 4.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
|
||||||
|
progressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled)
|
||||||
|
assertEquals(
|
||||||
|
ProgressReport(4, 4, BasicProgressStage.Cancelled, 4.0 / 4.0),
|
||||||
|
progressReporter.progressFlow.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,659 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import info.nightscout.comboctl.base.testUtils.TestBluetoothDevice
|
||||||
|
import info.nightscout.comboctl.base.testUtils.TestComboIO
|
||||||
|
import info.nightscout.comboctl.base.testUtils.TestPumpStateStore
|
||||||
|
import info.nightscout.comboctl.base.testUtils.TestRefPacketItem
|
||||||
|
import info.nightscout.comboctl.base.testUtils.checkTestPacketSequence
|
||||||
|
import info.nightscout.comboctl.base.testUtils.produceTpLayerPacket
|
||||||
|
import info.nightscout.comboctl.base.testUtils.runBlockingWithWatchdog
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.UtcOffset
|
||||||
|
|
||||||
|
class PumpIOTest {
|
||||||
|
// Common test code.
|
||||||
|
class TestStates(setupInvariantPumpData: Boolean) {
|
||||||
|
var testPumpStateStore: TestPumpStateStore
|
||||||
|
val testBluetoothDevice: TestBluetoothDevice
|
||||||
|
var testIO: TestComboIO
|
||||||
|
var pumpIO: PumpIO
|
||||||
|
|
||||||
|
init {
|
||||||
|
Logger.threshold = LogLevel.VERBOSE
|
||||||
|
|
||||||
|
// Set up the invariant pump data to be able to test regular connections.
|
||||||
|
|
||||||
|
testPumpStateStore = TestPumpStateStore()
|
||||||
|
|
||||||
|
testIO = TestComboIO()
|
||||||
|
testIO.respondToRTKeypressWithConfirmation = true
|
||||||
|
|
||||||
|
testBluetoothDevice = TestBluetoothDevice(testIO)
|
||||||
|
|
||||||
|
if (setupInvariantPumpData) {
|
||||||
|
val invariantPumpData = InvariantPumpData(
|
||||||
|
keyResponseAddress = 0x10,
|
||||||
|
clientPumpCipher = Cipher(byteArrayOfInts(
|
||||||
|
0x5a, 0x25, 0x0b, 0x75, 0xa9, 0x02, 0x21, 0xfa,
|
||||||
|
0xab, 0xbd, 0x36, 0x4d, 0x5c, 0xb8, 0x37, 0xd7)),
|
||||||
|
pumpClientCipher = Cipher(byteArrayOfInts(
|
||||||
|
0x2a, 0xb0, 0xf2, 0x67, 0xc2, 0x7d, 0xcf, 0xaa,
|
||||||
|
0x32, 0xb2, 0x48, 0x94, 0xe1, 0x6d, 0xe9, 0x5c)),
|
||||||
|
pumpID = "testPump"
|
||||||
|
)
|
||||||
|
testPumpStateStore.createPumpState(testBluetoothDevice.address, invariantPumpData, UtcOffset.ZERO, CurrentTbrState.NoTbrOngoing)
|
||||||
|
testIO.pumpClientCipher = invariantPumpData.pumpClientCipher
|
||||||
|
}
|
||||||
|
|
||||||
|
pumpIO = PumpIO(testPumpStateStore, testBluetoothDevice, onNewDisplayFrame = {}, onPacketReceiverException = {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests that a long button press is handled correctly.
|
||||||
|
// We expect an initial RT_BUTTON_STATUS packet with its
|
||||||
|
// buttonStatusChanged flag set to true, followed by
|
||||||
|
// a series of similar packet with the buttonStatusChanged
|
||||||
|
// flag set to false, and finished by an RT_BUTTON_STATUS
|
||||||
|
// packet whose button code is NO_BUTTON.
|
||||||
|
fun checkLongRTButtonPressPacketSequence(appLayerButton: ApplicationLayer.RTButton) {
|
||||||
|
assertTrue(
|
||||||
|
testIO.sentPacketData.size >= 3,
|
||||||
|
"Expected at least 3 items in sentPacketData list, got ${testIO.sentPacketData.size}"
|
||||||
|
)
|
||||||
|
|
||||||
|
checkRTButtonStatusPacketData(
|
||||||
|
testIO.sentPacketData.first(),
|
||||||
|
appLayerButton,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
testIO.sentPacketData.removeAt(0)
|
||||||
|
|
||||||
|
checkDisconnectPacketData(testIO.sentPacketData.last())
|
||||||
|
testIO.sentPacketData.removeAt(testIO.sentPacketData.size - 1)
|
||||||
|
|
||||||
|
checkRTButtonStatusPacketData(
|
||||||
|
testIO.sentPacketData.last(),
|
||||||
|
ApplicationLayer.RTButton.NO_BUTTON,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
testIO.sentPacketData.removeAt(testIO.sentPacketData.size - 1)
|
||||||
|
|
||||||
|
for (packetData in testIO.sentPacketData) {
|
||||||
|
checkRTButtonStatusPacketData(
|
||||||
|
packetData,
|
||||||
|
appLayerButton,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feeds initial connection setup packets into the test IO
|
||||||
|
// that would normally be sent by the Combo during connection
|
||||||
|
// setup. In that setup, the Combo is instructed to switch to
|
||||||
|
// the RT mode, so this also feeds a CTRL_ACTIVATE_SERVICE_RESPONSE
|
||||||
|
// packet into the IO.
|
||||||
|
suspend fun feedInitialPackets() {
|
||||||
|
val invariantPumpData = testPumpStateStore.getInvariantPumpData(testBluetoothDevice.address)
|
||||||
|
|
||||||
|
testIO.feedIncomingData(
|
||||||
|
produceTpLayerPacket(
|
||||||
|
TransportLayer.OutgoingPacketInfo(
|
||||||
|
command = TransportLayer.Command.REGULAR_CONNECTION_REQUEST_ACCEPTED
|
||||||
|
),
|
||||||
|
invariantPumpData.pumpClientCipher
|
||||||
|
).toByteList()
|
||||||
|
)
|
||||||
|
|
||||||
|
testIO.feedIncomingData(
|
||||||
|
produceTpLayerPacket(
|
||||||
|
ApplicationLayer.Packet(
|
||||||
|
command = ApplicationLayer.Command.CTRL_CONNECT_RESPONSE
|
||||||
|
).toTransportLayerPacketInfo(),
|
||||||
|
invariantPumpData.pumpClientCipher
|
||||||
|
).toByteList()
|
||||||
|
)
|
||||||
|
|
||||||
|
testIO.feedIncomingData(
|
||||||
|
produceTpLayerPacket(
|
||||||
|
ApplicationLayer.Packet(
|
||||||
|
command = ApplicationLayer.Command.CTRL_ACTIVATE_SERVICE_RESPONSE,
|
||||||
|
payload = byteArrayListOfInts(1, 2, 3, 4, 5)
|
||||||
|
).toTransportLayerPacketInfo(),
|
||||||
|
invariantPumpData.pumpClientCipher
|
||||||
|
).toByteList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This removes initial connection setup packets that are
|
||||||
|
// normally sent to the Combo. Outgoing packets are recorded
|
||||||
|
// in the testIO.sentPacketData list. In the tests here, we
|
||||||
|
// are not interested in these initial packets. This function
|
||||||
|
// gets rid of them.
|
||||||
|
fun checkAndRemoveInitialSentPackets() {
|
||||||
|
val expectedInitialPacketSequence = listOf(
|
||||||
|
TestRefPacketItem.TransportLayerPacketItem(
|
||||||
|
TransportLayer.createRequestRegularConnectionPacketInfo()
|
||||||
|
),
|
||||||
|
TestRefPacketItem.ApplicationLayerPacketItem(
|
||||||
|
ApplicationLayer.createCTRLConnectPacket()
|
||||||
|
),
|
||||||
|
TestRefPacketItem.ApplicationLayerPacketItem(
|
||||||
|
ApplicationLayer.createCTRLActivateServicePacket(ApplicationLayer.ServiceID.RT_MODE)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
checkTestPacketSequence(expectedInitialPacketSequence, testIO.sentPacketData)
|
||||||
|
for (i in expectedInitialPacketSequence.indices)
|
||||||
|
testIO.sentPacketData.removeAt(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkRTButtonStatusPacketData(
|
||||||
|
packetData: List<Byte>,
|
||||||
|
rtButton: ApplicationLayer.RTButton,
|
||||||
|
buttonStatusChangedFlag: Boolean
|
||||||
|
) {
|
||||||
|
val appLayerPacket = ApplicationLayer.Packet(packetData.toTransportLayerPacket())
|
||||||
|
assertEquals(ApplicationLayer.Command.RT_BUTTON_STATUS, appLayerPacket.command, "Application layer packet command mismatch")
|
||||||
|
assertEquals(rtButton.id.toByte(), appLayerPacket.payload[2], "RT_BUTTON_STATUS button byte mismatch")
|
||||||
|
assertEquals((if (buttonStatusChangedFlag) 0xB7 else 0x48).toByte(), appLayerPacket.payload[3], "RT_BUTTON_STATUS status flag mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkDisconnectPacketData(packetData: List<Byte>) {
|
||||||
|
val appLayerPacket = ApplicationLayer.Packet(packetData.toTransportLayerPacket())
|
||||||
|
assertEquals(ApplicationLayer.Command.CTRL_DISCONNECT, appLayerPacket.command, "Application layer packet command mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkShortButtonPress() {
|
||||||
|
// Check that a short button press is handled correctly.
|
||||||
|
// Short button presses are performed by sending two RT_BUTTON_STATUS
|
||||||
|
// packets. The first one contains the actual button code, the second
|
||||||
|
// one contains a NO_BUTTON code. We send two short button presses.
|
||||||
|
// This amounts to 2 pairs of RT_BUTTON_STATUS packets plus the
|
||||||
|
// final CTRL_DISCONNECT packets, for a total of 5 packets.
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
val testStates = TestStates(true)
|
||||||
|
val pumpIO = testStates.pumpIO
|
||||||
|
val testIO = testStates.testIO
|
||||||
|
|
||||||
|
testStates.feedInitialPackets()
|
||||||
|
|
||||||
|
pumpIO.connect(runHeartbeat = false)
|
||||||
|
|
||||||
|
pumpIO.sendShortRTButtonPress(ApplicationLayer.RTButton.UP)
|
||||||
|
delay(200L)
|
||||||
|
|
||||||
|
pumpIO.sendShortRTButtonPress(ApplicationLayer.RTButton.UP)
|
||||||
|
delay(200L)
|
||||||
|
|
||||||
|
pumpIO.disconnect()
|
||||||
|
|
||||||
|
testStates.checkAndRemoveInitialSentPackets()
|
||||||
|
|
||||||
|
// 4 RT packets from the sendShortRTButtonPress() calls
|
||||||
|
// above plus the final CTRL_DISCONNECT packet -> 5 packets.
|
||||||
|
assertEquals(5, testIO.sentPacketData.size)
|
||||||
|
|
||||||
|
// The two RT_BUTTON_STATUS packets (first one with button
|
||||||
|
// code UP, second one with button code NO_BUTTON) that
|
||||||
|
// were sent by the first sendShortRTButtonPress() call.
|
||||||
|
|
||||||
|
testStates.checkRTButtonStatusPacketData(
|
||||||
|
testIO.sentPacketData[0],
|
||||||
|
ApplicationLayer.RTButton.UP,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
testStates.checkRTButtonStatusPacketData(
|
||||||
|
testIO.sentPacketData[1],
|
||||||
|
ApplicationLayer.RTButton.NO_BUTTON,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
// The two RT_BUTTON_STATUS packets (first one with button
|
||||||
|
// code UP, second one with button code NO_BUTTON) that
|
||||||
|
// were sent by the second sendShortRTButtonPress() call.
|
||||||
|
|
||||||
|
testStates.checkRTButtonStatusPacketData(
|
||||||
|
testIO.sentPacketData[2],
|
||||||
|
ApplicationLayer.RTButton.UP,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
testStates.checkRTButtonStatusPacketData(
|
||||||
|
testIO.sentPacketData[3],
|
||||||
|
ApplicationLayer.RTButton.NO_BUTTON,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
// The final CTRL_DISCONNECT packet.
|
||||||
|
testStates.checkDisconnectPacketData(testIO.sentPacketData[4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkUpDownLongRTButtonPress() {
|
||||||
|
// Basic long press test. After connecting to the simulated Combo,
|
||||||
|
// the UP button is long-pressed. Then, the client reconnects to the
|
||||||
|
// Combo, and the same is done with the DOWN button. This tests that
|
||||||
|
// no states remain from a previous connection, and also of course
|
||||||
|
// tests that long-presses are handled correctly.
|
||||||
|
// The connection is established with the RT Keep-alive loop disabled
|
||||||
|
// to avoid having to deal with RT_KEEP_ALIVE packets in the
|
||||||
|
// testIO.sentPacketData list.
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
val testStates = TestStates(true)
|
||||||
|
val testIO = testStates.testIO
|
||||||
|
val pumpIO = testStates.pumpIO
|
||||||
|
|
||||||
|
// First, test long UP button press.
|
||||||
|
|
||||||
|
testStates.feedInitialPackets()
|
||||||
|
|
||||||
|
pumpIO.connect(runHeartbeat = false)
|
||||||
|
|
||||||
|
var counter = 0
|
||||||
|
pumpIO.startLongRTButtonPress(ApplicationLayer.RTButton.UP) {
|
||||||
|
// Return true the first time, false the second time.
|
||||||
|
// This way, we inform the function that it should
|
||||||
|
// send a button status to the Combo once (= when
|
||||||
|
// we return true).
|
||||||
|
counter++
|
||||||
|
counter <= 1
|
||||||
|
}
|
||||||
|
pumpIO.waitForLongRTButtonPressToFinish()
|
||||||
|
|
||||||
|
pumpIO.disconnect()
|
||||||
|
|
||||||
|
testStates.checkAndRemoveInitialSentPackets()
|
||||||
|
testStates.checkLongRTButtonPressPacketSequence(ApplicationLayer.RTButton.UP)
|
||||||
|
|
||||||
|
// Next, test long DOWN button press. Use stopLongRTButtonPress()
|
||||||
|
// instead of waitForLongRTButtonPressToFinish() here to also
|
||||||
|
// test that function. Waiting for a while and calling it should
|
||||||
|
// amount to the same behavior as calling waitForLongRTButtonPressToFinish().
|
||||||
|
|
||||||
|
testIO.resetSentPacketData()
|
||||||
|
testIO.resetIncomingPacketDataChannel()
|
||||||
|
|
||||||
|
testStates.feedInitialPackets()
|
||||||
|
|
||||||
|
pumpIO.connect(runHeartbeat = false)
|
||||||
|
|
||||||
|
pumpIO.startLongRTButtonPress(ApplicationLayer.RTButton.DOWN)
|
||||||
|
delay(500L)
|
||||||
|
pumpIO.stopLongRTButtonPress()
|
||||||
|
delay(500L)
|
||||||
|
|
||||||
|
pumpIO.disconnect()
|
||||||
|
|
||||||
|
testStates.checkAndRemoveInitialSentPackets()
|
||||||
|
testStates.checkLongRTButtonPressPacketSequence(ApplicationLayer.RTButton.DOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkDoubleLongButtonPress() {
|
||||||
|
// Check what happens if the user issues redundant startLongRTButtonPress()
|
||||||
|
// calls. The second call here should be ignored.
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
val testStates = TestStates(true)
|
||||||
|
val pumpIO = testStates.pumpIO
|
||||||
|
|
||||||
|
testStates.feedInitialPackets()
|
||||||
|
|
||||||
|
pumpIO.connect(runHeartbeat = false)
|
||||||
|
|
||||||
|
var counter = 0
|
||||||
|
pumpIO.startLongRTButtonPress(ApplicationLayer.RTButton.UP) {
|
||||||
|
// Return true the first time, false the second time.
|
||||||
|
// This way, we inform the function that it should
|
||||||
|
// send a button status to the Combo once (= when
|
||||||
|
// we return true).
|
||||||
|
counter++
|
||||||
|
counter <= 1
|
||||||
|
}
|
||||||
|
pumpIO.startLongRTButtonPress(ApplicationLayer.RTButton.UP)
|
||||||
|
|
||||||
|
pumpIO.waitForLongRTButtonPressToFinish()
|
||||||
|
|
||||||
|
pumpIO.disconnect()
|
||||||
|
|
||||||
|
testStates.checkAndRemoveInitialSentPackets()
|
||||||
|
testStates.checkLongRTButtonPressPacketSequence(ApplicationLayer.RTButton.UP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkDoubleLongButtonRelease() {
|
||||||
|
// Check what happens if the user issues redundant waitForLongRTButtonPressToFinish()
|
||||||
|
// calls. The second call here should be ignored.
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
val testStates = TestStates(true)
|
||||||
|
val pumpIO = testStates.pumpIO
|
||||||
|
|
||||||
|
testStates.feedInitialPackets()
|
||||||
|
|
||||||
|
pumpIO.connect(runHeartbeat = false)
|
||||||
|
|
||||||
|
var counter = 0
|
||||||
|
pumpIO.startLongRTButtonPress(ApplicationLayer.RTButton.UP) {
|
||||||
|
// Return true the first time, false the second time.
|
||||||
|
// This way, we inform the function that it should
|
||||||
|
// send a button status to the Combo once (= when
|
||||||
|
// we return true).
|
||||||
|
counter++
|
||||||
|
counter <= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pumpIO.waitForLongRTButtonPressToFinish()
|
||||||
|
pumpIO.waitForLongRTButtonPressToFinish()
|
||||||
|
|
||||||
|
pumpIO.disconnect()
|
||||||
|
|
||||||
|
testStates.checkAndRemoveInitialSentPackets()
|
||||||
|
testStates.checkLongRTButtonPressPacketSequence(ApplicationLayer.RTButton.UP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkRTSequenceNumberAssignment() {
|
||||||
|
// Check that PumpIO fills in correctly the RT sequence
|
||||||
|
// in outgoing RT packets. We use sendShortRTButtonPress()
|
||||||
|
// for this purpose, since each call produces 2 RT packets.
|
||||||
|
// We look at the transmitted RT packets and check if their
|
||||||
|
// RT sequence numbers are monotonically increasing, which
|
||||||
|
// is the correct behavior.
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
val testStates = TestStates(true)
|
||||||
|
val pumpIO = testStates.pumpIO
|
||||||
|
val testIO = testStates.testIO
|
||||||
|
|
||||||
|
testStates.feedInitialPackets()
|
||||||
|
|
||||||
|
pumpIO.connect(runHeartbeat = false)
|
||||||
|
|
||||||
|
pumpIO.sendShortRTButtonPress(ApplicationLayer.RTButton.UP)
|
||||||
|
delay(200L)
|
||||||
|
pumpIO.sendShortRTButtonPress(ApplicationLayer.RTButton.UP)
|
||||||
|
delay(200L)
|
||||||
|
pumpIO.sendShortRTButtonPress(ApplicationLayer.RTButton.UP)
|
||||||
|
delay(200L)
|
||||||
|
|
||||||
|
pumpIO.disconnect()
|
||||||
|
|
||||||
|
testStates.checkAndRemoveInitialSentPackets()
|
||||||
|
|
||||||
|
// 6 RT packets from the sendShortRTButtonPress() calls
|
||||||
|
// above plus the final CTRL_DISCONNECT packet -> 7 packets.
|
||||||
|
assertEquals(7, testIO.sentPacketData.size)
|
||||||
|
|
||||||
|
// The 3 sendShortRTButtonPress() calls each sent two
|
||||||
|
// packets, so we look at the first six packets here.
|
||||||
|
// The last one is the CTRL_DISCONNECT packet, which
|
||||||
|
// we verify below. The first 6 packets are RT packets,
|
||||||
|
// and their sequence numbers must be monotonically
|
||||||
|
// increasing, as explained above.
|
||||||
|
for (index in 0 until 6) {
|
||||||
|
val appLayerPacket = ApplicationLayer.Packet(testIO.sentPacketData[index].toTransportLayerPacket())
|
||||||
|
val rtSequenceNumber = (appLayerPacket.payload[0].toPosInt() shl 0) or (appLayerPacket.payload[1].toPosInt() shl 8)
|
||||||
|
assertEquals(index, rtSequenceNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
testStates.checkDisconnectPacketData(testIO.sentPacketData[6])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cmdCMDReadErrorWarningStatus() {
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
// Check that a simulated CMD error/warning status retrieval is performed successfully.
|
||||||
|
// Feed in raw data bytes into the test IO. These raw bytes are packets that contain
|
||||||
|
// error/warning status data. Check that these packets are correctly parsed and that
|
||||||
|
// the retrieved status is correct.
|
||||||
|
|
||||||
|
val testStates = TestStates(setupInvariantPumpData = false)
|
||||||
|
val pumpIO = testStates.pumpIO
|
||||||
|
val testIO = testStates.testIO
|
||||||
|
|
||||||
|
// Need to set up custom keys since the test data was
|
||||||
|
// created with those instead of the default test keys.
|
||||||
|
val invariantPumpData = InvariantPumpData(
|
||||||
|
keyResponseAddress = 0x10,
|
||||||
|
clientPumpCipher = Cipher(byteArrayOfInts(
|
||||||
|
0x12, 0xe2, 0x4a, 0xb6, 0x67, 0x50, 0xe5, 0xb4,
|
||||||
|
0xc4, 0xea, 0x10, 0xa7, 0x55, 0x11, 0x61, 0xd4)),
|
||||||
|
pumpClientCipher = Cipher(byteArrayOfInts(
|
||||||
|
0x8e, 0x0d, 0x35, 0xe3, 0x7c, 0xd7, 0x20, 0x55,
|
||||||
|
0x57, 0x2b, 0x05, 0x50, 0x34, 0x43, 0xc9, 0x8d)),
|
||||||
|
pumpID = "testPump"
|
||||||
|
)
|
||||||
|
testStates.testPumpStateStore.createPumpState(
|
||||||
|
testStates.testBluetoothDevice.address, invariantPumpData, UtcOffset.ZERO, CurrentTbrState.NoTbrOngoing)
|
||||||
|
testIO.pumpClientCipher = invariantPumpData.pumpClientCipher
|
||||||
|
|
||||||
|
testStates.feedInitialPackets()
|
||||||
|
|
||||||
|
pumpIO.connect(
|
||||||
|
initialMode = PumpIO.Mode.COMMAND,
|
||||||
|
runHeartbeat = false
|
||||||
|
)
|
||||||
|
|
||||||
|
val errorWarningStatusData = byteArrayListOfInts(
|
||||||
|
0x10, 0x23, 0x08, 0x00, 0x01, 0x39, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0xb7, 0xa5, 0xaa,
|
||||||
|
0x00, 0x00, 0x48, 0xb7, 0xa0, 0xea, 0x70, 0xc3, 0xd4, 0x42, 0x61, 0xd7
|
||||||
|
)
|
||||||
|
testIO.feedIncomingData(errorWarningStatusData)
|
||||||
|
|
||||||
|
val errorWarningStatus = pumpIO.readCMDErrorWarningStatus()
|
||||||
|
|
||||||
|
pumpIO.disconnect()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ApplicationLayer.CMDErrorWarningStatus(errorOccurred = false, warningOccurred = true),
|
||||||
|
errorWarningStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkCMDHistoryDeltaRetrieval() {
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
// Check that a simulated CMD history delta retrieval is performed successfully.
|
||||||
|
// Feed in raw data bytes into the test IO. These raw bytes are packets that
|
||||||
|
// contain history data with a series of events inside. Check that these packets
|
||||||
|
// are correctly parsed and that the retrieved history is correct.
|
||||||
|
|
||||||
|
val testStates = TestStates(setupInvariantPumpData = false)
|
||||||
|
val pumpIO = testStates.pumpIO
|
||||||
|
val testIO = testStates.testIO
|
||||||
|
|
||||||
|
// Need to set up custom keys since the test data was
|
||||||
|
// created with those instead of the default test keys.
|
||||||
|
val invariantPumpData = InvariantPumpData(
|
||||||
|
keyResponseAddress = 0x10,
|
||||||
|
clientPumpCipher = Cipher(byteArrayOfInts(
|
||||||
|
0x75, 0xb8, 0x88, 0xa8, 0xe7, 0x68, 0xc9, 0x25,
|
||||||
|
0x66, 0xc9, 0x3c, 0x4b, 0xd8, 0x09, 0x27, 0xd8)),
|
||||||
|
pumpClientCipher = Cipher(byteArrayOfInts(
|
||||||
|
0xb8, 0x75, 0x8c, 0x54, 0x88, 0x71, 0x78, 0xed,
|
||||||
|
0xad, 0xb7, 0xb7, 0xc1, 0x48, 0x37, 0xf3, 0x07)),
|
||||||
|
pumpID = "testPump"
|
||||||
|
)
|
||||||
|
testStates.testPumpStateStore.createPumpState(
|
||||||
|
testStates.testBluetoothDevice.address, invariantPumpData, UtcOffset.ZERO, CurrentTbrState.NoTbrOngoing)
|
||||||
|
testIO.pumpClientCipher = invariantPumpData.pumpClientCipher
|
||||||
|
|
||||||
|
val historyBlockPacketData = listOf(
|
||||||
|
// CMD_READ_HISTORY_BLOCK_RESPONSE
|
||||||
|
byteArrayListOfInts(
|
||||||
|
0x10, 0xa3, 0x65, 0x00, 0x01, 0x08, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x10, 0xb7, 0x96, 0xa9, 0x00, 0x00, 0x10, 0x00, 0x48, 0xb7, 0x05, 0xaa, 0x0d, 0x93, 0x54, 0x0f, 0x00, 0x00,
|
||||||
|
0x00, 0x04, 0x00, 0x6b, 0xf3, 0x09, 0x3b, 0x01, 0x00, 0x92, 0x4c, 0xb1, 0x0d, 0x93, 0x54, 0x0f, 0x00, 0x00,
|
||||||
|
0x00, 0x05, 0x00, 0xa1, 0x25, 0x0b, 0x3b, 0x01, 0x00, 0xe4, 0x75, 0x46, 0x0e, 0x93, 0x54, 0x1d, 0x00, 0x00,
|
||||||
|
0x00, 0x06, 0x00, 0xb7, 0xda, 0x0d, 0x3b, 0x01, 0x00, 0x7e, 0x3e, 0x54, 0x0e, 0x93, 0x54, 0x1d, 0x00, 0x00,
|
||||||
|
0x00, 0x07, 0x00, 0x73, 0x49, 0x0f, 0x3b, 0x01, 0x00, 0x08, 0x07, 0x77, 0x0e, 0x93, 0x54, 0x05, 0x00, 0x00,
|
||||||
|
0x00, 0x04, 0x00, 0x2f, 0xd8, 0x11, 0x3b, 0x01, 0x00, 0xeb, 0x6a, 0x81, 0xf5, 0x6c, 0x43, 0xf0, 0x88, 0x15, 0x3b
|
||||||
|
),
|
||||||
|
// CMD_CONFIRM_HISTORY_BLOCK_RESPONSE
|
||||||
|
byteArrayListOfInts(
|
||||||
|
0x10, 0x23, 0x06, 0x00, 0x01, 0x0a, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x10, 0xb7, 0x99, 0xa9, 0x00, 0x00, 0x8f, 0xec, 0xfa, 0xa7, 0xf5, 0x0d, 0x01, 0x6c
|
||||||
|
),
|
||||||
|
// CMD_READ_HISTORY_BLOCK_RESPONSE
|
||||||
|
byteArrayListOfInts(
|
||||||
|
0x10, 0xa3, 0x65, 0x00, 0x01, 0x0c, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x10, 0xb7, 0x96, 0xa9, 0x00, 0x00, 0x0b, 0x00, 0x48, 0xb7, 0x05, 0x79, 0x0e, 0x93, 0x54, 0x05, 0x00, 0x00,
|
||||||
|
0x00, 0x05, 0x00, 0x0c, 0x40, 0x13, 0x3b, 0x01, 0x00, 0x9d, 0x53, 0xad, 0x0e, 0x93, 0x54, 0x12, 0x00, 0x00,
|
||||||
|
0x00, 0x06, 0x00, 0x46, 0xa5, 0x15, 0x3b, 0x01, 0x00, 0x07, 0x18, 0xb6, 0x0e, 0x93, 0x54, 0x12, 0x00, 0x00,
|
||||||
|
0x00, 0x07, 0x00, 0x8c, 0x73, 0x17, 0x3b, 0x01, 0x00, 0x71, 0x21, 0x13, 0x10, 0x93, 0x54, 0xb1, 0x00, 0x0f,
|
||||||
|
0x00, 0x08, 0x00, 0xbb, 0x78, 0x1a, 0x3b, 0x01, 0x00, 0xfe, 0xaa, 0xd2, 0x13, 0x93, 0x54, 0xb1, 0x00, 0x0f,
|
||||||
|
0x00, 0x09, 0x00, 0xce, 0x68, 0x1c, 0x3b, 0x01, 0x00, 0x64, 0xe1, 0x2c, 0xc8, 0x37, 0xb3, 0xe5, 0xb7, 0x7c, 0xc4
|
||||||
|
),
|
||||||
|
// CMD_CONFIRM_HISTORY_BLOCK_RESPONSE
|
||||||
|
byteArrayListOfInts(
|
||||||
|
0x10, 0x23, 0x06, 0x00, 0x01, 0x0e, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x10, 0xb7, 0x99, 0xa9, 0x00, 0x00, 0xe5, 0xab, 0x11, 0x6d, 0xfc, 0x60, 0xfb, 0xee
|
||||||
|
),
|
||||||
|
// CMD_READ_HISTORY_BLOCK_RESPONSE
|
||||||
|
byteArrayListOfInts(
|
||||||
|
0x10, 0xa3, 0x65, 0x00, 0x01, 0x10, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x10, 0xb7, 0x96, 0xa9, 0x00, 0x00, 0x06, 0x00, 0x48, 0xb7, 0x05, 0x5f, 0x15, 0x93, 0x54, 0xc1, 0x94, 0xe0,
|
||||||
|
0x01, 0x0a, 0x00, 0x76, 0x3b, 0x1e, 0x3b, 0x01, 0x00, 0x12, 0xd8, 0xc8, 0x1c, 0x93, 0x54, 0xc1, 0x94, 0xe0,
|
||||||
|
0x01, 0x0b, 0x00, 0xc8, 0xa4, 0x20, 0x3b, 0x01, 0x00, 0xa2, 0x3a, 0x59, 0x20, 0x93, 0x54, 0x40, 0x30, 0x93,
|
||||||
|
0x54, 0x18, 0x00, 0xbb, 0x0c, 0x23, 0x3b, 0x01, 0x00, 0x6f, 0x1f, 0x40, 0x30, 0x93, 0x54, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x19, 0x00, 0x2b, 0x80, 0x24, 0x3b, 0x01, 0x00, 0x4e, 0x48, 0x85, 0x30, 0x93, 0x54, 0x14, 0x00, 0x00,
|
||||||
|
0x00, 0x04, 0x00, 0xe8, 0x98, 0x2b, 0x3b, 0x01, 0x00, 0xb7, 0xfa, 0x0e, 0x32, 0x37, 0x19, 0xb6, 0x59, 0x5a, 0xb1
|
||||||
|
),
|
||||||
|
// CMD_CONFIRM_HISTORY_BLOCK_RESPONSE
|
||||||
|
byteArrayListOfInts(
|
||||||
|
0x10, 0x23, 0x06, 0x00, 0x01, 0x12, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x10, 0xb7, 0x99, 0xa9, 0x00, 0x00, 0xae, 0xaa, 0xa7, 0x3a, 0xbc, 0x82, 0x8c, 0x15
|
||||||
|
),
|
||||||
|
// CMD_READ_HISTORY_BLOCK_RESPONSE
|
||||||
|
byteArrayListOfInts(
|
||||||
|
0x10, 0xa3, 0x1d, 0x00, 0x01, 0x14, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x10, 0xb7, 0x96, 0xa9, 0x00, 0x00, 0x01, 0x00, 0xb7, 0xb7, 0x01, 0x8f, 0x30, 0x93, 0x54, 0x14, 0x00, 0x00,
|
||||||
|
0x00, 0x05, 0x00, 0x57, 0xb0, 0x2d, 0x3b, 0x01, 0x00, 0x2d, 0xb1, 0x29, 0x32, 0xde, 0x3c, 0xa0, 0x80, 0x33, 0xd3
|
||||||
|
),
|
||||||
|
// CMD_CONFIRM_HISTORY_BLOCK_RESPONSE
|
||||||
|
byteArrayListOfInts(
|
||||||
|
0x10, 0x23, 0x06, 0x00, 0x01, 0x16, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x10, 0xb7, 0x99, 0xa9, 0x00, 0x00, 0x15, 0x63, 0xa5, 0x60, 0x3d, 0x75, 0xff, 0xfc
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val expectedHistoryDeltaEvents = listOf(
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 54, second = 42),
|
||||||
|
80649,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.QuickBolusRequested(15)
|
||||||
|
),
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 54, second = 49),
|
||||||
|
80651,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.QuickBolusInfused(15)
|
||||||
|
),
|
||||||
|
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 57, second = 6),
|
||||||
|
80653,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.StandardBolusRequested(29, true)
|
||||||
|
),
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 57, second = 20),
|
||||||
|
80655,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.StandardBolusInfused(29, true)
|
||||||
|
),
|
||||||
|
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 57, second = 55),
|
||||||
|
80657,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.QuickBolusRequested(5)
|
||||||
|
),
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 57, second = 57),
|
||||||
|
80659,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.QuickBolusInfused(5)
|
||||||
|
),
|
||||||
|
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 58, second = 45),
|
||||||
|
80661,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.StandardBolusRequested(18, true)
|
||||||
|
),
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 58, second = 54),
|
||||||
|
80663,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.StandardBolusInfused(18, true)
|
||||||
|
),
|
||||||
|
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 17, minute = 0, second = 19),
|
||||||
|
80666,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.ExtendedBolusStarted(177, 15)
|
||||||
|
),
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 17, minute = 15, second = 18),
|
||||||
|
80668,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.ExtendedBolusEnded(177, 15)
|
||||||
|
),
|
||||||
|
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 17, minute = 21, second = 31),
|
||||||
|
80670,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.MultiwaveBolusStarted(193, 37, 30)
|
||||||
|
),
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 17, minute = 51, second = 8),
|
||||||
|
80672,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.MultiwaveBolusEnded(193, 37, 30)
|
||||||
|
),
|
||||||
|
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 18, minute = 1, second = 25),
|
||||||
|
80675,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.NewDateTimeSet(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 19, minute = 1, second = 0)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 19, minute = 2, second = 5),
|
||||||
|
80683,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.QuickBolusRequested(20)
|
||||||
|
),
|
||||||
|
ApplicationLayer.CMDHistoryEvent(
|
||||||
|
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 19, minute = 2, second = 15),
|
||||||
|
80685,
|
||||||
|
ApplicationLayer.CMDHistoryEventDetail.QuickBolusInfused(20)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
testStates.feedInitialPackets()
|
||||||
|
|
||||||
|
pumpIO.connect(
|
||||||
|
initialMode = PumpIO.Mode.COMMAND,
|
||||||
|
runHeartbeat = false
|
||||||
|
)
|
||||||
|
|
||||||
|
historyBlockPacketData.forEach { testIO.feedIncomingData(it) }
|
||||||
|
|
||||||
|
val historyDelta = pumpIO.getCMDHistoryDelta(100)
|
||||||
|
|
||||||
|
pumpIO.disconnect()
|
||||||
|
|
||||||
|
assertEquals(expectedHistoryDeltaEvents.size, historyDelta.size)
|
||||||
|
for (events in expectedHistoryDeltaEvents.zip(historyDelta))
|
||||||
|
assertEquals(events.first, events.second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,411 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import info.nightscout.comboctl.base.testUtils.TestComboIO
|
||||||
|
import info.nightscout.comboctl.base.testUtils.TestPumpStateStore
|
||||||
|
import info.nightscout.comboctl.base.testUtils.WatchdogTimeoutException
|
||||||
|
import info.nightscout.comboctl.base.testUtils.coroutineScopeWithWatchdog
|
||||||
|
import info.nightscout.comboctl.base.testUtils.runBlockingWithWatchdog
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNotEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.datetime.UtcOffset
|
||||||
|
|
||||||
|
class TransportLayerTest {
|
||||||
|
@Test
|
||||||
|
fun parsePacketData() {
|
||||||
|
// Test the packet parser by parsing hardcoded packet data
|
||||||
|
// and verifying the individual packet property values.
|
||||||
|
|
||||||
|
val packetDataWithCRCPayload = byteArrayListOfInts(
|
||||||
|
0x10, // version
|
||||||
|
0x09, // request_pairing_connection command (sequence and data reliability bit set to 0)
|
||||||
|
0x02, 0x00, // payload length
|
||||||
|
0xF0, // address
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // nonce
|
||||||
|
0x99, 0x44, // payload
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 // nullbyte MAC
|
||||||
|
)
|
||||||
|
|
||||||
|
// The actual parsing.
|
||||||
|
val packet = packetDataWithCRCPayload.toTransportLayerPacket()
|
||||||
|
|
||||||
|
// Check the individual properties.
|
||||||
|
|
||||||
|
assertEquals(0x10, packet.version)
|
||||||
|
assertFalse(packet.sequenceBit)
|
||||||
|
assertFalse(packet.reliabilityBit)
|
||||||
|
assertEquals(TransportLayer.Command.REQUEST_PAIRING_CONNECTION, packet.command)
|
||||||
|
assertEquals(0xF0.toByte(), packet.address)
|
||||||
|
assertEquals(Nonce.nullNonce(), packet.nonce)
|
||||||
|
assertEquals(byteArrayListOfInts(0x99, 0x44), packet.payload)
|
||||||
|
assertEquals(NullMachineAuthCode, packet.machineAuthenticationCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createPacketData() {
|
||||||
|
// Create packet, and check that it is correctly converted to a byte list.
|
||||||
|
|
||||||
|
val packet = TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.REQUEST_PAIRING_CONNECTION,
|
||||||
|
version = 0x42,
|
||||||
|
sequenceBit = true,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x45,
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0B)),
|
||||||
|
payload = byteArrayListOfInts(0x50, 0x60, 0x70),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08))
|
||||||
|
)
|
||||||
|
|
||||||
|
val byteList = packet.toByteList()
|
||||||
|
|
||||||
|
val expectedPacketData = byteArrayListOfInts(
|
||||||
|
0x42, // version
|
||||||
|
0x80 or 0x09, // command 0x09 with sequence bit enabled
|
||||||
|
0x03, 0x00, // payload length
|
||||||
|
0x45, // address,
|
||||||
|
0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0B, // nonce
|
||||||
|
0x50, 0x60, 0x70, // payload
|
||||||
|
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08 // MAC
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(byteList, expectedPacketData)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verifyPacketDataIntegrityWithCRC() {
|
||||||
|
// Create packet and verify that the CRC check detects data corruption.
|
||||||
|
|
||||||
|
val packet = TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.REQUEST_PAIRING_CONNECTION,
|
||||||
|
version = 0x42,
|
||||||
|
sequenceBit = true,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x45,
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0B)),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check that the computed CRC is correct.
|
||||||
|
packet.computeCRC16Payload()
|
||||||
|
val expectedCRCPayload = byteArrayListOfInts(0xE1, 0x7B)
|
||||||
|
assertEquals(expectedCRCPayload, packet.payload)
|
||||||
|
|
||||||
|
// The CRC should match, since it was just computed.
|
||||||
|
assertTrue(packet.verifyCRC16Payload())
|
||||||
|
|
||||||
|
// Simulate data corruption by altering the CRC itself.
|
||||||
|
// This should produce a CRC mismatch, since the check
|
||||||
|
// will recompute the CRC from the header data.
|
||||||
|
packet.payload[0] = (packet.payload[0].toPosInt() xor 0xFF).toByte()
|
||||||
|
assertFalse(packet.verifyCRC16Payload())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verifyPacketDataIntegrityWithMAC() {
|
||||||
|
// Create packet and verify that the MAC check detects data corruption.
|
||||||
|
|
||||||
|
val key = ByteArray(CIPHER_KEY_SIZE).apply { fill('0'.code.toByte()) }
|
||||||
|
val cipher = Cipher(key)
|
||||||
|
|
||||||
|
val packet = TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.REQUEST_PAIRING_CONNECTION,
|
||||||
|
version = 0x42,
|
||||||
|
sequenceBit = true,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x45,
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0B)),
|
||||||
|
payload = byteArrayListOfInts(0x00, 0x00)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check that the computed MAC is correct.
|
||||||
|
packet.authenticate(cipher)
|
||||||
|
val expectedMAC = MachineAuthCode(byteArrayListOfInts(0x00, 0xC5, 0x48, 0xB3, 0xA8, 0xE6, 0x97, 0x76))
|
||||||
|
assertEquals(expectedMAC, packet.machineAuthenticationCode)
|
||||||
|
|
||||||
|
// The MAC should match, since it was just computed.
|
||||||
|
assertTrue(packet.verifyAuthentication(cipher))
|
||||||
|
|
||||||
|
// Simulate data corruption by altering the payload.
|
||||||
|
// This should produce a MAC mismatch.
|
||||||
|
packet.payload[0] = 0xFF.toByte()
|
||||||
|
assertFalse(packet.verifyAuthentication(cipher))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkBasicTransportLayerSequence() {
|
||||||
|
// Run a basic sequence of IO operations and verify
|
||||||
|
// that they produce the expected results. We use
|
||||||
|
// the connection setup sequence, since this one does
|
||||||
|
// not require an existing pump state.
|
||||||
|
|
||||||
|
// The calls must be run from within a coroutine scope.
|
||||||
|
// Starts a blocking scope with a watchdog that fails
|
||||||
|
// the test if it does not finish within 5 seconds
|
||||||
|
// (in case the tested code hangs).
|
||||||
|
runBlockingWithWatchdog(5000) {
|
||||||
|
val testPumpStateStore = TestPumpStateStore()
|
||||||
|
val testComboIO = TestComboIO()
|
||||||
|
val testBluetoothAddress = BluetoothAddress(byteArrayListOfInts(1, 2, 3, 4, 5, 6))
|
||||||
|
val tpLayerIO = TransportLayer.IO(testPumpStateStore, testBluetoothAddress, testComboIO) {}
|
||||||
|
|
||||||
|
// We'll simulate sending a REQUEST_PAIRING_CONNECTION packet and
|
||||||
|
// receiving a PAIRING_CONNECTION_REQUEST_ACCEPTED packet.
|
||||||
|
|
||||||
|
val pairingConnectionRequestAcceptedPacket = TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.PAIRING_CONNECTION_REQUEST_ACCEPTED,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x0f.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0x00, 0xF0, 0x6D),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set up transport layer IO and forward all packets to receive() calls.
|
||||||
|
tpLayerIO.start(packetReceiverScope = this) { TransportLayer.IO.ReceiverBehavior.FORWARD_PACKET }
|
||||||
|
|
||||||
|
// Send a REQUEST_PAIRING_CONNECTION and simulate Combo reaction
|
||||||
|
// to it by feeding the simulated Combo response into the IO object.
|
||||||
|
tpLayerIO.send(TransportLayer.createRequestPairingConnectionPacketInfo())
|
||||||
|
testComboIO.feedIncomingData(pairingConnectionRequestAcceptedPacket.toByteList())
|
||||||
|
// Receive the simulated response.
|
||||||
|
val receivedPacket = tpLayerIO.receive()
|
||||||
|
|
||||||
|
tpLayerIO.stop()
|
||||||
|
|
||||||
|
// IO is done. We expect 1 packet to have been sent by the transport layer IO.
|
||||||
|
// Also, we expect to have received the PAIRING_CONNECTION_REQUEST_ACCEPTED
|
||||||
|
// packet. Check for this, and verify that the sent packet data and the
|
||||||
|
// received packet data are correct.
|
||||||
|
|
||||||
|
val expectedReqPairingConnectionPacket = TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.REQUEST_PAIRING_CONNECTION,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0xf0.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0xB2, 0x11),
|
||||||
|
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, testComboIO.sentPacketData.size)
|
||||||
|
assertEquals(expectedReqPairingConnectionPacket.toByteList(), testComboIO.sentPacketData[0])
|
||||||
|
|
||||||
|
assertEquals(pairingConnectionRequestAcceptedPacket.toByteList(), receivedPacket.toByteList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkPacketReceiverExceptionHandling() {
|
||||||
|
// Test how exceptions in TransportLayer.IO are handled.
|
||||||
|
// We expect that the coroutine inside IO is stopped by
|
||||||
|
// an exception thrown inside.
|
||||||
|
// Subsequent send and receive call attempts need to throw
|
||||||
|
// a PacketReceiverException which in turn contains the
|
||||||
|
// exception that caused the failure.
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(5000) {
|
||||||
|
val testPumpStateStore = TestPumpStateStore()
|
||||||
|
val testComboIO = TestComboIO()
|
||||||
|
val testBluetoothAddress = BluetoothAddress(byteArrayListOfInts(1, 2, 3, 4, 5, 6))
|
||||||
|
var expectedError: Throwable? = null
|
||||||
|
val waitingForExceptionJob = Job()
|
||||||
|
val tpLayerIO = TransportLayer.IO(testPumpStateStore, testBluetoothAddress, testComboIO) { exception ->
|
||||||
|
expectedError = exception
|
||||||
|
waitingForExceptionJob.complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize pump state for the ERROR_RESPONSE packet, since
|
||||||
|
// that one is authenticated via its MAC and this pump state.
|
||||||
|
|
||||||
|
val testDecryptedCPKey =
|
||||||
|
byteArrayListOfInts(0x5a, 0x25, 0x0b, 0x75, 0xa9, 0x02, 0x21, 0xfa, 0xab, 0xbd, 0x36, 0x4d, 0x5c, 0xb8, 0x37, 0xd7)
|
||||||
|
val testDecryptedPCKey =
|
||||||
|
byteArrayListOfInts(0x2a, 0xb0, 0xf2, 0x67, 0xc2, 0x7d, 0xcf, 0xaa, 0x32, 0xb2, 0x48, 0x94, 0xe1, 0x6d, 0xe9, 0x5c)
|
||||||
|
val testAddress = 0x10.toByte()
|
||||||
|
|
||||||
|
testPumpStateStore.createPumpState(
|
||||||
|
testBluetoothAddress,
|
||||||
|
InvariantPumpData(
|
||||||
|
clientPumpCipher = Cipher(testDecryptedCPKey.toByteArray()),
|
||||||
|
pumpClientCipher = Cipher(testDecryptedPCKey.toByteArray()),
|
||||||
|
keyResponseAddress = testAddress,
|
||||||
|
pumpID = "testPump"
|
||||||
|
),
|
||||||
|
UtcOffset.ZERO, CurrentTbrState.NoTbrOngoing
|
||||||
|
)
|
||||||
|
|
||||||
|
val errorResponsePacket = TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.ERROR_RESPONSE,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x01.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0x0F)
|
||||||
|
)
|
||||||
|
errorResponsePacket.authenticate(Cipher(testDecryptedPCKey.toByteArray()))
|
||||||
|
|
||||||
|
// Start IO, and "receive" the error response packet (which
|
||||||
|
// normally would be sent by the Combo to the client) by feeding
|
||||||
|
// it into the test IO object. Since this packet contains an
|
||||||
|
// error report by the simulated Combo, an exception is thrown
|
||||||
|
// in the packet receiver coroutine in the IO class.
|
||||||
|
tpLayerIO.start(packetReceiverScope = this) { TransportLayer.IO.ReceiverBehavior.FORWARD_PACKET }
|
||||||
|
testComboIO.feedIncomingData(errorResponsePacket.toByteList())
|
||||||
|
|
||||||
|
// Wait until an exception is thrown in the packet receiver
|
||||||
|
// and we get notified about it.
|
||||||
|
waitingForExceptionJob.join()
|
||||||
|
System.err.println(
|
||||||
|
"Exception thrown by in packet receiver (this exception was expected by the test): $expectedError"
|
||||||
|
)
|
||||||
|
assertNotNull(expectedError)
|
||||||
|
assertIs<TransportLayer.ErrorResponseException>(expectedError!!.cause)
|
||||||
|
|
||||||
|
// At this point, the packet receiver is not running anymore
|
||||||
|
// due to the exception. Attempts at sending and receiving
|
||||||
|
// must fail and throw the exception that caused the failure
|
||||||
|
// in the packet receiver. This allows for propagating the
|
||||||
|
// error in a POSIX-esque style, where return codes inform
|
||||||
|
// about a failure that previously happened.
|
||||||
|
|
||||||
|
val exceptionThrownBySendCall = assertFailsWith<TransportLayer.PacketReceiverException> {
|
||||||
|
// The actual packet does not matter here. We just
|
||||||
|
// use createRequestPairingConnectionPacketInfo() to
|
||||||
|
// be able to use send(). Might as well use any
|
||||||
|
// other create*PacketInfo function.
|
||||||
|
tpLayerIO.send(TransportLayer.createRequestPairingConnectionPacketInfo())
|
||||||
|
}
|
||||||
|
System.err.println(
|
||||||
|
"Exception thrown by send() call (this exception was expected by the test): $exceptionThrownBySendCall"
|
||||||
|
)
|
||||||
|
assertIs<TransportLayer.ErrorResponseException>(exceptionThrownBySendCall.cause)
|
||||||
|
|
||||||
|
val exceptionThrownByReceiveCall = assertFailsWith<TransportLayer.PacketReceiverException> {
|
||||||
|
tpLayerIO.receive()
|
||||||
|
}
|
||||||
|
System.err.println(
|
||||||
|
"Exception thrown by receive() call (this exception was expected by the test): $exceptionThrownByReceiveCall"
|
||||||
|
)
|
||||||
|
assertIs<TransportLayer.ErrorResponseException>(exceptionThrownByReceiveCall.cause)
|
||||||
|
|
||||||
|
tpLayerIO.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkCustomIncomingPacketFiltering() {
|
||||||
|
// Test the custom incoming packet processing feature and
|
||||||
|
// its ability to drop packets. We simulate 3 incoming packets,
|
||||||
|
// one of which is a DATA packet. This one we want to drop
|
||||||
|
// before it ever reaches a receive() call. Consequently, we
|
||||||
|
// expect only 2 packets to ever reach receive(), while a third
|
||||||
|
// attempt at receiving should cause that third call to be
|
||||||
|
// suspended indefinitely. Also, we expect our tpLayerIO.start()
|
||||||
|
// callback to see all 3 packets. We count the number of DATA
|
||||||
|
// packets observed to confirm that the expected single DATA
|
||||||
|
// paket is in fact received by the TransportLayer IO.
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
// Setup.
|
||||||
|
|
||||||
|
val testPumpStateStore = TestPumpStateStore()
|
||||||
|
val testBluetoothAddress = BluetoothAddress(byteArrayListOfInts(1, 2, 3, 4, 5, 6))
|
||||||
|
val testComboIO = TestComboIO()
|
||||||
|
|
||||||
|
val tpLayerIO = TransportLayer.IO(testPumpStateStore, testBluetoothAddress, testComboIO) {}
|
||||||
|
|
||||||
|
val testDecryptedCPKey =
|
||||||
|
byteArrayListOfInts(0x5a, 0x25, 0x0b, 0x75, 0xa9, 0x02, 0x21, 0xfa, 0xab, 0xbd, 0x36, 0x4d, 0x5c, 0xb8, 0x37, 0xd7)
|
||||||
|
val testDecryptedPCKey =
|
||||||
|
byteArrayListOfInts(0x2a, 0xb0, 0xf2, 0x67, 0xc2, 0x7d, 0xcf, 0xaa, 0x32, 0xb2, 0x48, 0x94, 0xe1, 0x6d, 0xe9, 0x5c)
|
||||||
|
val testAddress = 0x10.toByte()
|
||||||
|
|
||||||
|
testPumpStateStore.createPumpState(
|
||||||
|
testBluetoothAddress,
|
||||||
|
InvariantPumpData(
|
||||||
|
clientPumpCipher = Cipher(testDecryptedCPKey.toByteArray()),
|
||||||
|
pumpClientCipher = Cipher(testDecryptedPCKey.toByteArray()),
|
||||||
|
keyResponseAddress = testAddress,
|
||||||
|
pumpID = "testPump"
|
||||||
|
),
|
||||||
|
UtcOffset.ZERO, CurrentTbrState.NoTbrOngoing
|
||||||
|
)
|
||||||
|
|
||||||
|
// The packets that our simulated Combo transmits to our client.
|
||||||
|
val customDataPackets = listOf(
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.REGULAR_CONNECTION_REQUEST_ACCEPTED,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x01.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0, 1, 2, 3)
|
||||||
|
),
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.DATA,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x01.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(1, 2, 3)
|
||||||
|
),
|
||||||
|
TransportLayer.Packet(
|
||||||
|
command = TransportLayer.Command.REGULAR_CONNECTION_REQUEST_ACCEPTED,
|
||||||
|
version = 0x10.toByte(),
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x01.toByte(),
|
||||||
|
nonce = Nonce(byteArrayListOfInts(0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||||
|
payload = byteArrayListOfInts(0, 1, 2, 3)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
customDataPackets.forEach {
|
||||||
|
it.authenticate(Cipher(testDecryptedPCKey.toByteArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var numReceivedDataPackets = 0
|
||||||
|
tpLayerIO.start(packetReceiverScope = this) { tpLayerPacket ->
|
||||||
|
if (tpLayerPacket.command == TransportLayer.Command.DATA) {
|
||||||
|
numReceivedDataPackets++
|
||||||
|
TransportLayer.IO.ReceiverBehavior.DROP_PACKET
|
||||||
|
} else
|
||||||
|
TransportLayer.IO.ReceiverBehavior.FORWARD_PACKET
|
||||||
|
}
|
||||||
|
|
||||||
|
customDataPackets.forEach {
|
||||||
|
testComboIO.feedIncomingData(it.toByteList())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that we received 2 non-DATA packets.
|
||||||
|
for (i in 0 until 2) {
|
||||||
|
val tpLayerPacket = tpLayerIO.receive()
|
||||||
|
assertNotEquals(TransportLayer.Command.DATA, tpLayerPacket.command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An attempt at receiving another packet should never
|
||||||
|
// finish, since any packet other than the 2 non-DATA
|
||||||
|
// ones must have been filtered out.
|
||||||
|
assertFailsWith<WatchdogTimeoutException> {
|
||||||
|
coroutineScopeWithWatchdog(1000) {
|
||||||
|
tpLayerIO.receive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tpLayerIO.stop()
|
||||||
|
|
||||||
|
assertEquals(1, numReceivedDataPackets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,333 @@
|
||||||
|
package info.nightscout.comboctl.base
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class TwofishTest {
|
||||||
|
|
||||||
|
// The test datasets in the unit tests below originate from the
|
||||||
|
// Two-Fish known answers test dataset (the twofish-kat.zip
|
||||||
|
// archive). It can be downloaded from:
|
||||||
|
// https://www.schneier.com/academic/twofish/
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkProcessedSubkeys() {
|
||||||
|
// From the ecb_ival.txt test file in the twofish-kat.zip
|
||||||
|
// Two-Fish known answers test dataset. 128-bit keysize.
|
||||||
|
|
||||||
|
val key = byteArrayOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
|
||||||
|
val expectedSubKeys = intArrayOf(
|
||||||
|
0x52C54DDE.toInt(), 0x11F0626D.toInt(), // Input whiten
|
||||||
|
0x7CAC9D4A.toInt(), 0x4D1B4AAA.toInt(),
|
||||||
|
0xB7B83A10.toInt(), 0x1E7D0BEB.toInt(), // Output whiten
|
||||||
|
0xEE9C341F.toInt(), 0xCFE14BE4.toInt(),
|
||||||
|
0xF98FFEF9.toInt(), 0x9C5B3C17.toInt(), // Round subkeys
|
||||||
|
0x15A48310.toInt(), 0x342A4D81.toInt(),
|
||||||
|
0x424D89FE.toInt(), 0xC14724A7.toInt(),
|
||||||
|
0x311B834C.toInt(), 0xFDE87320.toInt(),
|
||||||
|
0x3302778F.toInt(), 0x26CD67B4.toInt(),
|
||||||
|
0x7A6C6362.toInt(), 0xC2BAF60E.toInt(),
|
||||||
|
0x3411B994.toInt(), 0xD972C87F.toInt(),
|
||||||
|
0x84ADB1EA.toInt(), 0xA7DEE434.toInt(),
|
||||||
|
0x54D2960F.toInt(), 0xA2F7CAA8.toInt(),
|
||||||
|
0xA6B8FF8C.toInt(), 0x8014C425.toInt(),
|
||||||
|
0x6A748D1C.toInt(), 0xEDBAF720.toInt(),
|
||||||
|
0x928EF78C.toInt(), 0x0338EE13.toInt(),
|
||||||
|
0x9949D6BE.toInt(), 0xC8314176.toInt(),
|
||||||
|
0x07C07D68.toInt(), 0xECAE7EA7.toInt(),
|
||||||
|
0x1FE71844.toInt(), 0x85C05C89.toInt(),
|
||||||
|
0xF298311E.toInt(), 0x696EA672.toInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
val keyObject = Twofish.processKey(key)
|
||||||
|
|
||||||
|
assertEquals(expectedSubKeys.toList(), keyObject.subKeys.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkPermutationTablesAndMDSMatrixMultiplyTables() {
|
||||||
|
// From the ecb_tbl.txt test file in the twofish-kat.zip
|
||||||
|
// Two-Fish known answers test dataset. 128-bit keysize.
|
||||||
|
|
||||||
|
class TestVector(key: String, plaintext: String, ciphertext: String) {
|
||||||
|
val keyArray: ByteArray
|
||||||
|
val plaintextArray: ByteArray
|
||||||
|
val ciphertextArray: ByteArray
|
||||||
|
|
||||||
|
init {
|
||||||
|
keyArray = hexstringToByteArray(key)
|
||||||
|
plaintextArray = hexstringToByteArray(plaintext)
|
||||||
|
ciphertextArray = hexstringToByteArray(ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hexstringToByteArray(hexstring: String): ByteArray {
|
||||||
|
val array = ByteArray(hexstring.length / 2)
|
||||||
|
|
||||||
|
for (i in array.indices) {
|
||||||
|
val hexcharStr = hexstring.substring(IntRange(i * 2, i * 2 + 1))
|
||||||
|
array[i] = Integer.parseInt(hexcharStr, 16).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
return array
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val testVectors = arrayOf(
|
||||||
|
TestVector(
|
||||||
|
key = "00000000000000000000000000000000",
|
||||||
|
plaintext = "00000000000000000000000000000000",
|
||||||
|
ciphertext = "9F589F5CF6122C32B6BFEC2F2AE8C35A"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "00000000000000000000000000000000",
|
||||||
|
plaintext = "9F589F5CF6122C32B6BFEC2F2AE8C35A",
|
||||||
|
ciphertext = "D491DB16E7B1C39E86CB086B789F5419"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "9F589F5CF6122C32B6BFEC2F2AE8C35A",
|
||||||
|
plaintext = "D491DB16E7B1C39E86CB086B789F5419",
|
||||||
|
ciphertext = "019F9809DE1711858FAAC3A3BA20FBC3"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "D491DB16E7B1C39E86CB086B789F5419",
|
||||||
|
plaintext = "019F9809DE1711858FAAC3A3BA20FBC3",
|
||||||
|
ciphertext = "6363977DE839486297E661C6C9D668EB"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "019F9809DE1711858FAAC3A3BA20FBC3",
|
||||||
|
plaintext = "6363977DE839486297E661C6C9D668EB",
|
||||||
|
ciphertext = "816D5BD0FAE35342BF2A7412C246F752"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "6363977DE839486297E661C6C9D668EB",
|
||||||
|
plaintext = "816D5BD0FAE35342BF2A7412C246F752",
|
||||||
|
ciphertext = "5449ECA008FF5921155F598AF4CED4D0"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "816D5BD0FAE35342BF2A7412C246F752",
|
||||||
|
plaintext = "5449ECA008FF5921155F598AF4CED4D0",
|
||||||
|
ciphertext = "6600522E97AEB3094ED5F92AFCBCDD10"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "5449ECA008FF5921155F598AF4CED4D0",
|
||||||
|
plaintext = "6600522E97AEB3094ED5F92AFCBCDD10",
|
||||||
|
ciphertext = "34C8A5FB2D3D08A170D120AC6D26DBFA"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "6600522E97AEB3094ED5F92AFCBCDD10",
|
||||||
|
plaintext = "34C8A5FB2D3D08A170D120AC6D26DBFA",
|
||||||
|
ciphertext = "28530B358C1B42EF277DE6D4407FC591"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "34C8A5FB2D3D08A170D120AC6D26DBFA",
|
||||||
|
plaintext = "28530B358C1B42EF277DE6D4407FC591",
|
||||||
|
ciphertext = "8A8AB983310ED78C8C0ECDE030B8DCA4"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "28530B358C1B42EF277DE6D4407FC591",
|
||||||
|
plaintext = "8A8AB983310ED78C8C0ECDE030B8DCA4",
|
||||||
|
ciphertext = "48C758A6DFC1DD8B259FA165E1CE2B3C"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "8A8AB983310ED78C8C0ECDE030B8DCA4",
|
||||||
|
plaintext = "48C758A6DFC1DD8B259FA165E1CE2B3C",
|
||||||
|
ciphertext = "CE73C65C101680BBC251C5C16ABCF214"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "48C758A6DFC1DD8B259FA165E1CE2B3C",
|
||||||
|
plaintext = "CE73C65C101680BBC251C5C16ABCF214",
|
||||||
|
ciphertext = "C7ABD74AA060F78B244E24C71342BA89"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "CE73C65C101680BBC251C5C16ABCF214",
|
||||||
|
plaintext = "C7ABD74AA060F78B244E24C71342BA89",
|
||||||
|
ciphertext = "D0F8B3B6409EBCB666D29C916565ABFC"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "C7ABD74AA060F78B244E24C71342BA89",
|
||||||
|
plaintext = "D0F8B3B6409EBCB666D29C916565ABFC",
|
||||||
|
ciphertext = "DD42662908070054544FE09DA4263130"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "D0F8B3B6409EBCB666D29C916565ABFC",
|
||||||
|
plaintext = "DD42662908070054544FE09DA4263130",
|
||||||
|
ciphertext = "7007BACB42F7BF989CF30F78BC50EDCA"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "DD42662908070054544FE09DA4263130",
|
||||||
|
plaintext = "7007BACB42F7BF989CF30F78BC50EDCA",
|
||||||
|
ciphertext = "57B9A18EE97D90F435A16F69F0AC6F16"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "7007BACB42F7BF989CF30F78BC50EDCA",
|
||||||
|
plaintext = "57B9A18EE97D90F435A16F69F0AC6F16",
|
||||||
|
ciphertext = "06181F0D53267ABD8F3BB28455B198AD"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "57B9A18EE97D90F435A16F69F0AC6F16",
|
||||||
|
plaintext = "06181F0D53267ABD8F3BB28455B198AD",
|
||||||
|
ciphertext = "81A12D8449E9040BAAE7196338D8C8F2"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "06181F0D53267ABD8F3BB28455B198AD",
|
||||||
|
plaintext = "81A12D8449E9040BAAE7196338D8C8F2",
|
||||||
|
ciphertext = "BE422651C56F2622DA0201815A95A820"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "81A12D8449E9040BAAE7196338D8C8F2",
|
||||||
|
plaintext = "BE422651C56F2622DA0201815A95A820",
|
||||||
|
ciphertext = "113B19F2D778473990480CEE4DA238D1"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "BE422651C56F2622DA0201815A95A820",
|
||||||
|
plaintext = "113B19F2D778473990480CEE4DA238D1",
|
||||||
|
ciphertext = "E6942E9A86E544CF3E3364F20BE011DF"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "113B19F2D778473990480CEE4DA238D1",
|
||||||
|
plaintext = "E6942E9A86E544CF3E3364F20BE011DF",
|
||||||
|
ciphertext = "87CDC6AA487BFD0EA70188257D9B3859"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "E6942E9A86E544CF3E3364F20BE011DF",
|
||||||
|
plaintext = "87CDC6AA487BFD0EA70188257D9B3859",
|
||||||
|
ciphertext = "D5E2701253DD75A11A4CFB243714BD14"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "87CDC6AA487BFD0EA70188257D9B3859",
|
||||||
|
plaintext = "D5E2701253DD75A11A4CFB243714BD14",
|
||||||
|
ciphertext = "FD24812EEA107A9E6FAB8EABE0F0F48C"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "D5E2701253DD75A11A4CFB243714BD14",
|
||||||
|
plaintext = "FD24812EEA107A9E6FAB8EABE0F0F48C",
|
||||||
|
ciphertext = "DAFA84E31A297F372C3A807100CD783D"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "FD24812EEA107A9E6FAB8EABE0F0F48C",
|
||||||
|
plaintext = "DAFA84E31A297F372C3A807100CD783D",
|
||||||
|
ciphertext = "A55ED2D955EC8950FC0CC93B76ACBF91"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "DAFA84E31A297F372C3A807100CD783D",
|
||||||
|
plaintext = "A55ED2D955EC8950FC0CC93B76ACBF91",
|
||||||
|
ciphertext = "2ABEA2A4BF27ABDC6B6F278993264744"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "A55ED2D955EC8950FC0CC93B76ACBF91",
|
||||||
|
plaintext = "2ABEA2A4BF27ABDC6B6F278993264744",
|
||||||
|
ciphertext = "045383E219321D5A4435C0E491E7DE10"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "2ABEA2A4BF27ABDC6B6F278993264744",
|
||||||
|
plaintext = "045383E219321D5A4435C0E491E7DE10",
|
||||||
|
ciphertext = "7460A4CD4F312F32B1C7A94FA004E934"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "045383E219321D5A4435C0E491E7DE10",
|
||||||
|
plaintext = "7460A4CD4F312F32B1C7A94FA004E934",
|
||||||
|
ciphertext = "6BBF9186D32C2C5895649D746566050A"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "7460A4CD4F312F32B1C7A94FA004E934",
|
||||||
|
plaintext = "6BBF9186D32C2C5895649D746566050A",
|
||||||
|
ciphertext = "CDBDD19ACF40B8AC0328C80054266068"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "6BBF9186D32C2C5895649D746566050A",
|
||||||
|
plaintext = "CDBDD19ACF40B8AC0328C80054266068",
|
||||||
|
ciphertext = "1D2836CAE4223EAB5066867A71B1A1C3"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "CDBDD19ACF40B8AC0328C80054266068",
|
||||||
|
plaintext = "1D2836CAE4223EAB5066867A71B1A1C3",
|
||||||
|
ciphertext = "2D7F37121D0D2416D5E2767FF202061B"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "1D2836CAE4223EAB5066867A71B1A1C3",
|
||||||
|
plaintext = "2D7F37121D0D2416D5E2767FF202061B",
|
||||||
|
ciphertext = "D70736D1ABC7427A121CC816CD66D7FF"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "2D7F37121D0D2416D5E2767FF202061B",
|
||||||
|
plaintext = "D70736D1ABC7427A121CC816CD66D7FF",
|
||||||
|
ciphertext = "AC6CA71CBCBEDCC0EA849FB2E9377865"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "D70736D1ABC7427A121CC816CD66D7FF",
|
||||||
|
plaintext = "AC6CA71CBCBEDCC0EA849FB2E9377865",
|
||||||
|
ciphertext = "307265FF145CBBC7104B3E51C6C1D6B4"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "AC6CA71CBCBEDCC0EA849FB2E9377865",
|
||||||
|
plaintext = "307265FF145CBBC7104B3E51C6C1D6B4",
|
||||||
|
ciphertext = "934B7DB4B3544854DBCA81C4C5DE4EB1"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "307265FF145CBBC7104B3E51C6C1D6B4",
|
||||||
|
plaintext = "934B7DB4B3544854DBCA81C4C5DE4EB1",
|
||||||
|
ciphertext = "18759824AD9823D5961F84377D7EAEBF"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "934B7DB4B3544854DBCA81C4C5DE4EB1",
|
||||||
|
plaintext = "18759824AD9823D5961F84377D7EAEBF",
|
||||||
|
ciphertext = "DEDDAC6029B01574D9BABB099DC6CA6C"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "18759824AD9823D5961F84377D7EAEBF",
|
||||||
|
plaintext = "DEDDAC6029B01574D9BABB099DC6CA6C",
|
||||||
|
ciphertext = "5EA82EEA2244DED42CCA2F835D5615DF"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "DEDDAC6029B01574D9BABB099DC6CA6C",
|
||||||
|
plaintext = "5EA82EEA2244DED42CCA2F835D5615DF",
|
||||||
|
ciphertext = "1E3853F7FFA57091771DD8CDEE9414DE"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "5EA82EEA2244DED42CCA2F835D5615DF",
|
||||||
|
plaintext = "1E3853F7FFA57091771DD8CDEE9414DE",
|
||||||
|
ciphertext = "5C2EBBF75D31F30B5EA26EAC8782D8D1"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "1E3853F7FFA57091771DD8CDEE9414DE",
|
||||||
|
plaintext = "5C2EBBF75D31F30B5EA26EAC8782D8D1",
|
||||||
|
ciphertext = "3A3CFA1F13A136C94D76E5FA4A1109FF"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "5C2EBBF75D31F30B5EA26EAC8782D8D1",
|
||||||
|
plaintext = "3A3CFA1F13A136C94D76E5FA4A1109FF",
|
||||||
|
ciphertext = "91630CF96003B8032E695797E313A553"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "3A3CFA1F13A136C94D76E5FA4A1109FF",
|
||||||
|
plaintext = "91630CF96003B8032E695797E313A553",
|
||||||
|
ciphertext = "137A24CA47CD12BE818DF4D2F4355960"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "91630CF96003B8032E695797E313A553",
|
||||||
|
plaintext = "137A24CA47CD12BE818DF4D2F4355960",
|
||||||
|
ciphertext = "BCA724A54533C6987E14AA827952F921"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "137A24CA47CD12BE818DF4D2F4355960",
|
||||||
|
plaintext = "BCA724A54533C6987E14AA827952F921",
|
||||||
|
ciphertext = "6B459286F3FFD28D49F15B1581B08E42"
|
||||||
|
),
|
||||||
|
TestVector(
|
||||||
|
key = "BCA724A54533C6987E14AA827952F921",
|
||||||
|
plaintext = "6B459286F3FFD28D49F15B1581B08E42",
|
||||||
|
ciphertext = "5D9D4EEFFA9151575524F115815A12E0"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (testVector in testVectors) {
|
||||||
|
val keyObject = Twofish.processKey(testVector.keyArray)
|
||||||
|
|
||||||
|
val computedCiphertext = Twofish.blockEncrypt(testVector.plaintextArray, 0, keyObject)
|
||||||
|
assertEquals(testVector.ciphertextArray.toList(), computedCiphertext.toList())
|
||||||
|
|
||||||
|
val computedPlaintext = Twofish.blockDecrypt(testVector.ciphertextArray, 0, keyObject)
|
||||||
|
assertEquals(testVector.plaintextArray.toList(), computedPlaintext.toList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
package info.nightscout.comboctl.base.testUtils
|
||||||
|
|
||||||
|
import info.nightscout.comboctl.base.BluetoothAddress
|
||||||
|
import info.nightscout.comboctl.base.BluetoothDevice
|
||||||
|
import info.nightscout.comboctl.base.ComboFrameParser
|
||||||
|
import info.nightscout.comboctl.base.ComboIO
|
||||||
|
import info.nightscout.comboctl.base.ProgressReporter
|
||||||
|
import info.nightscout.comboctl.base.byteArrayListOfInts
|
||||||
|
import info.nightscout.comboctl.base.toComboFrame
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
class TestBluetoothDevice(private val testComboIO: ComboIO) : BluetoothDevice(Dispatchers.IO) {
|
||||||
|
private val frameParser = ComboFrameParser()
|
||||||
|
private var innerJob = SupervisorJob()
|
||||||
|
private var innerScope = CoroutineScope(innerJob)
|
||||||
|
|
||||||
|
override val address: BluetoothAddress = BluetoothAddress(byteArrayListOfInts(1, 2, 3, 4, 5, 6))
|
||||||
|
|
||||||
|
override fun connect() {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun disconnect() {
|
||||||
|
frameParser.reset()
|
||||||
|
runBlocking {
|
||||||
|
innerJob.cancelAndJoin()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reinitialize these, since once a Job is cancelled, it cannot be reused again.
|
||||||
|
innerJob = SupervisorJob()
|
||||||
|
innerScope = CoroutineScope(innerJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unpair() {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun blockingSend(dataToSend: List<Byte>) {
|
||||||
|
frameParser.pushData(dataToSend)
|
||||||
|
frameParser.parseFrame()?.let {
|
||||||
|
runBlocking {
|
||||||
|
innerScope.async {
|
||||||
|
testComboIO.send(it)
|
||||||
|
}.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun blockingReceive(): List<Byte> = runBlocking {
|
||||||
|
innerScope.async {
|
||||||
|
val retval = testComboIO.receive().toComboFrame()
|
||||||
|
retval
|
||||||
|
}.await()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
package info.nightscout.comboctl.base.testUtils
|
||||||
|
|
||||||
|
import info.nightscout.comboctl.base.ApplicationLayer
|
||||||
|
import info.nightscout.comboctl.base.Cipher
|
||||||
|
import info.nightscout.comboctl.base.ComboIO
|
||||||
|
import info.nightscout.comboctl.base.TransportLayer
|
||||||
|
import info.nightscout.comboctl.base.byteArrayListOfInts
|
||||||
|
import info.nightscout.comboctl.base.toTransportLayerPacket
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
|
||||||
|
class TestComboIO : ComboIO {
|
||||||
|
val sentPacketData = newTestPacketSequence()
|
||||||
|
var incomingPacketDataChannel = Channel<TestPacketData>(Channel.UNLIMITED)
|
||||||
|
|
||||||
|
var respondToRTKeypressWithConfirmation = false
|
||||||
|
var pumpClientCipher: Cipher? = null
|
||||||
|
|
||||||
|
override suspend fun send(dataToSend: TestPacketData) {
|
||||||
|
sentPacketData.add(dataToSend)
|
||||||
|
|
||||||
|
if (respondToRTKeypressWithConfirmation) {
|
||||||
|
assertNotNull(pumpClientCipher)
|
||||||
|
val tpLayerPacket = dataToSend.toTransportLayerPacket()
|
||||||
|
if (tpLayerPacket.command == TransportLayer.Command.DATA) {
|
||||||
|
try {
|
||||||
|
// Not using toAppLayerPacket() here, since that one
|
||||||
|
// performs error checks, which are only useful for
|
||||||
|
// application layer packets that we _received_.
|
||||||
|
val appLayerPacket = ApplicationLayer.Packet(tpLayerPacket)
|
||||||
|
if (appLayerPacket.command == ApplicationLayer.Command.RT_BUTTON_STATUS) {
|
||||||
|
feedIncomingData(
|
||||||
|
produceTpLayerPacket(
|
||||||
|
ApplicationLayer.Packet(
|
||||||
|
command = ApplicationLayer.Command.RT_BUTTON_CONFIRMATION,
|
||||||
|
payload = byteArrayListOfInts(0, 0)
|
||||||
|
).toTransportLayerPacketInfo(),
|
||||||
|
pumpClientCipher!!
|
||||||
|
).toByteList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (ignored: ApplicationLayer.ErrorCodeException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun receive(): TestPacketData =
|
||||||
|
incomingPacketDataChannel.receive()
|
||||||
|
|
||||||
|
suspend fun feedIncomingData(dataToFeed: TestPacketData) =
|
||||||
|
incomingPacketDataChannel.send(dataToFeed)
|
||||||
|
|
||||||
|
fun resetSentPacketData() = sentPacketData.clear()
|
||||||
|
|
||||||
|
fun resetIncomingPacketDataChannel() {
|
||||||
|
incomingPacketDataChannel.close()
|
||||||
|
incomingPacketDataChannel = Channel(Channel.UNLIMITED)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
package info.nightscout.comboctl.base.testUtils
|
||||||
|
|
||||||
|
import info.nightscout.comboctl.base.ApplicationLayer
|
||||||
|
import info.nightscout.comboctl.base.TransportLayer
|
||||||
|
import info.nightscout.comboctl.base.toTransportLayerPacket
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
typealias TestPacketData = List<Byte>
|
||||||
|
|
||||||
|
fun newTestPacketSequence() = mutableListOf<TestPacketData>()
|
||||||
|
|
||||||
|
sealed class TestRefPacketItem {
|
||||||
|
data class TransportLayerPacketItem(val packetInfo: TransportLayer.OutgoingPacketInfo) : TestRefPacketItem()
|
||||||
|
data class ApplicationLayerPacketItem(val packet: ApplicationLayer.Packet) : TestRefPacketItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkTestPacketSequence(referenceSequence: List<TestRefPacketItem>, testPacketSequence: List<TestPacketData>) {
|
||||||
|
assertTrue(testPacketSequence.size >= referenceSequence.size)
|
||||||
|
|
||||||
|
referenceSequence.zip(testPacketSequence) { referenceItem, tpLayerPacketData ->
|
||||||
|
val testTpLayerPacket = tpLayerPacketData.toTransportLayerPacket()
|
||||||
|
|
||||||
|
when (referenceItem) {
|
||||||
|
is TestRefPacketItem.TransportLayerPacketItem -> {
|
||||||
|
val refPacketInfo = referenceItem.packetInfo
|
||||||
|
assertEquals(refPacketInfo.command, testTpLayerPacket.command, "Transport layer packet command mismatch")
|
||||||
|
assertEquals(refPacketInfo.payload, testTpLayerPacket.payload, "Transport layer packet payload mismatch")
|
||||||
|
assertEquals(refPacketInfo.reliable, testTpLayerPacket.reliabilityBit, "Transport layer packet reliability bit mismatch")
|
||||||
|
}
|
||||||
|
is TestRefPacketItem.ApplicationLayerPacketItem -> {
|
||||||
|
val refAppLayerPacket = referenceItem.packet
|
||||||
|
val testAppLayerPacket = ApplicationLayer.Packet(testTpLayerPacket)
|
||||||
|
assertEquals(refAppLayerPacket, testAppLayerPacket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
package info.nightscout.comboctl.base.testUtils
|
||||||
|
|
||||||
|
import info.nightscout.comboctl.base.BluetoothAddress
|
||||||
|
import info.nightscout.comboctl.base.CurrentTbrState
|
||||||
|
import info.nightscout.comboctl.base.InvariantPumpData
|
||||||
|
import info.nightscout.comboctl.base.NUM_NONCE_BYTES
|
||||||
|
import info.nightscout.comboctl.base.Nonce
|
||||||
|
import info.nightscout.comboctl.base.PumpStateAlreadyExistsException
|
||||||
|
import info.nightscout.comboctl.base.PumpStateDoesNotExistException
|
||||||
|
import info.nightscout.comboctl.base.PumpStateStore
|
||||||
|
import kotlinx.datetime.UtcOffset
|
||||||
|
|
||||||
|
class TestPumpStateStore : PumpStateStore {
|
||||||
|
data class Entry(
|
||||||
|
val invariantPumpData: InvariantPumpData,
|
||||||
|
var currentTxNonce: Nonce,
|
||||||
|
var currentUtcOffset: UtcOffset,
|
||||||
|
var currentTbrState: CurrentTbrState
|
||||||
|
)
|
||||||
|
|
||||||
|
var states = mutableMapOf<BluetoothAddress, Entry>()
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun createPumpState(
|
||||||
|
pumpAddress: BluetoothAddress,
|
||||||
|
invariantPumpData: InvariantPumpData,
|
||||||
|
utcOffset: UtcOffset,
|
||||||
|
tbrState: CurrentTbrState
|
||||||
|
) {
|
||||||
|
if (states.contains(pumpAddress))
|
||||||
|
throw PumpStateAlreadyExistsException(pumpAddress)
|
||||||
|
|
||||||
|
states[pumpAddress] = Entry(invariantPumpData, Nonce(List(NUM_NONCE_BYTES) { 0x00 }), utcOffset, tbrState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deletePumpState(pumpAddress: BluetoothAddress) =
|
||||||
|
if (states.contains(pumpAddress)) {
|
||||||
|
states.remove(pumpAddress)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hasPumpState(pumpAddress: BluetoothAddress): Boolean =
|
||||||
|
states.contains(pumpAddress)
|
||||||
|
|
||||||
|
override fun getAvailablePumpStateAddresses(): Set<BluetoothAddress> = states.keys
|
||||||
|
|
||||||
|
override fun getInvariantPumpData(pumpAddress: BluetoothAddress): InvariantPumpData {
|
||||||
|
if (!states.contains(pumpAddress))
|
||||||
|
throw PumpStateDoesNotExistException(pumpAddress)
|
||||||
|
return states[pumpAddress]!!.invariantPumpData
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCurrentTxNonce(pumpAddress: BluetoothAddress): Nonce {
|
||||||
|
if (!states.contains(pumpAddress))
|
||||||
|
throw PumpStateDoesNotExistException(pumpAddress)
|
||||||
|
return states[pumpAddress]!!.currentTxNonce
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setCurrentTxNonce(pumpAddress: BluetoothAddress, currentTxNonce: Nonce) {
|
||||||
|
if (!states.contains(pumpAddress))
|
||||||
|
throw PumpStateDoesNotExistException(pumpAddress)
|
||||||
|
states[pumpAddress]!!.currentTxNonce = currentTxNonce
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCurrentUtcOffset(pumpAddress: BluetoothAddress): UtcOffset {
|
||||||
|
if (!states.contains(pumpAddress))
|
||||||
|
throw PumpStateDoesNotExistException(pumpAddress)
|
||||||
|
return states[pumpAddress]!!.currentUtcOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setCurrentUtcOffset(pumpAddress: BluetoothAddress, utcOffset: UtcOffset) {
|
||||||
|
if (!states.contains(pumpAddress))
|
||||||
|
throw PumpStateDoesNotExistException(pumpAddress)
|
||||||
|
states[pumpAddress]!!.currentUtcOffset = utcOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCurrentTbrState(pumpAddress: BluetoothAddress): CurrentTbrState {
|
||||||
|
if (!states.contains(pumpAddress))
|
||||||
|
throw PumpStateDoesNotExistException(pumpAddress)
|
||||||
|
return states[pumpAddress]!!.currentTbrState
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setCurrentTbrState(pumpAddress: BluetoothAddress, currentTbrState: CurrentTbrState) {
|
||||||
|
if (!states.contains(pumpAddress))
|
||||||
|
throw PumpStateDoesNotExistException(pumpAddress)
|
||||||
|
states[pumpAddress]!!.currentTbrState = currentTbrState
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,98 @@
|
||||||
|
package info.nightscout.comboctl.base.testUtils
|
||||||
|
|
||||||
|
import info.nightscout.comboctl.base.Cipher
|
||||||
|
import info.nightscout.comboctl.base.ComboException
|
||||||
|
import info.nightscout.comboctl.base.Nonce
|
||||||
|
import info.nightscout.comboctl.base.TransportLayer
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
import kotlin.test.fail
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
// Utility function to combine runBlocking() with a watchdog.
|
||||||
|
// A coroutine is started with runBlocking(), and inside that
|
||||||
|
// coroutine, sub-coroutines are spawned. One of them runs
|
||||||
|
// the supplied block, the other implements a watchdog by
|
||||||
|
// waiting with delay(). If delay() runs out, the watchdog
|
||||||
|
// is considered to have timed out, and failure is reported.
|
||||||
|
// The watchdog is disabled after the supplied block finished
|
||||||
|
// running. That way, if something in that block suspends
|
||||||
|
// coroutines indefinitely, the watchdog will make sure that
|
||||||
|
// the test does not hang permanently.
|
||||||
|
fun runBlockingWithWatchdog(
|
||||||
|
timeout: Long,
|
||||||
|
context: CoroutineContext = EmptyCoroutineContext,
|
||||||
|
block: suspend CoroutineScope.() -> Unit
|
||||||
|
) {
|
||||||
|
runBlocking(context) {
|
||||||
|
val watchdogJob = launch {
|
||||||
|
delay(timeout)
|
||||||
|
fail("Test run timeout reached")
|
||||||
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
try {
|
||||||
|
// Call the block with the current CoroutineScope
|
||||||
|
// as the receiver to allow code inside that block
|
||||||
|
// to access the CoroutineScope via the "this" value.
|
||||||
|
// This is important, otherwise test code cannot
|
||||||
|
// launch coroutines easily.
|
||||||
|
this.block()
|
||||||
|
} finally {
|
||||||
|
// Disabling the watchdog here makes sure
|
||||||
|
// that it is disabled no matter if the block
|
||||||
|
// finishes regularly or due to an exception.
|
||||||
|
watchdogJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WatchdogTimeoutException(message: String) : ComboException(message)
|
||||||
|
|
||||||
|
suspend fun coroutineScopeWithWatchdog(
|
||||||
|
timeout: Long,
|
||||||
|
block: suspend CoroutineScope.() -> Unit
|
||||||
|
) {
|
||||||
|
coroutineScope {
|
||||||
|
val watchdogJob = launch {
|
||||||
|
delay(timeout)
|
||||||
|
throw WatchdogTimeoutException("Test run timeout reached")
|
||||||
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
try {
|
||||||
|
// Call the block with the current CoroutineScope
|
||||||
|
// as the receiver to allow code inside that block
|
||||||
|
// to access the CoroutineScope via the "this" value.
|
||||||
|
// This is important, otherwise test code cannot
|
||||||
|
// launch coroutines easily.
|
||||||
|
this.block()
|
||||||
|
} finally {
|
||||||
|
// Disabling the watchdog here makes sure
|
||||||
|
// that it is disabled no matter if the block
|
||||||
|
// finishes regularly or due to an exception.
|
||||||
|
watchdogJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun produceTpLayerPacket(outgoingPacketInfo: TransportLayer.OutgoingPacketInfo, cipher: Cipher): TransportLayer.Packet {
|
||||||
|
val packet = TransportLayer.Packet(
|
||||||
|
command = outgoingPacketInfo.command,
|
||||||
|
sequenceBit = false,
|
||||||
|
reliabilityBit = false,
|
||||||
|
address = 0x01,
|
||||||
|
nonce = Nonce.nullNonce(),
|
||||||
|
payload = outgoingPacketInfo.payload
|
||||||
|
)
|
||||||
|
|
||||||
|
packet.authenticate(cipher)
|
||||||
|
|
||||||
|
return packet
|
||||||
|
}
|
|
@ -0,0 +1,366 @@
|
||||||
|
package info.nightscout.comboctl.main
|
||||||
|
|
||||||
|
import info.nightscout.comboctl.base.DisplayFrame
|
||||||
|
import info.nightscout.comboctl.base.LogLevel
|
||||||
|
import info.nightscout.comboctl.base.Logger
|
||||||
|
import info.nightscout.comboctl.base.NUM_DISPLAY_FRAME_PIXELS
|
||||||
|
import info.nightscout.comboctl.base.timeWithoutDate
|
||||||
|
import info.nightscout.comboctl.parser.AlertScreenContent
|
||||||
|
import info.nightscout.comboctl.parser.AlertScreenException
|
||||||
|
import info.nightscout.comboctl.parser.BatteryState
|
||||||
|
import info.nightscout.comboctl.parser.MainScreenContent
|
||||||
|
import info.nightscout.comboctl.parser.ParsedScreen
|
||||||
|
import info.nightscout.comboctl.parser.testFrameMainScreenWithTimeSeparator
|
||||||
|
import info.nightscout.comboctl.parser.testFrameMainScreenWithoutTimeSeparator
|
||||||
|
import info.nightscout.comboctl.parser.testFrameStandardBolusMenuScreen
|
||||||
|
import info.nightscout.comboctl.parser.testFrameTbrDurationEnglishScreen
|
||||||
|
import info.nightscout.comboctl.parser.testFrameTemporaryBasalRateNoPercentageScreen
|
||||||
|
import info.nightscout.comboctl.parser.testFrameTemporaryBasalRatePercentage110Screen
|
||||||
|
import info.nightscout.comboctl.parser.testFrameW6CancelTbrWarningScreen
|
||||||
|
import info.nightscout.comboctl.parser.testTimeAndDateSettingsHourPolishScreen
|
||||||
|
import info.nightscout.comboctl.parser.testTimeAndDateSettingsHourRussianScreen
|
||||||
|
import info.nightscout.comboctl.parser.testTimeAndDateSettingsHourTurkishScreen
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.jupiter.api.BeforeAll
|
||||||
|
|
||||||
|
class ParsedDisplayFrameStreamTest {
|
||||||
|
companion object {
|
||||||
|
@BeforeAll
|
||||||
|
@JvmStatic
|
||||||
|
fun commonInit() {
|
||||||
|
Logger.threshold = LogLevel.VERBOSE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkSingleDisplayFrame() = runBlocking {
|
||||||
|
/* Check if a frame is correctly recognized. */
|
||||||
|
|
||||||
|
val stream = ParsedDisplayFrameStream()
|
||||||
|
stream.feedDisplayFrame(testFrameStandardBolusMenuScreen)
|
||||||
|
val parsedFrame = stream.getParsedDisplayFrame()
|
||||||
|
assertNotNull(parsedFrame)
|
||||||
|
assertEquals(ParsedScreen.StandardBolusMenuScreen, parsedFrame.parsedScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkNullDisplayFrame() = runBlocking {
|
||||||
|
/* Check if a null frame is handled correctly. */
|
||||||
|
|
||||||
|
val stream = ParsedDisplayFrameStream()
|
||||||
|
stream.feedDisplayFrame(null)
|
||||||
|
val parsedFrame = stream.getParsedDisplayFrame()
|
||||||
|
assertNull(parsedFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkDuplicateDisplayFrameFiltering() = runBlocking {
|
||||||
|
// Test the duplicate detection by feeding the stream test frames
|
||||||
|
// along with unrecognizable ones. We feed duplicates, both recognizable
|
||||||
|
// and unrecognizable ones, to check that the stream filters these out.
|
||||||
|
|
||||||
|
val unrecognizableDisplayFrame1A = DisplayFrame(BooleanArray(NUM_DISPLAY_FRAME_PIXELS) { false })
|
||||||
|
val unrecognizableDisplayFrame1B = DisplayFrame(BooleanArray(NUM_DISPLAY_FRAME_PIXELS) { false })
|
||||||
|
val unrecognizableDisplayFrame2 = DisplayFrame(BooleanArray(NUM_DISPLAY_FRAME_PIXELS) { true })
|
||||||
|
|
||||||
|
val displayFrameList = listOf(
|
||||||
|
// We use these two frames to test out the filtering
|
||||||
|
// of duplicate frames. These two frame _are_ equal.
|
||||||
|
// The frames just differ in the time separator, but
|
||||||
|
// both result in ParsedScreen.NormalMainScreen instances
|
||||||
|
// with the same semantics (same time etc). We expect the
|
||||||
|
// stream to recognize and filter out the duplicate.
|
||||||
|
testFrameMainScreenWithTimeSeparator,
|
||||||
|
testFrameMainScreenWithoutTimeSeparator,
|
||||||
|
// 1A and 1B are two different unrecognizable DisplayFrame
|
||||||
|
// instances with equal pixel content to test the filtering
|
||||||
|
// of duplicate frames when said frames are _not_ recognizable
|
||||||
|
// by the parser. The stream should then compare the frames
|
||||||
|
// pixel by pixel.
|
||||||
|
unrecognizableDisplayFrame1A,
|
||||||
|
unrecognizableDisplayFrame1B,
|
||||||
|
// Frame 2 is an unrecognizable DisplayFrame whose pixels
|
||||||
|
// are different than the ones in frames 1A and 1B. We
|
||||||
|
// expect the stream to do a pixel-by-pixel comparison between
|
||||||
|
// the unrecognizable frames and detect that frame 2 is
|
||||||
|
// really different (= not a duplicate).
|
||||||
|
unrecognizableDisplayFrame2,
|
||||||
|
// A recognizable frame to test the case that a recognizable
|
||||||
|
// frame follows an unrecognizable one.
|
||||||
|
testFrameStandardBolusMenuScreen
|
||||||
|
)
|
||||||
|
|
||||||
|
val parsedFrameList = mutableListOf<ParsedDisplayFrame>()
|
||||||
|
val stream = ParsedDisplayFrameStream()
|
||||||
|
|
||||||
|
coroutineScope {
|
||||||
|
val producerJob = launch {
|
||||||
|
for (displayFrame in displayFrameList) {
|
||||||
|
// Wait here until the frame has been retrieved, since otherwise,
|
||||||
|
// the feedDisplayFrame() call below would overwrite the already
|
||||||
|
// stored frame.
|
||||||
|
while (stream.hasStoredDisplayFrame())
|
||||||
|
delay(100)
|
||||||
|
stream.feedDisplayFrame(displayFrame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
while (true) {
|
||||||
|
val parsedFrame = stream.getParsedDisplayFrame(filterDuplicates = true)
|
||||||
|
assertNotNull(parsedFrame)
|
||||||
|
parsedFrameList.add(parsedFrame)
|
||||||
|
if (parsedFrameList.size >= 4)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
producerJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsedFrameIter = parsedFrameList.listIterator()
|
||||||
|
|
||||||
|
// We expect _one_ ParsedScreen.NormalMainScreen
|
||||||
|
// (the other frame with the equal content must be filtered out).
|
||||||
|
assertEquals(
|
||||||
|
ParsedScreen.MainScreen(
|
||||||
|
MainScreenContent.Normal(
|
||||||
|
currentTime = timeWithoutDate(hour = 10, minute = 20),
|
||||||
|
activeBasalProfileNumber = 1,
|
||||||
|
currentBasalRateFactor = 200,
|
||||||
|
batteryState = BatteryState.FULL_BATTERY
|
||||||
|
)
|
||||||
|
),
|
||||||
|
parsedFrameIter.next().parsedScreen
|
||||||
|
)
|
||||||
|
// Next we expect an UnrecognizedScreen result after the change from NormalMainScreen
|
||||||
|
// to a frame (unrecognizableDisplayFrame1A) that could not be recognized.
|
||||||
|
assertEquals(
|
||||||
|
ParsedScreen.UnrecognizedScreen,
|
||||||
|
parsedFrameIter.next().parsedScreen
|
||||||
|
)
|
||||||
|
// We expect an UnrecognizedScreen result after switching to unrecognizableDisplayFrame2.
|
||||||
|
// This is an unrecognizable frame that differs in its pixels from
|
||||||
|
// unrecognizableDisplayFrame1A and 1B. Importantly, 1B must have been
|
||||||
|
// filtered out, since both 1A and 1B could not be recognized _and_ have
|
||||||
|
// equal pixel content.
|
||||||
|
assertEquals(
|
||||||
|
ParsedScreen.UnrecognizedScreen,
|
||||||
|
parsedFrameIter.next().parsedScreen
|
||||||
|
)
|
||||||
|
// Since unrecognizableDisplayFrame1B must have been filtered out,
|
||||||
|
// the next result we expect is the StandardBolusMenuScreen.
|
||||||
|
assertEquals(
|
||||||
|
ParsedScreen.StandardBolusMenuScreen,
|
||||||
|
parsedFrameIter.next().parsedScreen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkDuplicateParsedScreenFiltering() = runBlocking {
|
||||||
|
// Test the duplicate parsed screen detection with 3 time and date hour settings screens.
|
||||||
|
// All three are parsed to ParsedScreen.TimeAndDateSettingsHourScreen instances.
|
||||||
|
// All three contain different pixels. (This is the crucial difference to the
|
||||||
|
// checkDuplicateDisplayFrameFiltering above.) However, the first 2 have their "hour"
|
||||||
|
// properties set to 13, while the third has "hour" set to 14. The stream is
|
||||||
|
// expected to filter the duplicate TimeAndDateSettingsHourScreen with the "13" hour.
|
||||||
|
|
||||||
|
val displayFrameList = listOf(
|
||||||
|
testTimeAndDateSettingsHourRussianScreen, // This screen frame has "1 PM" (= 13 in 24h format) as hour
|
||||||
|
testTimeAndDateSettingsHourTurkishScreen, // This screen frame has "1 PM" (= 13 in 24h format) as hour
|
||||||
|
testTimeAndDateSettingsHourPolishScreen // This screen frame has "2 PM" (= 13 in 24h format) as hour
|
||||||
|
)
|
||||||
|
|
||||||
|
val parsedFrameList = mutableListOf<ParsedDisplayFrame>()
|
||||||
|
val stream = ParsedDisplayFrameStream()
|
||||||
|
|
||||||
|
coroutineScope {
|
||||||
|
val producerJob = launch {
|
||||||
|
for (displayFrame in displayFrameList) {
|
||||||
|
// Wait here until the frame has been retrieved, since otherwise,
|
||||||
|
// the feedDisplayFrame() call below would overwrite the already
|
||||||
|
// stored frame.
|
||||||
|
while (stream.hasStoredDisplayFrame())
|
||||||
|
delay(100)
|
||||||
|
stream.feedDisplayFrame(displayFrame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
while (true) {
|
||||||
|
val parsedFrame = stream.getParsedDisplayFrame(filterDuplicates = true)
|
||||||
|
assertNotNull(parsedFrame)
|
||||||
|
parsedFrameList.add(parsedFrame)
|
||||||
|
if (parsedFrameList.size >= 2)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
producerJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsedFrameIter = parsedFrameList.listIterator()
|
||||||
|
|
||||||
|
assertEquals(2, parsedFrameList.size)
|
||||||
|
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 13), parsedFrameIter.next().parsedScreen)
|
||||||
|
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 14), parsedFrameIter.next().parsedScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkDuplicateDetectionReset() = runBlocking {
|
||||||
|
// Test that resetting the duplicate detection works correctly.
|
||||||
|
|
||||||
|
// Two screens with equal content (both are a TimeAndDateSettingsHourScreen
|
||||||
|
// with the hour set to 13, or 1 PM). Duplicate detection would normally
|
||||||
|
// filter out the second one. The resetDuplicate() call should prevent this.
|
||||||
|
val displayFrameList = listOf(
|
||||||
|
testTimeAndDateSettingsHourRussianScreen,
|
||||||
|
testTimeAndDateSettingsHourTurkishScreen
|
||||||
|
)
|
||||||
|
|
||||||
|
val stream = ParsedDisplayFrameStream()
|
||||||
|
|
||||||
|
val parsedFrameList = mutableListOf<ParsedDisplayFrame>()
|
||||||
|
for (displayFrame in displayFrameList) {
|
||||||
|
stream.resetDuplicate()
|
||||||
|
stream.feedDisplayFrame(displayFrame)
|
||||||
|
val parsedFrame = stream.getParsedDisplayFrame()
|
||||||
|
assertNotNull(parsedFrame)
|
||||||
|
parsedFrameList.add(parsedFrame)
|
||||||
|
}
|
||||||
|
val parsedFrameIter = parsedFrameList.listIterator()
|
||||||
|
|
||||||
|
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 13), parsedFrameIter.next().parsedScreen)
|
||||||
|
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 13), parsedFrameIter.next().parsedScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkDisabledDuplicateDetection() = runBlocking {
|
||||||
|
// Test that getting frames with disabled duplicate detection works correctly.
|
||||||
|
|
||||||
|
// Two screens with equal content (both are a TimeAndDateSettingsHourScreen
|
||||||
|
// with the hour set to 13, or 1 PM). Duplicate detection would normally
|
||||||
|
// filter out the second one.
|
||||||
|
val displayFrameList = listOf(
|
||||||
|
testTimeAndDateSettingsHourRussianScreen,
|
||||||
|
testTimeAndDateSettingsHourTurkishScreen
|
||||||
|
)
|
||||||
|
|
||||||
|
val parsedFrameList = mutableListOf<ParsedDisplayFrame>()
|
||||||
|
val stream = ParsedDisplayFrameStream()
|
||||||
|
|
||||||
|
coroutineScope {
|
||||||
|
val producerJob = launch {
|
||||||
|
for (displayFrame in displayFrameList) {
|
||||||
|
// Wait here until the frame has been retrieved, since otherwise,
|
||||||
|
// the feedDisplayFrame() call below would overwrite the already
|
||||||
|
// stored frame.
|
||||||
|
while (stream.hasStoredDisplayFrame())
|
||||||
|
delay(100)
|
||||||
|
stream.feedDisplayFrame(displayFrame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
while (true) {
|
||||||
|
val parsedFrame = stream.getParsedDisplayFrame(filterDuplicates = false)
|
||||||
|
assertNotNull(parsedFrame)
|
||||||
|
parsedFrameList.add(parsedFrame)
|
||||||
|
if (parsedFrameList.size >= 2)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
producerJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsedFrameIter = parsedFrameList.listIterator()
|
||||||
|
|
||||||
|
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 13), parsedFrameIter.next().parsedScreen)
|
||||||
|
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 13), parsedFrameIter.next().parsedScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkAlertScreenDetection() = runBlocking {
|
||||||
|
// Test that alert screens are detected and handled correctly.
|
||||||
|
|
||||||
|
val stream = ParsedDisplayFrameStream()
|
||||||
|
|
||||||
|
// Feed some dummy non-alert screen first to see that such a
|
||||||
|
// screen does not mess up the alert screen detection logic.
|
||||||
|
// We expect normal parsing behavior.
|
||||||
|
stream.feedDisplayFrame(testTimeAndDateSettingsHourRussianScreen)
|
||||||
|
val parsedFirstFrame = stream.getParsedDisplayFrame()
|
||||||
|
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 13), parsedFirstFrame!!.parsedScreen)
|
||||||
|
|
||||||
|
// Feed a W6 screen, but with alert screen detection disabled.
|
||||||
|
// We expect normal parsing behavior.
|
||||||
|
stream.feedDisplayFrame(testFrameW6CancelTbrWarningScreen)
|
||||||
|
val parsedWarningFrame = stream.getParsedDisplayFrame(processAlertScreens = false)
|
||||||
|
assertEquals(ParsedScreen.AlertScreen(AlertScreenContent.Warning(6)), parsedWarningFrame!!.parsedScreen)
|
||||||
|
|
||||||
|
// Feed a W6 screen, but with alert screen detection enabled.
|
||||||
|
// We expect the alert screen to be detected and an exception
|
||||||
|
// to be thrown as a result.
|
||||||
|
val alertScreenException = assertFailsWith<AlertScreenException> {
|
||||||
|
stream.feedDisplayFrame(testFrameW6CancelTbrWarningScreen)
|
||||||
|
stream.getParsedDisplayFrame(processAlertScreens = true)
|
||||||
|
}
|
||||||
|
assertIs<AlertScreenContent.Warning>(alertScreenException.alertScreenContent)
|
||||||
|
assertEquals(6, (alertScreenException.alertScreenContent as AlertScreenContent.Warning).code)
|
||||||
|
|
||||||
|
// Feed another dummy non-alert screen to see that the stream
|
||||||
|
// parses correctly even after an AlertScreenException.
|
||||||
|
stream.feedDisplayFrame(testTimeAndDateSettingsHourTurkishScreen)
|
||||||
|
val parsedLastFrame = stream.getParsedDisplayFrame()
|
||||||
|
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 13), parsedLastFrame!!.parsedScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkSkippingBlinkedOutScreens() = runBlocking {
|
||||||
|
// Test that the stream correctly skips blinked-out screens.
|
||||||
|
|
||||||
|
// Produce a test feed of 3 frames, with the second frame being blinked out.
|
||||||
|
// We expect the stream to filter that blinked-out frame.
|
||||||
|
val displayFrameList = listOf(
|
||||||
|
testFrameTemporaryBasalRatePercentage110Screen,
|
||||||
|
testFrameTemporaryBasalRateNoPercentageScreen,
|
||||||
|
testFrameTbrDurationEnglishScreen
|
||||||
|
)
|
||||||
|
|
||||||
|
val parsedFrameList = mutableListOf<ParsedDisplayFrame>()
|
||||||
|
val stream = ParsedDisplayFrameStream()
|
||||||
|
|
||||||
|
coroutineScope {
|
||||||
|
val producerJob = launch {
|
||||||
|
for (displayFrame in displayFrameList) {
|
||||||
|
// Wait here until the frame has been retrieved, since otherwise,
|
||||||
|
// the feedDisplayFrame() call below would overwrite the already
|
||||||
|
// stored frame.
|
||||||
|
while (stream.hasStoredDisplayFrame())
|
||||||
|
delay(100)
|
||||||
|
stream.feedDisplayFrame(displayFrame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
while (true) {
|
||||||
|
val parsedFrame = stream.getParsedDisplayFrame(filterDuplicates = false)
|
||||||
|
assertNotNull(parsedFrame)
|
||||||
|
parsedFrameList.add(parsedFrame)
|
||||||
|
if (parsedFrameList.size >= 2)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
producerJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsedFrameIter = parsedFrameList.listIterator()
|
||||||
|
|
||||||
|
assertEquals(2, parsedFrameList.size)
|
||||||
|
assertEquals(ParsedScreen.TemporaryBasalRatePercentageScreen(percentage = 110), parsedFrameIter.next().parsedScreen)
|
||||||
|
assertEquals(ParsedScreen.TemporaryBasalRateDurationScreen(durationInMinutes = 30), parsedFrameIter.next().parsedScreen)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,781 @@
|
||||||
|
package info.nightscout.comboctl.main
|
||||||
|
|
||||||
|
import info.nightscout.comboctl.base.LogLevel
|
||||||
|
import info.nightscout.comboctl.base.Logger
|
||||||
|
import info.nightscout.comboctl.base.NullDisplayFrame
|
||||||
|
import info.nightscout.comboctl.base.PathSegment
|
||||||
|
import info.nightscout.comboctl.base.findShortestPath
|
||||||
|
import info.nightscout.comboctl.base.testUtils.runBlockingWithWatchdog
|
||||||
|
import info.nightscout.comboctl.parser.AlertScreenException
|
||||||
|
import info.nightscout.comboctl.parser.BatteryState
|
||||||
|
import info.nightscout.comboctl.parser.MainScreenContent
|
||||||
|
import info.nightscout.comboctl.parser.ParsedScreen
|
||||||
|
import info.nightscout.comboctl.parser.Quickinfo
|
||||||
|
import info.nightscout.comboctl.parser.ReservoirState
|
||||||
|
import kotlin.reflect.KClassifier
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import org.junit.jupiter.api.BeforeAll
|
||||||
|
|
||||||
|
class RTNavigationTest {
|
||||||
|
/* RTNavigationContext implementation for testing out RTNavigation functionality.
|
||||||
|
* This simulates the activity of a Combo in RT mode by using a defined list
|
||||||
|
* of ParsedScreen instances. This context runs a coroutine that keeps emitting
|
||||||
|
* screens from the list. Initially, it regularly emits the first ParsedScreen
|
||||||
|
* from the list. If automaticallyAdvanceScreens is true, it will move on to
|
||||||
|
* the next screen after a short while, otherwise it will stay at the same screen
|
||||||
|
* in the testParsedScreenList until a button is pressed. The actual button type
|
||||||
|
* is not evaluated; any button press advances the list iterator and causes the
|
||||||
|
* next screen to be emitted through the SharedFlow. The flow is set up such
|
||||||
|
* that it suspends when there are subscribers to make sure they don't miss
|
||||||
|
* any screens. When there are no subscribers, it just keeps repeating the
|
||||||
|
* same screen, so no screens are missed then either.
|
||||||
|
*
|
||||||
|
* This simulates the Combo's RT mode in the following ways: Often, the screen
|
||||||
|
* doesn't actually change in a meaningful manner (for example when it blinks).
|
||||||
|
* And, in most cases, a real change in the RT screen contents only happens
|
||||||
|
* after user interaction (= a button press). automaticallyAdvanceScreens is
|
||||||
|
* in fact false by default because its behavior is not commonly encountered.
|
||||||
|
* That property is only used when testing waitUntilScreenAppears().
|
||||||
|
*/
|
||||||
|
class TestRTNavigationContext(
|
||||||
|
testParsedScreenList: List<ParsedScreen>,
|
||||||
|
private val automaticallyAdvanceScreens: Boolean = false
|
||||||
|
) : RTNavigationContext {
|
||||||
|
private val mainJob = SupervisorJob()
|
||||||
|
private val mainScope = CoroutineScope(mainJob)
|
||||||
|
private val testParsedScreenListIter = testParsedScreenList.listIterator()
|
||||||
|
private var currentParsedScreen = testParsedScreenListIter.next()
|
||||||
|
private val parsedScreenChannel = Channel<ParsedScreen?>(capacity = Channel.RENDEZVOUS)
|
||||||
|
private var longButtonJob: Job? = null
|
||||||
|
private var lastParsedScreen: ParsedScreen? = null
|
||||||
|
|
||||||
|
val shortPressedRTButtons = mutableListOf<RTNavigationButton>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
mainScope.launch {
|
||||||
|
while (true) {
|
||||||
|
System.err.println("Emitting test screen $currentParsedScreen")
|
||||||
|
parsedScreenChannel.send(currentParsedScreen)
|
||||||
|
delay(100)
|
||||||
|
if (automaticallyAdvanceScreens) {
|
||||||
|
if (testParsedScreenListIter.hasNext())
|
||||||
|
currentParsedScreen = testParsedScreenListIter.next()
|
||||||
|
else
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val maxNumCycleAttempts: Int = 20
|
||||||
|
|
||||||
|
override fun resetDuplicate() {
|
||||||
|
lastParsedScreen = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getParsedDisplayFrame(filterDuplicates: Boolean, processAlertScreens: Boolean): ParsedDisplayFrame? {
|
||||||
|
while (true) {
|
||||||
|
val thisParsedScreen = parsedScreenChannel.receive()
|
||||||
|
|
||||||
|
if (filterDuplicates && (lastParsedScreen != null) && (thisParsedScreen != null)) {
|
||||||
|
if (lastParsedScreen == thisParsedScreen) {
|
||||||
|
currentParsedScreen = testParsedScreenListIter.next()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastParsedScreen = thisParsedScreen
|
||||||
|
|
||||||
|
if ((thisParsedScreen != null) && thisParsedScreen.isBlinkedOut)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (processAlertScreens && (thisParsedScreen != null)) {
|
||||||
|
if (thisParsedScreen is ParsedScreen.AlertScreen)
|
||||||
|
throw AlertScreenException(thisParsedScreen.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return thisParsedScreen?.let { ParsedDisplayFrame(NullDisplayFrame, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun startLongButtonPress(button: RTNavigationButton, keepGoing: (suspend () -> Boolean)?) {
|
||||||
|
longButtonJob = mainScope.launch {
|
||||||
|
while (true) {
|
||||||
|
// The keepGoing() predicate can suspend this coroutine for a while.
|
||||||
|
// This is OK and expected. By definition, every time this predicate
|
||||||
|
// returns true, the long RT button press "keeps going". At each
|
||||||
|
// iteration with a predicate return value being true, the long RT
|
||||||
|
// button press is signaled to the Combo, which induces a change
|
||||||
|
// in the Combo, for example a quantity increment. We simulate this
|
||||||
|
// here by moving to the next screen if keepGoing() returns true.
|
||||||
|
// If keepGoing is not set, this behaves as if keepGoing() always
|
||||||
|
// returned true.
|
||||||
|
if (keepGoing?.let { !it() } ?: false)
|
||||||
|
break
|
||||||
|
currentParsedScreen = testParsedScreenListIter.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stopLongButtonPress() {
|
||||||
|
longButtonJob?.cancelAndJoin()
|
||||||
|
longButtonJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun waitForLongButtonPressToFinish() {
|
||||||
|
longButtonJob?.join()
|
||||||
|
longButtonJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun shortPressButton(button: RTNavigationButton) {
|
||||||
|
// Simulate the consequences of user interaction by moving to the next screen.
|
||||||
|
currentParsedScreen = testParsedScreenListIter.next()
|
||||||
|
System.err.println("Moved to next screen $currentParsedScreen after short button press")
|
||||||
|
shortPressedRTButtons.add(button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@BeforeAll
|
||||||
|
@JvmStatic
|
||||||
|
fun commonInit() {
|
||||||
|
Logger.threshold = LogLevel.VERBOSE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkRTNavigationGraphConnectivity() {
|
||||||
|
// Check the rtNavigationGraph's connectivity. All nodes are
|
||||||
|
// supposed to be connected and reachable from other nodes.
|
||||||
|
|
||||||
|
val screenNodes = rtNavigationGraph.nodes
|
||||||
|
|
||||||
|
for (nodeA in screenNodes.values) {
|
||||||
|
for (nodeB in screenNodes.values) {
|
||||||
|
// Skip this case, since the nodes in this
|
||||||
|
// graph have no self-edges.
|
||||||
|
if (nodeA === nodeB)
|
||||||
|
continue
|
||||||
|
|
||||||
|
val path = rtNavigationGraph.findShortestPath(nodeA, nodeB)
|
||||||
|
assertTrue(path!!.isNotEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkRTNavigationGraphPathFromMainScreenToBasalRateFactorSettingScreen() {
|
||||||
|
val path = findShortestRtPath(
|
||||||
|
ParsedScreen.MainScreen::class,
|
||||||
|
ParsedScreen.BasalRateFactorSettingScreen::class,
|
||||||
|
isComboStopped = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(path)
|
||||||
|
assertEquals(5, path.size)
|
||||||
|
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
|
||||||
|
ParsedScreen.TemporaryBasalRateMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[0])
|
||||||
|
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
|
||||||
|
ParsedScreen.MyDataMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[1])
|
||||||
|
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
|
||||||
|
ParsedScreen.BasalRate1ProgrammingMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[2])
|
||||||
|
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
|
||||||
|
ParsedScreen.BasalRateTotalScreen::class, RTEdgeValue(RTNavigationButton.CHECK)), path[3])
|
||||||
|
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
|
||||||
|
ParsedScreen.BasalRateFactorSettingScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[4])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkRTNavigationGraphPathFromMainScreenToBasalRateFactorSettingScreenWhenStopped() {
|
||||||
|
// The TBR menu is disabled when the Combo is stopped. We expect the
|
||||||
|
// RT navigation to take that into account and find a shortest path
|
||||||
|
// that does not include the TBR menu screen.
|
||||||
|
|
||||||
|
val path = findShortestRtPath(
|
||||||
|
ParsedScreen.MainScreen::class,
|
||||||
|
ParsedScreen.BasalRateFactorSettingScreen::class,
|
||||||
|
isComboStopped = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(path)
|
||||||
|
assertEquals(4, path.size)
|
||||||
|
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
|
||||||
|
ParsedScreen.MyDataMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[0])
|
||||||
|
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
|
||||||
|
ParsedScreen.BasalRate1ProgrammingMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[1])
|
||||||
|
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
|
||||||
|
ParsedScreen.BasalRateTotalScreen::class, RTEdgeValue(RTNavigationButton.CHECK)), path[2])
|
||||||
|
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
|
||||||
|
ParsedScreen.BasalRateFactorSettingScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkComputeShortRTButtonPressWithOneStepSize() {
|
||||||
|
// Test that computeShortRTButtonPress() correctly computes
|
||||||
|
// the number of necessary short RT button presses and figures
|
||||||
|
// out the correct button to press. These are the tests for
|
||||||
|
// increment step arrays with one item.
|
||||||
|
|
||||||
|
var result: Pair<Int, RTNavigationButton>
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 100,
|
||||||
|
targetQuantity = 130,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 3)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair((130 - 100) / 3, RTNavigationButton.UP), result)
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 4000,
|
||||||
|
targetQuantity = 60,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 20)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair((4000 - 60) / 20, RTNavigationButton.DOWN), result)
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 10,
|
||||||
|
targetQuantity = 20,
|
||||||
|
cyclicQuantityRange = 60,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 1)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair((20 - 10) / 1, RTNavigationButton.UP), result)
|
||||||
|
|
||||||
|
// Tests that the cyclic quantity range is respected.
|
||||||
|
// In this case, a wrap-around is expected to be preferred
|
||||||
|
// by computeShortRTButtonPress().
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 10,
|
||||||
|
targetQuantity = 50,
|
||||||
|
cyclicQuantityRange = 60,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 1)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair(((60 - 50) + (10 - 0)) / 1, RTNavigationButton.DOWN), result)
|
||||||
|
|
||||||
|
// Additional test to check that cyclic ranges are handled correctly
|
||||||
|
// even if currentQuantity is slightly higher than targetQuantity. This
|
||||||
|
// verifies that the computation does not produce negative distances.
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 11,
|
||||||
|
targetQuantity = 10,
|
||||||
|
cyclicQuantityRange = 24,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 1)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair(1, RTNavigationButton.DOWN), result)
|
||||||
|
|
||||||
|
// Test that computeShortRTButtonPress() can correctly handle start
|
||||||
|
// quantities that aren't an integer multiple of the step size. The
|
||||||
|
// "half-step" should be counted as one full step.
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 25,
|
||||||
|
targetQuantity = 40,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 20)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair(1, RTNavigationButton.UP), result)
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 25,
|
||||||
|
targetQuantity = 60,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 20)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair(2, RTNavigationButton.UP), result)
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 35,
|
||||||
|
targetQuantity = 20,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 20)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair(1, RTNavigationButton.DOWN), result)
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 55,
|
||||||
|
targetQuantity = 20,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 20)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair(2, RTNavigationButton.DOWN), result)
|
||||||
|
|
||||||
|
// Corner case: current and target quantity are the same. In this case,
|
||||||
|
// no RT button would actually be pressed, but the button value in the
|
||||||
|
// result can't be left unset, so it is just set to CHECK. (Any value
|
||||||
|
// would be okay; CHECK was chosen because it seems closest to something
|
||||||
|
// like a "neutral" value.)
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 60,
|
||||||
|
targetQuantity = 60,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 20)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair(0, RTNavigationButton.CHECK), result)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkComputeShortRTButtonPressWithTwoStepSizes() {
|
||||||
|
// Test that computeShortRTButtonPress() correctly computes
|
||||||
|
// the number of necessary short RT button presses and figures
|
||||||
|
// out the correct button to press. These are the tests for
|
||||||
|
// increment step arrays with two items.
|
||||||
|
|
||||||
|
var result: Pair<Int, RTNavigationButton>
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 100,
|
||||||
|
targetQuantity = 150,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 10), Pair(1000, 50)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair((150 - 100) / 10, RTNavigationButton.UP), result)
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 1000,
|
||||||
|
targetQuantity = 1100,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 10), Pair(1000, 50)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair((1100 - 1000) / 50, RTNavigationButton.UP), result)
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 900,
|
||||||
|
targetQuantity = 1050,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 10), Pair(1000, 50)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair((1000 - 900) / 10 + (1050 - 1000) / 50, RTNavigationButton.UP), result)
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 300,
|
||||||
|
targetQuantity = 230,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 10), Pair(1000, 50)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair((300 - 230) / 10, RTNavigationButton.DOWN), result)
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 1200,
|
||||||
|
targetQuantity = 1000,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 10), Pair(1000, 50)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair((1200 - 1000) / 50, RTNavigationButton.DOWN), result)
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 1100,
|
||||||
|
targetQuantity = 970,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 10), Pair(1000, 50)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair((1000 - 970) / 10 + (1100 - 1000) / 50, RTNavigationButton.DOWN), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkComputeShortRTButtonPressWithThreeStepSizes() {
|
||||||
|
// Test that computeShortRTButtonPress() correctly computes
|
||||||
|
// the number of necessary short RT button presses and figures
|
||||||
|
// out the correct button to press. These are the tests for
|
||||||
|
// increment step arrays with three items.
|
||||||
|
|
||||||
|
var result: Pair<Int, RTNavigationButton>
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 7900,
|
||||||
|
targetQuantity = 710,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 50), Pair(50, 10), Pair(1000, 50)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair((1000 - 710) / 10 + (7900 - 1000) / 50, RTNavigationButton.DOWN), result)
|
||||||
|
|
||||||
|
result = computeShortRTButtonPress(
|
||||||
|
currentQuantity = 0,
|
||||||
|
targetQuantity = 1100,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 50), Pair(50, 10), Pair(1000, 50)),
|
||||||
|
incrementButton = RTNavigationButton.UP,
|
||||||
|
decrementButton = RTNavigationButton.DOWN
|
||||||
|
)
|
||||||
|
assertEquals(Pair((50 - 0) / 50 + (1000 - 50) / 10 + (1100 - 1000) / 50, RTNavigationButton.UP), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkRTNavigationFromMainToQuickinfo() {
|
||||||
|
// Check RT screen navigation by navigating from the main screen
|
||||||
|
// to the quickinfo screen. If this does not work properly, the
|
||||||
|
// navigateToRTScreen() throws an exception or never ends. In
|
||||||
|
// the latter case, the watchdog will eventually cancel the
|
||||||
|
// coroutine and report the test as failed.
|
||||||
|
|
||||||
|
val rtNavigationContext = TestRTNavigationContext(listOf(
|
||||||
|
ParsedScreen.MainScreen(MainScreenContent.Normal(
|
||||||
|
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
|
||||||
|
activeBasalProfileNumber = 1,
|
||||||
|
currentBasalRateFactor = 300,
|
||||||
|
batteryState = BatteryState.FULL_BATTERY
|
||||||
|
)),
|
||||||
|
ParsedScreen.QuickinfoMainScreen(Quickinfo(availableUnits = 105, reservoirState = ReservoirState.FULL))
|
||||||
|
))
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
navigateToRTScreen(rtNavigationContext, ParsedScreen.QuickinfoMainScreen::class, isComboStopped = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkRTNavigationWithBlinkedOutScreens() {
|
||||||
|
// During navigation, the stream must skip screens that are blinked out,
|
||||||
|
// otherwise the navigation may incorrectly press RT buttons more often
|
||||||
|
// than necessary.
|
||||||
|
|
||||||
|
val rtNavigationContext = TestRTNavigationContext(listOf(
|
||||||
|
ParsedScreen.MainScreen(MainScreenContent.Normal(
|
||||||
|
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
|
||||||
|
activeBasalProfileNumber = 1,
|
||||||
|
currentBasalRateFactor = 300,
|
||||||
|
batteryState = BatteryState.FULL_BATTERY
|
||||||
|
)),
|
||||||
|
ParsedScreen.TemporaryBasalRateMenuScreen,
|
||||||
|
ParsedScreen.TemporaryBasalRatePercentageScreen(percentage = 110),
|
||||||
|
ParsedScreen.TemporaryBasalRatePercentageScreen(percentage = null),
|
||||||
|
ParsedScreen.TemporaryBasalRateDurationScreen(durationInMinutes = 45)
|
||||||
|
))
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
navigateToRTScreen(rtNavigationContext, ParsedScreen.TemporaryBasalRateDurationScreen::class, isComboStopped = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertContentEquals(
|
||||||
|
listOf(
|
||||||
|
RTNavigationButton.MENU,
|
||||||
|
RTNavigationButton.CHECK,
|
||||||
|
RTNavigationButton.MENU
|
||||||
|
),
|
||||||
|
rtNavigationContext.shortPressedRTButtons
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkRTNavigationWhenAlreadyAtTarget() {
|
||||||
|
// Check edge case handling when we want to navigate to
|
||||||
|
// a target screen type, but we are in fact already there.
|
||||||
|
|
||||||
|
val rtNavigationContext = TestRTNavigationContext(listOf(
|
||||||
|
ParsedScreen.MainScreen(MainScreenContent.Normal(
|
||||||
|
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
|
||||||
|
activeBasalProfileNumber = 1,
|
||||||
|
currentBasalRateFactor = 300,
|
||||||
|
batteryState = BatteryState.FULL_BATTERY
|
||||||
|
))
|
||||||
|
))
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
navigateToRTScreen(rtNavigationContext, ParsedScreen.MainScreen::class, isComboStopped = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkRTNavigationFromMainScreenToBasalRateFactorSettingScreen() {
|
||||||
|
// Check the result of a more complex navigation.
|
||||||
|
|
||||||
|
val rtNavigationContext = TestRTNavigationContext(listOf(
|
||||||
|
ParsedScreen.MainScreen(MainScreenContent.Normal(
|
||||||
|
currentTime = LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 1, hour = 23, minute = 11),
|
||||||
|
activeBasalProfileNumber = 1,
|
||||||
|
currentBasalRateFactor = 800,
|
||||||
|
batteryState = BatteryState.FULL_BATTERY
|
||||||
|
)),
|
||||||
|
ParsedScreen.StopPumpMenuScreen,
|
||||||
|
ParsedScreen.StandardBolusMenuScreen,
|
||||||
|
ParsedScreen.ExtendedBolusMenuScreen,
|
||||||
|
ParsedScreen.MultiwaveBolusMenuScreen,
|
||||||
|
ParsedScreen.TemporaryBasalRateMenuScreen,
|
||||||
|
ParsedScreen.MyDataMenuScreen,
|
||||||
|
ParsedScreen.BasalRateProfileSelectionMenuScreen,
|
||||||
|
ParsedScreen.BasalRate1ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRateTotalScreen(1840, 1),
|
||||||
|
ParsedScreen.BasalRateFactorSettingScreen(
|
||||||
|
LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 1, hour = 0, minute = 0),
|
||||||
|
LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 1, hour = 1, minute = 0),
|
||||||
|
1000,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
))
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
val targetScreen = navigateToRTScreen(
|
||||||
|
rtNavigationContext,
|
||||||
|
ParsedScreen.BasalRateFactorSettingScreen::class,
|
||||||
|
isComboStopped = false
|
||||||
|
)
|
||||||
|
assertIs<ParsedScreen.BasalRateFactorSettingScreen>(targetScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation is done by pressing MENU 9 times until the basal rate
|
||||||
|
// 1 programming menu is reached. The programming menu is entered
|
||||||
|
// by pressing CHECK, after which the basal rate totals screen
|
||||||
|
// shows up. Pressing MENU again enters further and shows the
|
||||||
|
// first basal profile factor, which is the target the navigation
|
||||||
|
// is trying to reach.
|
||||||
|
val expectedShortRTButtonPressSequence = listOf(
|
||||||
|
RTNavigationButton.MENU,
|
||||||
|
RTNavigationButton.MENU,
|
||||||
|
RTNavigationButton.MENU,
|
||||||
|
RTNavigationButton.MENU,
|
||||||
|
RTNavigationButton.MENU,
|
||||||
|
RTNavigationButton.MENU,
|
||||||
|
RTNavigationButton.MENU,
|
||||||
|
RTNavigationButton.MENU,
|
||||||
|
RTNavigationButton.CHECK,
|
||||||
|
RTNavigationButton.MENU
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContentEquals(expectedShortRTButtonPressSequence, rtNavigationContext.shortPressedRTButtons)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkLongPressRTButtonUntil() {
|
||||||
|
// Test long RT button presses by simulating transitions
|
||||||
|
// between screens that happen due to the long button
|
||||||
|
// press. The transition goes from the main screen
|
||||||
|
// over basal rate 1-3 programming screens up to the
|
||||||
|
// 4th one, which is the target. To test for "overshoots",
|
||||||
|
// there's a 5th one after that.
|
||||||
|
|
||||||
|
val rtNavigationContext = TestRTNavigationContext(listOf(
|
||||||
|
ParsedScreen.MainScreen(MainScreenContent.Normal(
|
||||||
|
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
|
||||||
|
activeBasalProfileNumber = 1,
|
||||||
|
currentBasalRateFactor = 300,
|
||||||
|
batteryState = BatteryState.FULL_BATTERY
|
||||||
|
)),
|
||||||
|
ParsedScreen.BasalRate1ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate2ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate3ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate4ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate5ProgrammingMenuScreen
|
||||||
|
))
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
val finalScreen = longPressRTButtonUntil(rtNavigationContext, RTNavigationButton.MENU) { parsedScreen ->
|
||||||
|
if (parsedScreen is ParsedScreen.BasalRate4ProgrammingMenuScreen)
|
||||||
|
LongPressRTButtonsCommand.ReleaseButton
|
||||||
|
else
|
||||||
|
LongPressRTButtonsCommand.ContinuePressingButton
|
||||||
|
}
|
||||||
|
assertIs<ParsedScreen.BasalRate4ProgrammingMenuScreen>(finalScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkShortPressRTButtonUntil() {
|
||||||
|
// Test short RT button presses by simulating transitions
|
||||||
|
// between screens that happen due to repeated short
|
||||||
|
// button presses. The transition goes from the main screen
|
||||||
|
// over basal rate 1-3 programming screens up to the
|
||||||
|
// 4th one, which is the target. To test for "overshoots",
|
||||||
|
// there's a 5th one after that.
|
||||||
|
|
||||||
|
val rtNavigationContext = TestRTNavigationContext(listOf(
|
||||||
|
ParsedScreen.MainScreen(MainScreenContent.Normal(
|
||||||
|
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
|
||||||
|
activeBasalProfileNumber = 1,
|
||||||
|
currentBasalRateFactor = 300,
|
||||||
|
batteryState = BatteryState.FULL_BATTERY
|
||||||
|
)),
|
||||||
|
ParsedScreen.BasalRate1ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate2ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate3ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate4ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate5ProgrammingMenuScreen
|
||||||
|
))
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
val finalScreen = shortPressRTButtonsUntil(rtNavigationContext) { parsedScreen ->
|
||||||
|
if (parsedScreen is ParsedScreen.BasalRate4ProgrammingMenuScreen)
|
||||||
|
ShortPressRTButtonsCommand.Stop
|
||||||
|
else
|
||||||
|
ShortPressRTButtonsCommand.PressButton(RTNavigationButton.MENU)
|
||||||
|
}
|
||||||
|
assertIs<ParsedScreen.BasalRate4ProgrammingMenuScreen>(finalScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkCycleToRTScreen() {
|
||||||
|
// Test the cycleToRTScreen() by letting it repeatedly
|
||||||
|
// press MENU until it reaches basal rate programming screen 4
|
||||||
|
// in our simulated sequence of screens.
|
||||||
|
|
||||||
|
val rtNavigationContext = TestRTNavigationContext(listOf(
|
||||||
|
ParsedScreen.MainScreen(MainScreenContent.Normal(
|
||||||
|
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
|
||||||
|
activeBasalProfileNumber = 1,
|
||||||
|
currentBasalRateFactor = 300,
|
||||||
|
batteryState = BatteryState.FULL_BATTERY
|
||||||
|
)),
|
||||||
|
ParsedScreen.BasalRate1ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate2ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate3ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate4ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate5ProgrammingMenuScreen
|
||||||
|
))
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
val finalScreen = cycleToRTScreen(
|
||||||
|
rtNavigationContext,
|
||||||
|
RTNavigationButton.MENU,
|
||||||
|
ParsedScreen.BasalRate4ProgrammingMenuScreen::class
|
||||||
|
)
|
||||||
|
assertIs<ParsedScreen.BasalRate4ProgrammingMenuScreen>(finalScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkWaitUntilScreenAppears() {
|
||||||
|
// Test waitUntilScreenAppears() by letting the context itself
|
||||||
|
// advance the screens until the screen that the function is
|
||||||
|
// waiting for is reached.
|
||||||
|
|
||||||
|
val rtNavigationContext = TestRTNavigationContext(
|
||||||
|
listOf(
|
||||||
|
ParsedScreen.BasalRate1ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate2ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate3ProgrammingMenuScreen,
|
||||||
|
ParsedScreen.BasalRate4ProgrammingMenuScreen
|
||||||
|
),
|
||||||
|
automaticallyAdvanceScreens = true
|
||||||
|
)
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
val finalScreen = waitUntilScreenAppears(
|
||||||
|
rtNavigationContext,
|
||||||
|
ParsedScreen.BasalRate3ProgrammingMenuScreen::class
|
||||||
|
)
|
||||||
|
assertIs<ParsedScreen.BasalRate3ProgrammingMenuScreen>(finalScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkAdjustQuantityOnScreen() {
|
||||||
|
// Test adjustQuantityOnScreen() by simulating a sequence of screens
|
||||||
|
// with a changing percentage quantity. This also simulates an overshoot
|
||||||
|
// by jumping from 150 straight to 170, past the target of 160. We
|
||||||
|
// expect adjustQuantityOnScreen() to catch this and correct it
|
||||||
|
// using short RT button presses until the target quantity is observed.
|
||||||
|
|
||||||
|
val rtNavigationContext = TestRTNavigationContext(listOf(
|
||||||
|
ParsedScreen.TemporaryBasalRatePercentageScreen(100),
|
||||||
|
ParsedScreen.TemporaryBasalRatePercentageScreen(110),
|
||||||
|
ParsedScreen.TemporaryBasalRatePercentageScreen(120),
|
||||||
|
ParsedScreen.TemporaryBasalRatePercentageScreen(130),
|
||||||
|
ParsedScreen.TemporaryBasalRatePercentageScreen(140),
|
||||||
|
ParsedScreen.TemporaryBasalRatePercentageScreen(150),
|
||||||
|
// No 160 quantity here, on purpose, to test overshoot handling
|
||||||
|
ParsedScreen.TemporaryBasalRatePercentageScreen(170),
|
||||||
|
ParsedScreen.TemporaryBasalRatePercentageScreen(170),
|
||||||
|
ParsedScreen.TemporaryBasalRatePercentageScreen(170),
|
||||||
|
ParsedScreen.TemporaryBasalRatePercentageScreen(160),
|
||||||
|
ParsedScreen.TemporaryBasalRatePercentageScreen(160)
|
||||||
|
))
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
adjustQuantityOnScreen(
|
||||||
|
rtNavigationContext,
|
||||||
|
targetQuantity = 160,
|
||||||
|
cyclicQuantityRange = null,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 10))
|
||||||
|
) { parsedScreen ->
|
||||||
|
parsedScreen as ParsedScreen.TemporaryBasalRatePercentageScreen
|
||||||
|
parsedScreen.percentage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkCyclicAdjustQuantityOnScreen() {
|
||||||
|
// Similar to checkAdjustQuantityOnScreen(), except that we
|
||||||
|
// test a "cyclic" quantity here, meaning that there is a maximum
|
||||||
|
// quantity and the current quantity can wrap around it back to 0.
|
||||||
|
// In here, we simulate an adjustment with starting quantity 58
|
||||||
|
// and target quantity 2, and a range of 0-60. Expected behavior
|
||||||
|
// is that adjustQuantityOnScreen() increments and correctly
|
||||||
|
// handles the wraparound from 59 to 0, since thanks to the
|
||||||
|
// wraparound, incrementing the quantity is actually faster
|
||||||
|
// than decrementing it. (With wrapround it goes 58 -> 0 -> 2
|
||||||
|
// by incrementing, which is a total distance of 5 steps, while
|
||||||
|
// without wraparound, it goes 58 -> 2 by decrementing, which
|
||||||
|
// is a total distance of 55 steps.)
|
||||||
|
|
||||||
|
val rtNavigationContext = TestRTNavigationContext(listOf(
|
||||||
|
ParsedScreen.TimeAndDateSettingsMinuteScreen(58),
|
||||||
|
ParsedScreen.TimeAndDateSettingsMinuteScreen(59),
|
||||||
|
ParsedScreen.TimeAndDateSettingsMinuteScreen(0),
|
||||||
|
ParsedScreen.TimeAndDateSettingsMinuteScreen(1),
|
||||||
|
// No 2 quantity here, on purpose, to test overshoot handling
|
||||||
|
ParsedScreen.TimeAndDateSettingsMinuteScreen(3),
|
||||||
|
ParsedScreen.TimeAndDateSettingsMinuteScreen(2)
|
||||||
|
))
|
||||||
|
|
||||||
|
runBlockingWithWatchdog(6000) {
|
||||||
|
adjustQuantityOnScreen(
|
||||||
|
rtNavigationContext,
|
||||||
|
targetQuantity = 2,
|
||||||
|
cyclicQuantityRange = 60,
|
||||||
|
incrementSteps = arrayOf(Pair(0, 1))
|
||||||
|
) { parsedScreen ->
|
||||||
|
parsedScreen as ParsedScreen.TimeAndDateSettingsMinuteScreen
|
||||||
|
parsedScreen.minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,104 @@
|
||||||
|
package info.nightscout.comboctl.parser
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class TokenizationTest {
|
||||||
|
@Test
|
||||||
|
fun checkBasicPatternMatch() {
|
||||||
|
// Try to match the LARGE_BASAL symbol pattern in the testFrameMainScreenWithTimeSeparator.
|
||||||
|
// That symbol is present at position (0,9).
|
||||||
|
// Trying to match it at those coordinates is expected to succeed,
|
||||||
|
// while trying to match it slightly to the right should fail.
|
||||||
|
|
||||||
|
val largeBasalGlyphPattern = glyphPatterns[Glyph.LargeSymbol(LargeSymbol.BASAL)]!!
|
||||||
|
|
||||||
|
val result1 = checkIfPatternMatchesAt(
|
||||||
|
testFrameMainScreenWithTimeSeparator,
|
||||||
|
largeBasalGlyphPattern,
|
||||||
|
0, 8
|
||||||
|
)
|
||||||
|
assertTrue(result1)
|
||||||
|
|
||||||
|
val result2 = checkIfPatternMatchesAt(
|
||||||
|
testFrameMainScreenWithTimeSeparator,
|
||||||
|
largeBasalGlyphPattern,
|
||||||
|
1, 8
|
||||||
|
)
|
||||||
|
assertFalse(result2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkMainScreenTokenization() {
|
||||||
|
// Look for tokens in the main menu display frame.
|
||||||
|
// The pattern matching algorithm scans the frame
|
||||||
|
// left to right, top to bottom, and tries the
|
||||||
|
// large patterns first.
|
||||||
|
// The main screen contains symbols that yield
|
||||||
|
// ambiguities due to overlapping tokens. For
|
||||||
|
// example, the basal icon contains sub-patterns
|
||||||
|
// that match the cyrillic letter "п". These must
|
||||||
|
// be filtered out by findTokens().
|
||||||
|
|
||||||
|
val tokens = findTokens(testFrameMainScreenWithTimeSeparator)
|
||||||
|
|
||||||
|
assertEquals(13, tokens.size)
|
||||||
|
|
||||||
|
val iterator = tokens.iterator()
|
||||||
|
|
||||||
|
assertEquals(Glyph.SmallSymbol(SmallSymbol.CLOCK), iterator.next().glyph)
|
||||||
|
|
||||||
|
assertEquals(Glyph.SmallDigit(1), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallDigit(0), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallSymbol(SmallSymbol.SEPARATOR), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallDigit(2), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallDigit(0), iterator.next().glyph)
|
||||||
|
|
||||||
|
assertEquals(Glyph.LargeSymbol(LargeSymbol.BASAL), iterator.next().glyph)
|
||||||
|
|
||||||
|
assertEquals(Glyph.LargeDigit(0), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.LargeSymbol(LargeSymbol.DOT), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.LargeDigit(2), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.LargeDigit(0), iterator.next().glyph)
|
||||||
|
|
||||||
|
assertEquals(Glyph.LargeSymbol(LargeSymbol.UNITS_PER_HOUR), iterator.next().glyph)
|
||||||
|
|
||||||
|
assertEquals(Glyph.SmallDigit(1), iterator.next().glyph)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkStandardBolusTokenization() {
|
||||||
|
// Look for tokens in the standard bolus display frame.
|
||||||
|
// The pattern matching algorithm scans the frame
|
||||||
|
// left to right, top to bottom, and tries the
|
||||||
|
// large patterns first.
|
||||||
|
// The standard bolus screen contains mostly letters,
|
||||||
|
// but also a LARGE_BOLUS symbol at the very bottom of
|
||||||
|
// the screen, thus testing that patterns are also properly
|
||||||
|
// matched if they are at a border.
|
||||||
|
|
||||||
|
val tokens = findTokens(testFrameStandardBolusMenuScreen)
|
||||||
|
|
||||||
|
assertEquals(14, tokens.size)
|
||||||
|
|
||||||
|
val iterator = tokens.iterator()
|
||||||
|
|
||||||
|
assertEquals(Glyph.SmallCharacter('S'), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallCharacter('T'), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallCharacter('A'), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallCharacter('N'), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallCharacter('D'), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallCharacter('A'), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallCharacter('R'), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallCharacter('D'), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallCharacter('B'), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallCharacter('O'), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallCharacter('L'), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallCharacter('U'), iterator.next().glyph)
|
||||||
|
assertEquals(Glyph.SmallCharacter('S'), iterator.next().glyph)
|
||||||
|
|
||||||
|
assertEquals(Glyph.LargeSymbol(LargeSymbol.BOLUS), iterator.next().glyph)
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ include ':ui'
|
||||||
include ':implementation'
|
include ':implementation'
|
||||||
include ':plugins'
|
include ':plugins'
|
||||||
include ':pump:combo'
|
include ':pump:combo'
|
||||||
|
include ':pump:combov2:comboctl'
|
||||||
include ':pump:dana'
|
include ':pump:dana'
|
||||||
include ':pump:danar'
|
include ':pump:danar'
|
||||||
include ':pump:danars'
|
include ':pump:danars'
|
||||||
|
|
Loading…
Reference in a new issue