Import ComboCtl

Signed-off-by: Carlos Rafael Giani <crg7475@mailbox.org>
This commit is contained in:
Carlos Rafael Giani 2022-02-22 19:50:57 +01:00
parent 5df6084fa8
commit 643f26b7bf
68 changed files with 34436 additions and 0 deletions

View file

@ -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()

View 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.

View 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"
}

View file

@ -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>

View file

@ -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" }
}
}

View file

@ -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()
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -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
}
}

View file

@ -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")!!
}

View file

@ -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)
}

View file

@ -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() })

View file

@ -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()
}

View file

@ -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)
}

View file

@ -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>
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)

View file

@ -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 })

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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 })

View file

@ -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() })

View file

@ -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))

View file

@ -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)
)
}
}

View file

@ -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
}

View file

@ -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" }
}
}

View file

@ -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
)
}
}

View file

@ -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()

View file

@ -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()
}

View file

@ -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

View file

@ -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" }
}
}

View file

@ -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)

View file

@ -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,
"ЧА" to TitleID.HOUR,
"МИHУ" 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
)

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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())
}
}

View file

@ -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() }
}
}

View file

@ -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)
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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")
}
}

View file

@ -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
)
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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())
}
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}
}
}
}

View file

@ -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)
}
}

View file

@ -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'