.toTransportLayerPacket(): TransportLayer.Packet {
+ return TransportLayer.Packet(this)
+}
diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Twofish.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Twofish.kt
new file mode 100644
index 0000000000..a3366acfe8
--- /dev/null
+++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Twofish.kt
@@ -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:
+ //
+ // g(x) = x**4 + (a + 1/a) x**3 + a x**2 + (a + 1/a) x + 1
+ //
+ // 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
+ )
+ }
+}
diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Utility.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Utility.kt
new file mode 100644
index 0000000000..b0ed0dae3e
--- /dev/null
+++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Utility.kt
@@ -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.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.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()
diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/BasalProfile.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/BasalProfile.kt
new file mode 100644
index 0000000000..bd4a45d5ee
--- /dev/null
+++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/BasalProfile.kt
@@ -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) {
+ 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 = _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()
+}
diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/ParsedDisplayFrameStream.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/ParsedDisplayFrameStream.kt
new file mode 100644
index 0000000000..3289f345cb
--- /dev/null
+++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/ParsedDisplayFrameStream.kt
@@ -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(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 = _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(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
+}
diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/Pump.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/Pump.kt
new file mode 100644
index 0000000000..bfcd1b1e1a
--- /dev/null
+++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/Pump.kt
@@ -0,0 +1,3198 @@
+package info.nightscout.comboctl.main
+
+import info.nightscout.comboctl.base.ApplicationLayer
+import info.nightscout.comboctl.base.ApplicationLayer.CMDHistoryEventDetail
+import info.nightscout.comboctl.base.BasicProgressStage
+import info.nightscout.comboctl.base.BluetoothAddress
+import info.nightscout.comboctl.base.BluetoothDevice
+import info.nightscout.comboctl.base.BluetoothException
+import info.nightscout.comboctl.base.ComboException
+import info.nightscout.comboctl.base.ComboIOException
+import info.nightscout.comboctl.base.CurrentTbrState
+import info.nightscout.comboctl.base.DisplayFrame
+import info.nightscout.comboctl.base.LogLevel
+import info.nightscout.comboctl.base.Logger
+import info.nightscout.comboctl.base.Nonce
+import info.nightscout.comboctl.base.ProgressReport
+import info.nightscout.comboctl.base.ProgressReporter
+import info.nightscout.comboctl.base.ProgressStage
+import info.nightscout.comboctl.base.PumpIO
+import info.nightscout.comboctl.base.PumpIO.ConnectionRequestIsNotBeingAcceptedException
+import info.nightscout.comboctl.base.PumpStateStore
+import info.nightscout.comboctl.base.Tbr
+import info.nightscout.comboctl.base.TransportLayer
+import info.nightscout.comboctl.base.toStringWithDecimal
+import info.nightscout.comboctl.base.withFixedYearFrom
+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.ReservoirState
+import kotlin.math.absoluteValue
+import kotlin.time.Duration
+import kotlin.time.DurationUnit
+import kotlin.time.toDuration
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.withContext
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.UtcOffset
+import kotlinx.datetime.asTimeZone
+import kotlinx.datetime.atStartOfDayIn
+import kotlinx.datetime.offsetAt
+import kotlinx.datetime.toInstant
+import kotlinx.datetime.toLocalDateTime
+
+private val logger = Logger.get("Pump")
+
+private const val NUM_IDEMPOTENT_COMMAND_DISPATCH_ATTEMPTS = 10
+private const val DEFAULT_MAX_NUM_REGULAR_CONNECT_ATTEMPTS = 10
+private const val DELAY_IN_MS_BETWEEN_COMMAND_DISPATCH_ATTEMPTS = 2000L
+private const val PUMP_DATETIME_UPDATE_LONG_RT_BUTTON_PRESS_THRESHOLD = 5
+
+object RTCommandProgressStage {
+ /**
+ * Basal profile setting stage.
+ *
+ * @property numSetFactors How many basal rate factors have been set by now.
+ * When the basal profile has been fully set, this value equals the value of
+ * totalNumFactors. Valid range is 0 to ([NUM_COMBO_BASAL_PROFILE_FACTORS] - 1).
+ */
+ data class SettingBasalProfile(val numSetFactors: Int) : ProgressStage("settingBasalProfile")
+
+ /**
+ * Basal profile getting stage.
+ *
+ * @property numSetFactors How many basal rate factors have been retrieved by now.
+ * When the basal profile has been fully retrieved, this value equals the value
+ * of totalNumFactors. Valid range is 0 to ([NUM_COMBO_BASAL_PROFILE_FACTORS] - 1).
+ */
+ data class GettingBasalProfile(val numSetFactors: Int) : ProgressStage("gettingBasalProfile")
+
+ /**
+ * TBR percentage setting stage.
+ *
+ * @property settingProgress How far along the TBR percentage setting is, in the 0-100 range.
+ * 0 = procedure started. 100 = TBR percentage setting finished.
+ */
+ data class SettingTBRPercentage(val settingProgress: Int) : ProgressStage("settingTBRPercentage")
+
+ /**
+ * TBR duration setting stage.
+ *
+ * @property settingProgress How far along the TBR duration setting is, in the 0-100 range.
+ * 0 = procedure started. 100 = TBR duration setting finished.
+ */
+ data class SettingTBRDuration(val settingProgress: Int) : ProgressStage("settingTBRDuration")
+
+ /**
+ * Bolus delivery stage.
+ *
+ * The amounts are given in 0.1 IU units. For example, "57" means 5.7 IU.
+ *
+ * @property deliveredAmount How many units have been delivered so far.
+ * This is always <= totalAmount.
+ * @property totalAmount Total amount of bolus units.
+ */
+ data class DeliveringBolus(val deliveredAmount: Int, val totalAmount: Int) : ProgressStage("deliveringBolus")
+
+ /**
+ * TDD fetching history stage.
+ *
+ * @property historyEntryIndex Index of the history entry that was just fetched.
+ * Valid range is 1 to [totalNumEntries].
+ * @property totalNumEntries Total number of entries in the history.
+ */
+ data class FetchingTDDHistory(val historyEntryIndex: Int, val totalNumEntries: Int) : ProgressStage("fetchingTDDHistory")
+
+ /**
+ * SetDateTime stage when the current hour is set.
+ */
+ object SettingDateTimeHour : ProgressStage("settingDateTimeHour")
+
+ /**
+ * SetDateTime stage when the current minute is set.
+ */
+ object SettingDateTimeMinute : ProgressStage("settingDateTimeMinute")
+
+ /**
+ * SetDateTime stage when the current year is set.
+ */
+ object SettingDateTimeYear : ProgressStage("settingDateTimeYear")
+
+ /**
+ * SetDateTime stage when the current month is set.
+ */
+ object SettingDateTimeMonth : ProgressStage("settingDateTimeMonth")
+
+ /**
+ * SetDateTime stage when the current day is set.
+ */
+ object SettingDateTimeDay : ProgressStage("settingDateTimeDay")
+}
+
+/**
+ * Main pump control class.
+ *
+ * This is the class that callers will mainly use for interacting with a pump.
+ * It takes care of IO with the pump and implements higher level commands like
+ * setting / getting the basal profile, delivering a bolus, getting / setting
+ * TBRs and the current datetime etc.
+ *
+ * To begin operating the pump, call [connect] to set up a Bluetooth connection.
+ * The connection can be terminated with [disconnect].
+ *
+ * This class applies a series of checks for safety and robustness reasons.
+ * These are divided into checks performed by [connect] and checks performed
+ * before, during, and after command execution. See [connect] for a documentation
+ * about the on-connect checks. As for the command execution ones, these are:
+ *
+ * 1. Before each command, the Combo's warning & error flags are queried.
+ * If these are set, the Combo is switched to the remote terminal mode
+ * to "see" what warning/error is on the RT screen. That screen is parsed
+ * and processed. If it can't be handled locally, an [AlertScreenException]
+ * is thrown.
+ * 2. During command execution, if the execution fails due to connection issues,
+ * and the command is idempotent, this class attempts to reconnect to the pump,
+ * followed by another command execution attempt. This is repeated a number of
+ * times until execution succeeds or all possible attempts have been exhausted.
+ * If no attempt succeeded, [CommandExecutionAttemptsFailedException] is thrown.
+ * However, if the command is _not_ idempotent ([deliverBolus] is a notable
+ * example), then no repeat attempts are made. A command is idempotent if
+ * a command can be repeated safely. This is the case when repeated execution
+ * doesn't actually change anything unless the previous attempt failed.
+ * For example, if the same TBR is (re)started twice in quick succession,
+ * the second attempt effectively changes nothing. Repeating the same bolus
+ * however is _not_ idempotent since these boluses stack up, so failed bolus
+ * deliveries *must not* be repeated.
+ * 3. After command execution, the same check from step #1 is performed.
+ *
+ * All datetime timestamps are given as [Instant] values instead of localtime.
+ * This is done to ensure that timezone and/or daylight savings changes do
+ * not negatively affect operation of the pump. The pump's current datetime
+ * is automatically adjusted if it deviates from the current system datetime,
+ * and the system's current UTC offset is also stored (in the [PumpStateStore]).
+ *
+ * This class also informs callers about various events of type [Event].
+ * Events can be for example "battery low", "TBR started", "bolus delivered" etc.
+ * When the Combo is suspended, a 0% 15-minute TBR event is emitted, since the
+ * suspended state effectively acts like such a 0% TBR. Events are emitted via
+ * the [onEvent] callback. Errors are communicated as exceptions, not as events.
+ *
+ * The class has a state (via [stateFlow]) and a status ([statusFlow]). The
+ * state informs about what the pump is currently doing or what it can
+ * currently do, while the status informs about various quantities in the
+ * pump, like how many IUs the pump's reservoir currently has. The status
+ * is updated by calling [updateStatus]. Note however that some functions
+ * like [connect] also automatically update the status.
+ *
+ * [initialBasalProfile] allows for setting a known basal profile as the
+ * current one. This does _not_ program that profile into the pump; instead,
+ * this sets the initial value of [currentBasalProfile]. If that property
+ * is null, [connect] will read the profile from the pump, so if the user
+ * is certain that the pump already contains a certain profile, setting
+ * this argument to that profile avoids an unnecessary basal profile read
+ * operation when connecting.
+ *
+ * IMPORTANT: The commands in this class are not designed to be executed
+ * concurrently (the Combo does not support this), so make sure these
+ * commands (for example, [setBasalProfile] and [deliverBolus]) are
+ * never called concurrently by multiple threads and/or coroutines.
+ * If necessary, use synchronization primitives.
+ *
+ * @param bluetoothDevice [BluetoothDevice] object to use for
+ * Bluetooth I/O. Must be in a disconnected state when
+ * assigned to this instance.
+ * @param pumpStateStore Pump state store to use.
+ * @param initialBasalProfile Basal profile to use as the initial value
+ * of [currentBasalProfile].
+ * @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.
+ */
+class Pump(
+ private val bluetoothDevice: BluetoothDevice,
+ private val pumpStateStore: PumpStateStore,
+ initialBasalProfile: BasalProfile? = null,
+ private val onEvent: (event: Event) -> Unit = { }
+) {
+ private val pumpIO = PumpIO(pumpStateStore, bluetoothDevice, this::processDisplayFrame, this::packetReceiverExceptionThrown)
+ // Updated by updateStatusImpl(). true if the Combo
+ // is currently in the stop mode. If true, commands
+ // are not executed, and an exception is thrown instead.
+ // See the checks in executeCommand() for details.
+ private var pumpSuspended = false
+
+ // This is used in connectInternal() to prevent executeCommand()
+ // from attempting to reconnect. That's needed when connectInternal()
+ // performs post-connect pump check commands.
+ private var reconnectAttemptsEnabled = false
+
+ // States for navigating through remote terminal (RT) screens. Needed by
+ // all commands that simulate user interactions in the RT mode, like
+ // setBasalProfile(). Not used by command-mode commands like [deliverBolus].
+ private val parsedDisplayFrameStream = ParsedDisplayFrameStream()
+ private val rtNavigationContext = RTNavigationContextProduction(pumpIO, parsedDisplayFrameStream)
+
+ // Used for keeping track of wether an RT alert screen was already dismissed
+ // (necessary since the screen may change its contents but still be the same screen).
+ private var rtScreenAlreadyDismissed = false
+ // Used in handleAlertScreenContent() to check if the current alert
+ // screen contains the same alert as the previous one.
+ private var lastObservedAlertScreenContent: AlertScreenContent? = null
+
+ private var currentPumpUtcOffset: UtcOffset? = null
+
+ // Command progress reporters.
+
+ private val setBasalProfileReporter = createBasalProgressReporter()
+ private val getBasalProfileReporter = createBasalProgressReporter()
+
+ private val setTbrProgressReporter = ProgressReporter(
+ listOf(
+ RTCommandProgressStage.SettingTBRPercentage::class,
+ RTCommandProgressStage.SettingTBRDuration::class
+ ),
+ Unit
+ ) { _: Int, _: Int, stage: ProgressStage, _: Unit ->
+ // TBR progress is divided in two stages, each of which have
+ // their own individual progress. Combine them by letting the
+ // SettingTBRPercentage stage cover the 0.0 - 0.5 progress
+ // range, and SettingTBRDuration cover the remaining 0.5 -1.0
+ // progress range.
+ when (stage) {
+ BasicProgressStage.Finished,
+ is BasicProgressStage.Aborted -> 1.0
+ is RTCommandProgressStage.SettingTBRPercentage ->
+ 0.0 + stage.settingProgress.toDouble() / 100.0 * 0.5
+ is RTCommandProgressStage.SettingTBRDuration ->
+ 0.5 + stage.settingProgress.toDouble() / 100.0 * 0.5
+ else -> 0.0
+ }
+ }
+
+ private val bolusDeliveryProgressReporter = ProgressReporter(
+ listOf(
+ RTCommandProgressStage.DeliveringBolus::class
+ ),
+ Unit
+ ) { _: Int, _: Int, stage: ProgressStage, _: Unit ->
+ // Bolus delivery progress is determined by the single
+ // stage in the reporter, which is DeliveringBolus.
+ // That stage contains how many IU have been delivered
+ // so far, which is suitable for a progress indicator,
+ // so we use that for the overall progress.
+ when (stage) {
+ BasicProgressStage.Finished,
+ is BasicProgressStage.Aborted -> 1.0
+ is RTCommandProgressStage.DeliveringBolus ->
+ stage.deliveredAmount.toDouble() / stage.totalAmount.toDouble()
+ else -> 0.0
+ }
+ }
+
+ private val connectProgressReporter = ProgressReporter(
+ listOf(
+ BasicProgressStage.EstablishingBtConnection::class,
+ BasicProgressStage.PerformingConnectionHandshake::class
+ ),
+ Unit
+ )
+
+ private val setDateTimeProgressReporter = ProgressReporter(
+ listOf(
+ RTCommandProgressStage.SettingDateTimeHour::class,
+ RTCommandProgressStage.SettingDateTimeMinute::class,
+ RTCommandProgressStage.SettingDateTimeYear::class,
+ RTCommandProgressStage.SettingDateTimeMonth::class,
+ RTCommandProgressStage.SettingDateTimeDay::class
+ ),
+ Unit
+ )
+
+ private val tddHistoryProgressReporter = ProgressReporter(
+ listOf(
+ RTCommandProgressStage.FetchingTDDHistory::class
+ ),
+ Unit
+ ) { _: Int, _: Int, stage: ProgressStage, _: Unit ->
+ when (stage) {
+ // TDD history fetching progress is determined by the single
+ // stage in the reporter, which is FetchingTDDHistory.
+ // That stage contains the index of the TDD that was just
+ // read, which is suitable for a progress indicator,
+ // so we use that for the overall progress.
+ BasicProgressStage.Finished,
+ is BasicProgressStage.Aborted -> 1.0
+ is RTCommandProgressStage.FetchingTDDHistory ->
+ stage.historyEntryIndex.toDouble() / stage.totalNumEntries.toDouble()
+ else -> 0.0
+ }
+ }
+
+ /**
+ * Empty base class for command descriptions used during the [State.ExecutingCommand] state.
+ *
+ * Callers can check for a specific subclass to determine the command that is
+ * being executed. Example:
+ *
+ * ```
+ * when (executingCommandState.description) {
+ * is GettingBasalProfileCommandDesc -> println("Getting basal profile")
+ * is FetchingTDDHistoryCommandDesc -> println("Fetching TDD history")
+ * // etc.
+ * }
+ * ```
+ */
+ open class CommandDescription
+
+ class GettingBasalProfileCommandDesc : CommandDescription()
+ class SettingBasalProfileCommandDesc : CommandDescription()
+ class UpdatingPumpDateTimeCommandDesc(val newPumpLocalDateTime: LocalDateTime) : CommandDescription()
+ class UpdatingPumpStatusCommandDesc : CommandDescription()
+ class FetchingTDDHistoryCommandDesc : CommandDescription()
+ class SettingTbrCommandDesc(
+ val percentage: Int,
+ val durationInMinutes: Int,
+ val type: Tbr.Type,
+ val force100Percent: Boolean
+ ) : CommandDescription()
+ class DeliveringBolusCommandDesc(
+ val bolusAmount: Int,
+ val bolusReason: StandardBolusReason
+ ) : CommandDescription()
+
+ /**
+ * Exception thrown when an idempotent command failed every time.
+ *
+ * Idempotent commands are retried multiple times if they fail. If all attempts
+ * fail, the dispatcher gives up, and throws this exception instead.
+ */
+ class CommandExecutionAttemptsFailedException :
+ ComboException("All attempts to execute the command failed")
+
+ /**
+ * Exception thrown when setting the pump datetime fails.
+ */
+ class SettingPumpDatetimeFailedException :
+ ComboException("Could not set pump datetime")
+
+ class UnaccountedBolusDetectedException :
+ ComboException("Unaccounted bolus(es) detected")
+
+ /**
+ * Exception thrown when something goes wrong with a bolus delivery.
+ *
+ * @param totalAmount Total bolus amount that was supposed to be delivered. In 0.1 IU units.
+ * @param message The detail message.
+ */
+ open class BolusDeliveryException(val totalAmount: Int, message: String) : ComboException(message)
+
+ /**
+ * Exception thrown when the Combo did not deliver the bolus at all.
+ *
+ * @param totalAmount Total bolus amount that was supposed to be delivered. In 0.1 IU units.
+ */
+ class BolusNotDeliveredException(totalAmount: Int) :
+ BolusDeliveryException(totalAmount, "Could not deliver bolus amount of ${totalAmount.toStringWithDecimal(1)} IU")
+
+ /**
+ * Exception thrown when the bolus delivery was cancelled.
+ *
+ * @param deliveredAmount Bolus amount that was delivered before the bolus was cancelled. In 0.1 IU units.
+ * @param totalAmount Total bolus amount that was supposed to be delivered. In 0.1 IU units.
+ */
+ class BolusCancelledByUserException(val deliveredAmount: Int, totalAmount: Int) :
+ BolusDeliveryException(
+ totalAmount,
+ "Bolus cancelled (delivered amount: ${deliveredAmount.toStringWithDecimal(1)} IU " +
+ "total programmed amount: ${totalAmount.toStringWithDecimal(1)} IU"
+ )
+
+ /**
+ * Exception thrown when the bolus delivery was aborted due to an error.
+ *
+ * @param deliveredAmount Bolus amount that was delivered before the bolus was aborted. In 0.1 IU units.
+ * @param totalAmount Total bolus amount that was supposed to be delivered.
+ */
+ class BolusAbortedDueToErrorException(deliveredAmount: Int, totalAmount: Int) :
+ BolusDeliveryException(
+ totalAmount,
+ "Bolus aborted due to an error (delivered amount: ${deliveredAmount.toStringWithDecimal(1)} IU " +
+ "total programmed amount: ${totalAmount.toStringWithDecimal(1)} IU"
+ )
+
+ /**
+ * Exception thrown when there isn't enough insulin in the reservoir for the bolus to be delivered.
+ *
+ * IMPORTANT: Bolus amount is given in 0.1 IU units, while the available units in the
+ * reservoir are given in whole 1 IU units.
+ *
+ * @param bolusAmount Bolus amount that was attempted to be delivered. In 0.1 IU units.
+ * @param availableUnitsInReservoir Number of units in the reservoir. In 1 IU units.
+ */
+ class InsufficientInsulinAvailableException(bolusAmount: Int, val availableUnitsInReservoir: Int) :
+ BolusDeliveryException(
+ bolusAmount,
+ "Insufficient insulin in reservoir for bolus: bolus amount: ${bolusAmount.toStringWithDecimal(1)} IU " +
+ "available units in reservoir: $availableUnitsInReservoir"
+ )
+
+ /**
+ * Exception thrown when the TBR that was passed to setTbr() does not match the actually active TBR.
+ *
+ * If no TBR is active, [actualTbrDuration] is 0. If no TBR was expected to be active,
+ * [expectedTbrDuration] is 0.
+ */
+ class UnexpectedTbrStateException(
+ val expectedTbrPercentage: Int,
+ val expectedTbrDuration: Int,
+ val actualTbrPercentage: Int,
+ val actualTbrDuration: Int
+ ) : ComboException(
+ "Expected TBR: $expectedTbrPercentage% $expectedTbrDuration minutes ; " +
+ "actual TBR: $actualTbrPercentage% $actualTbrDuration minutes"
+ )
+
+ /**
+ * Exception thrown when the main screen shows information about an active extended / multiwave bolus.
+ *
+ * These bolus type are currently not supported and cannot be handled properly.
+ *
+ * @property bolusInfo Information about the detected extended / multiwave bolus.
+ */
+ class ExtendedOrMultiwaveBolusActiveException(val bolusInfo: MainScreenContent.ExtendedOrMultiwaveBolus) :
+ ComboException("Extended or multiwave bolus is active; bolus info: $bolusInfo")
+
+ /**
+ * Reason for a standard bolus delivery.
+ *
+ * A standard bolus may be delivered for various reasons.
+ */
+ enum class StandardBolusReason {
+ /**
+ * This is a normal bolus.
+ */
+ NORMAL,
+
+ /**
+ * This is a superbolus.
+ */
+ SUPERBOLUS,
+
+ /**
+ * This is a bolus that is used for priming an infusion set.
+ */
+ PRIMING_INFUSION_SET
+ }
+
+ /**
+ * Events that can occur during operation.
+ *
+ * These are forwarded through the [onEvent] property.
+ *
+ * IMPORTANT: Bolus amounts are given in 0.1 IU units,
+ * so for example, "57" means 5.7 IU.
+ */
+ sealed class Event {
+ object BatteryLow : Event()
+ object ReservoirLow : Event()
+ data class QuickBolusRequested(
+ val bolusId: Long,
+ val timestamp: Instant,
+ val bolusAmount: Int
+ ) : Event()
+ data class QuickBolusInfused(
+ val bolusId: Long,
+ val timestamp: Instant,
+ val bolusAmount: Int
+ ) : Event()
+ data class StandardBolusRequested(
+ val bolusId: Long,
+ val timestamp: Instant,
+ val manual: Boolean,
+ val bolusAmount: Int,
+ val standardBolusReason: StandardBolusReason
+ ) : Event()
+ data class StandardBolusInfused(
+ val bolusId: Long,
+ val timestamp: Instant,
+ val manual: Boolean,
+ val bolusAmount: Int,
+ val standardBolusReason: StandardBolusReason
+ ) : Event()
+ data class ExtendedBolusStarted(
+ val bolusId: Long,
+ val timestamp: Instant,
+ val totalBolusAmount: Int,
+ val totalDurationMinutes: Int
+ ) : Event()
+ data class ExtendedBolusEnded(
+ val bolusId: Long,
+ val timestamp: Instant,
+ val totalBolusAmount: Int,
+ val totalDurationMinutes: Int
+ ) : Event()
+ data class MultiwaveBolusStarted(
+ val bolusId: Long,
+ val timestamp: Instant,
+ val totalBolusAmount: Int,
+ val immediateBolusAmount: Int,
+ val totalDurationMinutes: Int
+ ) : Event()
+ data class MultiwaveBolusEnded(
+ val bolusId: Long,
+ val timestamp: Instant,
+ val totalBolusAmount: Int,
+ val immediateBolusAmount: Int,
+ val totalDurationMinutes: Int
+ ) : Event()
+ data class TbrStarted(val tbr: Tbr) : Event()
+ data class TbrEnded(val tbr: Tbr, val timestampWhenTbrEnded: Instant) : Event()
+ data class UnknownTbrDetected(
+ val tbrPercentage: Int,
+ val remainingTbrDurationInMinutes: Int
+ ) : Event()
+ }
+
+ /**
+ * The pump's Bluetooth address.
+ */
+ val address: BluetoothAddress = bluetoothDevice.address
+
+ /**
+ * Read-only [SharedFlow] property that delivers newly assembled and parsed display frames.
+ *
+ * See [ParsedDisplayFrame] for details about these frames.
+ */
+ val parsedDisplayFrameFlow: SharedFlow = parsedDisplayFrameStream.flow
+
+ /**
+ * Read-only [StateFlow] property that announces when the current [PumpIO.Mode] changed.
+ *
+ * This flow's value is null until the connection is fully established (at which point
+ * the mode is set to [PumpIO.Mode.REMOTE_TERMINAL] or [PumpIO.Mode.COMMAND]), and
+ * set back to null again after disconnecting.
+ */
+ val currentModeFlow: StateFlow = pumpIO.currentModeFlow
+
+ /**
+ * Possible states the pump can be in.
+ */
+ sealed class State {
+ /**
+ * There is no connection to the pump. This is the initial state.
+ */
+ object Disconnected : State()
+
+ /**
+ * Connection to the pump is being established. This state is set
+ * while [connect] is running. If connecting fails, the state
+ * is set to [Error], otherwise it is set to [CheckingPump],
+ * [Suspended], or [ReadyForCommands].
+ */
+ object Connecting : State()
+
+ /**
+ * After connection was established, [connect] performs checks
+ * (if [performOnConnectChecks] is set to true). The pump state
+ * is set to this one while these checks are running.
+ * If [performOnConnectChecks] is set to false, this state
+ * is never set. Instead, after [Connecting], the state transitions
+ * directly to [ReadyForCommands], [Suspended], or [Error].
+ */
+ object CheckingPump : State()
+
+ /**
+ * After successfully connecting and performing the checks, this
+ * becomes the current state. Commands can be run in this state.
+ * If the Combo is stopped (also known as "suspended"), the
+ * state is set to [Suspended] instead (see below).
+ */
+ object ReadyForCommands : State()
+
+ /**
+ * A command is currently being executed. This state remains set
+ * until the command execution finishes. If it finishes successfully,
+ * it is set back to [ReadyForCommands]. If an error occurs,
+ * it is set to [Error]. The [description] provides information for
+ * UIs to show the user what command is being executed.
+ */
+ data class ExecutingCommand(val description: CommandDescription) : State()
+
+ /**
+ * The Combo is currently stopped (= suspended). No commands can
+ * be executed. This is not an error, but the user has to resume
+ * pump operation manually.
+ */
+ object Suspended : State()
+
+ /**
+ * An error occurred during connection setup or command execution.
+ * Said error was non-recoverable. The only valid operation that
+ * can be performed in this state is to call [disconnect].
+ * Commands cannot be executed in this state.
+ *
+ * @property throwable Optional reference to a Throwable that triggered this error state.
+ * @property message Optional human-readable message describing the error.
+ * This is meant for logging purposes.
+ */
+ data class Error(val throwable: Throwable? = null, val message: String? = null) : State() {
+ override fun toString(): String {
+ return if (throwable != null)
+ "Error (\"$message\"); throwable: $throwable"
+ else
+ "Error (\"$message\")"
+ }
+ }
+ }
+
+ private val _stateFlow = MutableStateFlow(State.Disconnected)
+
+ /**
+ * [StateFlow] that notifies about the pump's current state.
+ */
+ val stateFlow: StateFlow = _stateFlow.asStateFlow()
+
+ /**
+ * [StateFlow] for reporting progress during the [connect] call.
+ *
+ * See the [ProgressReporter] documentation for details.
+ */
+ val connectProgressFlow: StateFlow = connectProgressReporter.progressFlow
+
+ /**
+ * [ProgressReporter] flow for reporting progress while the pump datetime is set.
+ *
+ * See the [ProgressReporter] documentation for details.
+ *
+ * This flow consists of these stages (aside from Finished/Aborted/Idle):
+ *
+ * - [RTCommandProgressStage.SettingDateTimeHour]
+ * - [RTCommandProgressStage.SettingDateTimeMinute]
+ * - [RTCommandProgressStage.SettingDateTimeYear]
+ * - [RTCommandProgressStage.SettingDateTimeMonth]
+ * - [RTCommandProgressStage.SettingDateTimeDay]
+ */
+ val setDateTimeProgressFlow = setDateTimeProgressReporter.progressFlow
+
+ /**
+ * Pump status.
+ *
+ * This contains status information like the number of available
+ * units in the reservoir, the percentage of a currently ongoing
+ * TBR, the battery state etc.
+ *
+ * There is no field that specifies whether the Combo is running
+ * or stopped. That's because that information is already covered
+ * by [State.Suspended].
+ *
+ * A [currentBasalRateFactor] is special in that it indicates
+ * that [updateStatus] could not get the current factor. This
+ * happens when the pump is stopped (the main screen does not
+ * show any factor then). It also happens when a 0% TBR is
+ * active (the factor shown on screen is then always 0 regardless
+ * or what the actual underlying factor is).
+ */
+ data class Status(
+ val availableUnitsInReservoir: Int,
+ val activeBasalProfileNumber: Int,
+ val currentBasalRateFactor: Int,
+ val tbrOngoing: Boolean,
+ val remainingTbrDurationInMinutes: Int,
+ val tbrPercentage: Int,
+ val reservoirState: ReservoirState,
+ val batteryState: BatteryState
+ )
+
+ private val _statusFlow = MutableStateFlow(null)
+
+ /**
+ * [StateFlow] that notifies about the pump's current status.
+ *
+ * This is updated by the [updateStatus] function. Initially,
+ * it is set to null. It is set to null again after disconnecting.
+ */
+ val statusFlow = _statusFlow.asStateFlow()
+
+ /**
+ * The basal profile that is currently being used.
+ *
+ * This is initially set to the profile that is passed to [Pump]'s
+ * constructor. If [setBasalProfile] is called, and the pump's
+ * profile is updated, then so is this property.
+ */
+ var currentBasalProfile: BasalProfile? = initialBasalProfile
+ private set
+
+ /**
+ * Information about the last bolus. See [lastBolusFlow].
+ *
+ * NOTE: This only reports quick and standard boluses, not multiwave and extended ones.
+ *
+ * @property bolusId ID associated with this bolus.
+ * @property bolusAmount Bolus amount, in 0.1 IU units.
+ * @property timestamp Timestamp of the bolus delivery.
+ */
+ data class LastBolus(val bolusId: Long, val bolusAmount: Int, val timestamp: Instant)
+
+ private var _lastBolusFlow = MutableStateFlow(null)
+
+ /**
+ * Informs about the last bolus that was administered during this connection.
+ *
+ * Boluses that might have happened in an earlier connection are not looked
+ * at. This is purely about the _current_ connection.
+ */
+ val lastBolusFlow = _lastBolusFlow.asStateFlow()
+
+ private var _currentTbrFlow = MutableStateFlow(null)
+
+ /**
+ * Informs about a currently active TBR.
+ *
+ * Along with [Event.TbrStarted], [Event.TbrEnded], and the TBR details in
+ * [Status], this is an additional way to get informed about TBR activity,
+ * and is mostly useful for UI updates. If no TBR is ongoing, the flow's
+ * value is set to null.
+ */
+ val currentTbrFlow = _currentTbrFlow.asStateFlow()
+
+ /**
+ * Unpairs the pump.
+ *
+ * Unpairing consists of deleting any associated pump state,
+ * followed by unpairing the Bluetooth device.
+ *
+ * This disconnects before unpairing to make sure there
+ * is no ongoing connection while attempting to unpair.
+ *
+ * If the pump isn't paired already, this function does nothing.
+ *
+ * NOTE: This removes pump data from ComboCtl's pump state store
+ * and unpairs the Combo at the Bluetooth level, but does _not_
+ * remove this client from the Combo. The user still has to
+ * operate the Combo's local LCD UI to manually remove this
+ * client from the Combo in its Bluetooth settings. There is
+ * no way to do this remotely by the client.
+ */
+ suspend fun unpair() {
+ if (!pumpStateStore.hasPumpState(address))
+ return
+
+ disconnect()
+
+ pumpStateStore.deletePumpState(address)
+
+ // Unpairing in a coroutine with an IO dispatcher
+ // in case unpairing blocks.
+ withContext(bluetoothDevice.ioDispatcher) {
+ bluetoothDevice.unpair()
+ }
+
+ logger(LogLevel.INFO) { "Unpaired from pump with address ${bluetoothDevice.address}" }
+ }
+
+ /**
+ * Establishes a connection to the Combo.
+ *
+ * This suspends the calling coroutine until the connection
+ * is up and running, a connection error occurs, or the
+ * calling coroutine is cancelled.
+ *
+ * This changes the current state multiple times. These
+ * updates are accessible through [stateFlow]. Initially,
+ * the state is set to [State.Connecting]. Once the underlying
+ * Bluetooth device is connected, this function transitions to
+ * the [State.CheckingPump] state and performs checks on the
+ * pump (described below). As part of these checks, if the Combo
+ * is found to be currently stopped (= suspended), the state is
+ * set to [State.Suspended], otherwise it is set to
+ * [State.ReadyForCommands]. At this point, this function
+ * finishes successfully.
+ *
+ * If any error occurs while this function runs, the state
+ * is set to [State.Error]. If the calling coroutine is cancelled,
+ * the state is instead set to [State.Disconnected] because
+ * cancellation rolls back any partial connection setup that
+ * might have been done by the time the cancellation occurs.
+ *
+ * At each connection setup, a series of checks are performed.:
+ *
+ * 1. [updateStatus] is called to get the current up-to-date status,
+ * which is needed by other checks. This also updates the [statusFlow].
+ * 2. The command mode history delta is retrieved. This contains all
+ * delivered boluses since the last time the history delta was retrieved.
+ * If no boluses happened in between connections, this list will be empty.
+ * Otherwise, unaccounted boluses happened. These are announced via [onEvent].
+ * 3. The current pump status is evaluated. If the pump is found to be
+ * suspended, the [stateFlow] switches to [State.Suspended], the checks
+ * end, and so does this function. Otherwise, it continues.
+ * 4. The TBR state is evaluated according to the information from
+ * [PumpStateStore] and what is displayed on the main Combo screen
+ * (this is retrieved by [updateStatus] in the remote terminal mode).
+ * If an unknown TBR is detected, then that unknown TBR is cancelled,
+ * and [Event.UnknownTbrDetected] is emitted via [onEvent].
+ * 5. If [currentBasalProfile] is null, or if the current basal rate
+ * that is shown on the main Combo RT screen does not match the current
+ * basal rate from the profile at this hour, the basal profile is read
+ * from the Combo, and [currentBasalProfile] is updated. The basal
+ * profile retrieval can be tracked via [getBasalProfileFlow].
+ * 6. The current pump's datetime is updated to match the current
+ * system datetime if there is a mismatch. This is done through the
+ * remote terminal mode. The progress can be tracked by watching the
+ * [setDateTimeProgressFlow].
+ * 7. The current pump's UTC offset is updated to match the current
+ * system's UTC offset if there is a mismatch. The UTC offset is
+ * written to [pumpStateStore].
+ *
+ * Since no two clients can deliver a bolus, set a TBR etc. on the same
+ * Combo simultaneously, these checks do not have to be performed before
+ * each command - it is sufficient to do them upon connection setup.
+ *
+ * If IO errors happen during a connection attempt, this function tries
+ * to establish the connection again. The maximum number of attempts is
+ * specified by the [maxNumAttempts] argument. It can be set to null to
+ * allow for an unlimited amount of attempts. This should only be used
+ * if the caller has some sort of custom timeout mechanism at which the
+ * [disconnect] function is called (which makes this function abort).
+ *
+ * This function also handles a special situation if the [Nonce] that is
+ * stored in [PumpStateStore] for this pump is incorrect. The Bluetooth
+ * socket can then be successfully connected, but right afterwards, when
+ * this function tries to send a [TransportLayer.Command.REQUEST_REGULAR_CONNECTION]
+ * packet, the Combo does not respond, instead terminating the connection
+ * and producing a [BluetoothException]. If this happens, this function
+ * increments the nonce and tries again. This is done multiple times
+ * until either the connection setup succeeds or the maximum number of
+ * attempts is reached. In the latter case, this function throws a
+ * [ConnectionRequestIsNotBeingAcceptedException]. The user should then
+ * be recommended to re-pair with the Combo, since establishing a connection
+ * isn't working.
+ *
+ * @throws IllegalStateException if the current state is not
+ * [State.Disconnected] (calling [connect] while a connection is present
+ * makes no sense).
+ * @throws ConnectionRequestIsNotBeingAcceptedException if connecting the
+ * actual Bluetooth socket succeeds, but the Combo does not accept the
+ * packet that requests a connection, and this failed several times
+ * in a row.
+ * @throws AlertScreenException if the pump reports errors or
+ * unhandled warnings during the connection setup and/or
+ * pump checks.
+ * @throws SettingPumpDatetimeFailedException if during the checks,
+ * the pump's datetime was found to be deviating too much from the
+ * actual current datetime, and adjusting the pump's datetime failed.
+ * @throws ExtendedOrMultiwaveBolusActiveException if an extended / multiwave
+ * bolus is active (these are shown on the main screen).
+ */
+ suspend fun connect(maxNumAttempts: Int? = DEFAULT_MAX_NUM_REGULAR_CONNECT_ATTEMPTS) {
+ check(stateFlow.value == State.Disconnected) { "Attempted to connect to pump in the ${stateFlow.value} state" }
+
+ val actualMaxNumAttempts = maxNumAttempts ?: Int.MAX_VALUE
+
+ for (connectionAttemptNr in 1..actualMaxNumAttempts) {
+ connectProgressReporter.reset(Unit)
+
+ logger(LogLevel.DEBUG) { "Attempt no. $connectionAttemptNr to establish connection" }
+
+ connectProgressReporter.setCurrentProgressStage(BasicProgressStage.EstablishingBtConnection(
+ currentAttemptNr = connectionAttemptNr,
+ totalNumAttempts = maxNumAttempts
+ ))
+
+ try {
+ connectInternal()
+ break
+ } catch (e: CancellationException) {
+ connectProgressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled)
+ pumpIO.disconnect()
+ _statusFlow.value = null
+ parsedDisplayFrameStream.resetAll()
+ setState(State.Disconnected)
+ throw e
+ } catch (e: ComboException) {
+ pumpIO.disconnect()
+ _statusFlow.value = null
+ parsedDisplayFrameStream.resetAll()
+ when (e) {
+ // If these exceptions occur, do _not_ try another connection attempt.
+ // Instead, disconnect and forward these exceptions, as if all attempts
+ // failed. That's because these exceptions indicate hard errors that
+ // must be reported ASAP and disallow more connection attempts, at
+ // least attempts without notifying the user.
+ is ExtendedOrMultiwaveBolusActiveException,
+ is SettingPumpDatetimeFailedException,
+ is AlertScreenException -> {
+ setState(State.Error(throwable = e, "Connection error"))
+ throw e
+ }
+ else -> Unit
+ }
+ if (connectionAttemptNr < actualMaxNumAttempts) {
+ logger(LogLevel.DEBUG) { "Got exception while connecting; will try again; exception was: $e" }
+ delay(DELAY_IN_MS_BETWEEN_COMMAND_DISPATCH_ATTEMPTS)
+ continue
+ } else {
+ logger(LogLevel.ERROR) {
+ "Got exception $e while connecting, and max number of " +
+ "connection establishing attempts reached; not trying again"
+ }
+ connectProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(e))
+ setState(State.Error(throwable = e, "Connection error"))
+ throw e
+ }
+ } catch (t: Throwable) {
+ connectProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(t))
+ setState(State.Error(throwable = t, "Connection error"))
+ throw t
+ }
+ }
+ }
+
+ /**
+ * Terminates an ongoing connection previously established by [connect].
+ *
+ * If no connection is ongoing, this does nothing.
+ *
+ * This function resets the pump state and undoes a [State.Error] state.
+ * In case of an error, the user has to call [disconnect] to reset back
+ * to the [State.Disconnected] state. Afterwards, the user can try again
+ * to establish a new connection.
+ *
+ * This sets [statusFlow] to null and [stateFlow] to [State.Disconnected].
+ */
+ suspend fun disconnect() {
+ if (stateFlow.value == State.Disconnected) {
+ logger(LogLevel.DEBUG) { "Ignoring disconnect() call since pump is already disconnected" }
+ return
+ }
+
+ pumpIO.disconnect()
+ _statusFlow.value = null
+ parsedDisplayFrameStream.resetAll()
+ reconnectAttemptsEnabled = false
+ setState(State.Disconnected)
+ }
+
+ /**
+ * [ProgressReporter] flow for keeping track of the progress of [setBasalProfile].
+ */
+ val setBasalProfileFlow = setBasalProfileReporter.progressFlow
+
+ /**
+ * [ProgressReporter] flow for keeping track of the progress of when the pump's basal profile is read.
+ *
+ * This happens when a [connect] call determines that reading
+ * the profile from the pump is necessary at the time of that
+ * function call.
+ */
+ val getBasalProfileFlow = getBasalProfileReporter.progressFlow
+
+ /**
+ * Sets [basalProfile] as the new basal profile to use in the pump.
+ *
+ * This programs the pump to use this basal profile by simulating user
+ * interaction in the remote terminal mode. There is no command-mode
+ * command to directly pass the 24 profile factors to the pump, so
+ * it has to be set by doing the aforementioned simulation. This is
+ * relatively slow, so it is recommended to use [setBasalProfileFlow]
+ * to provide some form of progress indicator (like a progress bar)
+ * to the user.
+ *
+ * If [currentBasalProfile] is not null, this function compares
+ * [basalProfile] to that profile. If their factors equal, this
+ * function does nothing. That way, redundant calls are caught and
+ * ignored. If [currentBasalProfile] is null, or if its factors do
+ * not match those of [basalProfile], then it is set to [basalProfile].
+ *
+ * If [carryOverLastFactor] is set to true (the default value), this function
+ * moves between basal profile factors by pressing the UP and DOWN buttons
+ * simultaneously instead of the MENU button. This copies over the last
+ * factor that was being programmed in to the next factor. If this is false,
+ * the MENU key is pressed. The pump then does not carry over anything to the
+ * next screen; instead, the currently programmed in factor shows up.
+ * Typically, carrying over the last factor is faster, which is why this is
+ * set to true by default. There might be corner cases where setting this to
+ * false results in faster execution, but at the moment, none are known.
+ *
+ * This also checks if setting the profile is actually necessary by comparing
+ * [basalProfile] with [currentBasalProfile]. If these match, this function
+ * does not set anything, and just returns false. Otherwise, it sets the
+ * new profile, sets [basalProfile] as the new [currentBasalProfile],
+ * and returns true. Note that a return value of false is _not_ an error.
+ *
+ * @param basalProfile New basal profile to program into the pump.
+ * @param carryOverLastFactor If set to true, previously programmed in factors
+ * are carried to the next factor while navigating through the profile.
+ * @return true if the profile was actually set, false otherwise.
+ * @throws AlertScreenException if an alert occurs during this call.
+ * @throws IllegalStateException if the current state is not
+ * [State.ReadyForCommands].
+ */
+ suspend fun setBasalProfile(basalProfile: BasalProfile, carryOverLastFactor: Boolean = true) = executeCommand(
+ pumpMode = PumpIO.Mode.REMOTE_TERMINAL,
+ isIdempotent = true,
+ description = SettingBasalProfileCommandDesc(),
+ allowExecutionWhileSuspended = true
+ ) {
+ if (basalProfile == currentBasalProfile) {
+ logger(LogLevel.DEBUG) { "Current basal profile equals the profile that is to be set; ignoring redundant call" }
+ return@executeCommand false
+ }
+
+ setBasalProfileReporter.reset(Unit)
+
+ setBasalProfileReporter.setCurrentProgressStage(RTCommandProgressStage.SettingBasalProfile(0))
+
+ // Refine the adjustment behavior. If the quantity on screen
+ // is only slightly deviating from what we want to configure,
+ // only use short RT button presses.
+ val longRTButtonPressPredicate = fun(targetQuantity: Int, quantityOnScreen: Int): Boolean {
+ val quantityDelta = (targetQuantity - quantityOnScreen).absoluteValue
+ // Distinguish between <1.0 and >= 1.0 IU quantities,
+ // since the granularity of adjustments differs below
+ // and above the 1.0 IU threshold (below, the quantity
+ // is incremented in 0.01 IU steps, above in 0.05 ones).
+ return if ((targetQuantity <= 1000) && (quantityOnScreen <= 1000)) {
+ (quantityDelta >= 150)
+ } else if ((targetQuantity >= 1000) && (quantityOnScreen >= 1000)) {
+ (quantityDelta >= 500)
+ } else {
+ val (quantityBelow1IU, quantityAbove1IU) = if (targetQuantity < quantityOnScreen)
+ Pair(targetQuantity, quantityOnScreen)
+ else
+ Pair(quantityOnScreen, targetQuantity)
+
+ ((1000 - quantityBelow1IU) > 150) || ((quantityAbove1IU - 1000) > 500)
+ }
+ }
+
+ try {
+ val firstBasalRateFactorScreen =
+ navigateToRTScreen(rtNavigationContext, ParsedScreen.BasalRateFactorSettingScreen::class, pumpSuspended)
+
+ // Store the hours at which the current basal rate factor
+ // begins to ensure that during screen cycling we
+ // actually get to the next factor (which begins at
+ // different hours).
+ var previousBeginHour = (firstBasalRateFactorScreen as ParsedScreen.BasalRateFactorSettingScreen).beginTime.hour
+
+ for (index in 0 until basalProfile.size) {
+ val basalFactor = basalProfile[index]
+ adjustQuantityOnScreen(
+ rtNavigationContext,
+ targetQuantity = basalFactor,
+ longRTButtonPressPredicate = longRTButtonPressPredicate,
+ // The in/decrement steps go as follows (the range is for the basal factor on screen):
+ // 0.0 - 0.05 IU : 0.05 IU steps
+ // 0.05 - 1.0 IU : 0.01 IU steps
+ // 1.0 - 10.0 IU : 0.05 IU steps
+ // above 10.0 IU : 0.1 IU steps
+ incrementSteps = arrayOf(Pair(0, 50), Pair(50, 10), Pair(1000, 50), Pair(10000, 100))
+ ) {
+ (it as ParsedScreen.BasalRateFactorSettingScreen).numUnits
+ }
+
+ setBasalProfileReporter.setCurrentProgressStage(RTCommandProgressStage.SettingBasalProfile(index + 1))
+
+ // By pushing MENU or UP_DOWN we move to the next basal rate factor.
+ // If we are at the last factor, and are about to transition back to
+ // the first one again, we always press MENU to make sure the first
+ // factor isn't overwritten by the last factor that got carried over.
+ rtNavigationContext.shortPressButton(
+ if (carryOverLastFactor && (index != (basalProfile.size - 1)))
+ RTNavigationButton.UP_DOWN
+ else
+ RTNavigationButton.MENU
+ )
+
+ rtNavigationContext.resetDuplicate()
+
+ // Wait until we actually get a different BasalRateFactorSettingScreen.
+ // The pump might send us the same screen multiple times, because it
+ // might be blinking, so it is important to wait until the button press
+ // above actually resulted in a change to the screen with the next factor.
+ while (true) {
+ val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue
+ val parsedScreen = parsedDisplayFrame.parsedScreen
+
+ parsedScreen as ParsedScreen.BasalRateFactorSettingScreen
+ if (parsedScreen.beginTime.hour != previousBeginHour) {
+ previousBeginHour = parsedScreen.beginTime.hour
+ break
+ }
+ }
+ }
+
+ // All factors are set. Press CHECK once to get back to the total
+ // basal rate screen, and then CHECK again to store the new profile
+ // and return to the main menu.
+
+ rtNavigationContext.shortPressButton(RTNavigationButton.CHECK)
+ waitUntilScreenAppears(rtNavigationContext, ParsedScreen.BasalRateTotalScreen::class)
+
+ rtNavigationContext.shortPressButton(RTNavigationButton.CHECK)
+ waitUntilScreenAppears(rtNavigationContext, ParsedScreen.MainScreen::class)
+
+ setBasalProfileReporter.setCurrentProgressStage(BasicProgressStage.Finished)
+
+ currentBasalProfile = basalProfile
+
+ return@executeCommand true
+ } catch (e: CancellationException) {
+ setBasalProfileReporter.setCurrentProgressStage(BasicProgressStage.Cancelled)
+ throw e
+ } catch (t: Throwable) {
+ setBasalProfileReporter.setCurrentProgressStage(BasicProgressStage.Error(t))
+ throw t
+ }
+ }
+
+ /**
+ * [ProgressReporter] flow for keeping track of the progress of [setTbr].
+ */
+ val setTbrProgressFlow = setTbrProgressReporter.progressFlow
+
+ /**
+ * Sets the Combo's current temporary basal rate (TBR) via the remote terminal (RT) mode.
+ *
+ * This function suspends until the TBR is fully set. The [tbrProgressFlow]
+ * can be used to get informed about the TBR setting progress. Since setting
+ * a TBR can take a while, it is recommended to make use of this to show
+ * some sort of progress indicator on a GUI.
+ *
+ * If [percentage] is 100, and [force100Percent] is true, any ongoing TBR will be
+ * cancelled. The Combo will produce a W6 warning screen when this happens. This
+ * screen is automatically dismissed by this function before it exits. If instead
+ * [percentage] is 100 but [force100Percent] is false, this function will actually
+ * start a 15-minute TBR of 90% or 110%, depending on the current TBR. (If the
+ * current TBR is less than 100%, a 15-minute 110% TBR is started, otherwise a
+ * 15-minute 90% TBR starts.) This is done to avoid the vibration that accompanies
+ * the aforementioned W6 warning.
+ *
+ * [percentage] must be in the range 0-500 (specifying the % of the TBR),
+ * and an integer multiple of 10.
+ * [durationInMinutes] must be at least 15 (since the Combo cannot do TBRs
+ * that are shorter than 15 minutes), and must an integer multiple of 15.
+ * Maximum allowed duration is 24 hours, so the maximum valid value is 1440.
+ * However, if [percentage] is 100, the value of [durationInMinutes]
+ * is ignored.
+ *
+ * This also automatically cancels any TBR that may be ongoing, replacing it with
+ * the newly set TBR. (This cancelling does not produce any W6 warnings, since
+ * they are instantly replaced by the new TBR.)
+ *
+ * As soon as a TBR is started by this function, [Event.TbrStarted] is emitted
+ * via the [onEvent] callback. Likewise, when a TBR finishes or is cancelled,
+ * [Event.TbrEnded] is emitted.
+ *
+ * @param percentage TBR percentage to set.
+ * @param durationInMinutes TBR duration in minutes to set.
+ * This argument is not used if [percentage] is 100.
+ * @param type Type of the TBR. Only [Tbr.Type.NORMAL], [Tbr.Type.SUPERBOLUS],
+ * and [Tbr.Type.EMULATED_COMBO_STOP] can be used here.
+ * This argument is not used if [percentage] is 100.
+ * @param force100Percent Whether to really set the TBR to 100% (= actually
+ * cancelling an ongoing TBR, which produces a W6 warning) or to fake a
+ * 100% TBR by setting 90% / 110% TBRs (see above).
+ * This argument is only used if [percentage] is 100.
+ * @throws IllegalArgumentException if the percentage is not in the 0-500 range,
+ * or if the percentage value is not an integer multiple of 10, or if
+ * the duration is <15 or not an integer multiple of 15 (see the note
+ * about duration being ignored with percentage 100 above though).
+ * @throws UnexpectedTbrStateException if the TBR that is actually active
+ * after this function finishes does not match the specified percentage
+ * and duration.
+ * @throws ExtendedOrMultiwaveBolusActiveException if an extended / multiwave
+ * bolus is active after setting the TBR. (This should not normally happen,
+ * since it is not possible for users to set such a bolus while also setting
+ * the TBR, but is included for completeness.)
+ * @throws IllegalStateException if the current state is not
+ * [State.ReadyForCommands], or if the pump is suspended after setting the TBR.
+ * @throws AlertScreenException if alerts occurs during this call, and they
+ * aren't a W6 warning (those are handled by this function).
+ */
+ suspend fun setTbr(
+ percentage: Int,
+ durationInMinutes: Int,
+ type: Tbr.Type,
+ force100Percent: Boolean = false
+ ) = executeCommand(
+ pumpMode = PumpIO.Mode.REMOTE_TERMINAL,
+ isIdempotent = true,
+ description = SettingTbrCommandDesc(percentage, durationInMinutes, type, force100Percent)
+ ) {
+ require(type in listOf(Tbr.Type.NORMAL, Tbr.Type.SUPERBOLUS, Tbr.Type.EMULATED_COMBO_STOP)) { "Invalid TBR type" }
+
+ // NOTE: Not using the Tbr class directly as a function argument since
+ // the timestamp property of that class is not useful here. The Tbr
+ // class is rather meant for TBR events.
+
+ val currentStatus = statusFlow.value ?: throw IllegalStateException("Cannot start TBR without a known pump status")
+ var expectedTbrPercentage: Int
+ var expectedTbrDuration: Int
+
+ // In the code below, we always create a Tbr object _before_ calling
+ // setCurrentTbr to make use of the checks in the Tbr constructor.
+ // If percentage and/or durationInMinutes are invalid, these checks
+ // will throw an IllegalArgumentException. We want to do this
+ // before actually setting the TBR.
+
+ if (percentage == 100) {
+ if (currentStatus.tbrPercentage != 100) {
+ if (force100Percent) {
+ setCurrentTbr(100, 0)
+ reportOngoingTbrAsStopped()
+ expectedTbrPercentage = 100
+ expectedTbrDuration = 0
+ } else {
+ val newPercentage = if (currentStatus.tbrPercentage < 100) 110 else 90
+ val tbr = Tbr(
+ timestamp = Clock.System.now(),
+ percentage = newPercentage,
+ durationInMinutes = 15,
+ Tbr.Type.EMULATED_100_PERCENT
+ )
+ setCurrentTbr(percentage = newPercentage, durationInMinutes = 15)
+ reportStartedTbr(tbr)
+ expectedTbrPercentage = newPercentage
+ expectedTbrDuration = 15
+ }
+ } else {
+ // Current status shows that there is no TBR ongoing. This is
+ // therefore a redunant call. Handle this by expecting a 100%
+ // basal rate to make sure the checks below don't throw anything.
+ expectedTbrPercentage = 100
+ expectedTbrDuration = 0
+ logger(LogLevel.INFO) { "TBR was already cancelled" }
+ }
+ } else {
+ val tbr = Tbr(
+ timestamp = Clock.System.now(),
+ percentage = percentage,
+ durationInMinutes = durationInMinutes,
+ type
+ )
+ tbr.checkDurationForCombo()
+ setCurrentTbr(percentage = percentage, durationInMinutes = durationInMinutes)
+ reportStartedTbr(tbr)
+ expectedTbrPercentage = percentage
+ expectedTbrDuration = durationInMinutes
+ }
+
+ // We just set the TBR. Now check the main screen contents to see if
+ // the TBR was actually set, and if so, whether it was set correctly.
+ // If not, throw an exception, since this is an error.
+
+ val mainScreen = waitUntilScreenAppears(rtNavigationContext, ParsedScreen.MainScreen::class)
+ val mainScreenContent = when (mainScreen) {
+ is ParsedScreen.MainScreen -> mainScreen.content
+ else -> throw NoUsableRTScreenException()
+ }
+ logger(LogLevel.DEBUG) {
+ "Main screen content after setting TBR: $mainScreenContent; expected TBR " +
+ "percentage / duration: $expectedTbrPercentage / $expectedTbrDuration"
+ }
+ when (mainScreenContent) {
+ is MainScreenContent.Stopped ->
+ throw IllegalStateException("Combo is in the stopped state after setting TBR")
+
+ is MainScreenContent.ExtendedOrMultiwaveBolus ->
+ throw ExtendedOrMultiwaveBolusActiveException(mainScreenContent)
+
+ is MainScreenContent.Normal -> {
+ if (expectedTbrPercentage != 100) {
+ // We expected a TBR to be active, but there isn't any;
+ // we aren't seen any TBR main screen contents.
+ throw UnexpectedTbrStateException(
+ expectedTbrPercentage = expectedTbrPercentage,
+ expectedTbrDuration = expectedTbrDuration,
+ actualTbrPercentage = 100,
+ actualTbrDuration = 0
+ )
+ }
+ }
+
+ is MainScreenContent.Tbr -> {
+ if (expectedTbrPercentage == 100) {
+ // We expected the TBR to be cancelled, but it isn't.
+ throw UnexpectedTbrStateException(
+ expectedTbrPercentage = 100,
+ expectedTbrDuration = 0,
+ actualTbrPercentage = mainScreenContent.tbrPercentage,
+ actualTbrDuration = mainScreenContent.remainingTbrDurationInMinutes
+ )
+ } else if ((expectedTbrDuration - mainScreenContent.remainingTbrDurationInMinutes) > 2) {
+ // The current TBR duration does not match the programmed one.
+ // We allow a tolerance range of 2 minutes since a little while
+ // may have passed between setting the TBR and reaching this
+ // location in the code.
+ throw UnexpectedTbrStateException(
+ expectedTbrPercentage = expectedTbrPercentage,
+ expectedTbrDuration = expectedTbrDuration,
+ actualTbrPercentage = mainScreenContent.tbrPercentage,
+ actualTbrDuration = mainScreenContent.remainingTbrDurationInMinutes
+ )
+ }
+ }
+ }
+ }
+
+ /**
+ * [ProgressReporter] flow for keeping track of the progress of [deliverBolus].
+ */
+ val bolusDeliveryProgressFlow = bolusDeliveryProgressReporter.progressFlow
+
+ /**
+ * Instructs the pump to deliver the specified bolus amount.
+ *
+ * This function only delivers a standard bolus, no multi-wave / extended ones.
+ * It is currently not known how to command the Combo to deliver those types.
+ *
+ * The function suspends until the bolus was fully delivered or an error occurred.
+ * In the latter case, an exception is thrown. During the delivery, the current
+ * status is periodically retrieved from the pump. [bolusStatusUpdateIntervalInMs]
+ * controls the status update interval. At each update, the bolus state is checked
+ * (that is, whether it is delivering, or it is done, or an error occurred etc.)
+ * The bolus amount that was delivered by that point is communicated via the
+ * [bolusDeliveryProgressFlow].
+ *
+ * To cancel the bolus, simply cancel the coroutine that is suspended by this function.
+ *
+ * Prior to the delivery, the number of units available in the reservoir is checked
+ * by looking at [statusFlow]. If there aren't enough IU in the reservoir, this
+ * function throws [InsufficientInsulinAvailableException].
+ *
+ * After the delivery, this function looks at the Combo's bolus history delta. That
+ * delta is expected to contain exactly one entry - the bolus that was just delivered.
+ * The details in that history delta entry are then emitted as
+ * [Event.StandardBolusInfused] via [onEvent].
+ * If there is no entry, [BolusNotDeliveredException] is thrown. If more than one
+ * bolus entry is detected, [UnaccountedBolusDetectedException] is thrown (this
+ * second case is not expected to ever happen, but is possible in theory). The
+ * history delta is looked at even if an exception is thrown (unless it is one
+ * of the exceptions that were just mentioned). This is because if there is an
+ * error _during_ a bolus delivery, then some insulin might have still be
+ * delivered, and there will be a [Event.StandardBolusInfused] history entry,
+ * probably just not with the insulin amount that was originally planned.
+ * It is still important to report that (partial) delivery, which is done
+ * via [onEvent] just as described above.
+ *
+ * Once that is completed, this function calls [updateStatus] to make sure the
+ * contents of [statusFlow] are up-to-date. A bolus delivery will at least
+ * change the value of [Status.availableUnitsInReservoir] (unless perhaps it
+ * is a very small bolus like 0.1 IU, since that value is given in whole IU units).
+ *
+ * @param bolusAmount Bolus amount to deliver. Note that this is given
+ * in 0.1 IU units, so for example, "57" means 5.7 IU. Valid range
+ * is 0.0 IU to 25.0 IU (that is, integer values 0-250).
+ * @param bolusReason Reason for this standard bolus.
+ * @param bolusStatusUpdateIntervalInMs Interval between status updates,
+ * in milliseconds. Must be at least 1
+ * @throws BolusNotDeliveredException if the pump did not deliver the bolus.
+ * This typically happens because the pump is currently stopped.
+ * @throws BolusCancelledByUserException when the bolus was cancelled by the user.
+ * @throws BolusAbortedDueToErrorException when the bolus delivery failed due
+ * to an error.
+ * @throws UnaccountedBolusDetectedException if after the bolus delivery
+ * more than one bolus is reported in the Combo's bolus history delta.
+ * @throws InsufficientInsulinAvailableException if the reservoir does not
+ * have enough IUs left for this bolus.
+ * @throws IllegalArgumentException if [bolusAmount] is not in the 0-250 range,
+ * or if [bolusStatusUpdateIntervalInMs] is less than 1.
+ * @throws IllegalStateException if the current state is not
+ * [State.ReadyForCommands].
+ * @throws AlertScreenException if alerts occurs during this call, and they
+ * aren't a W6 warning (those are handled by this function).
+ * @throws ExtendedOrMultiwaveBolusActiveException if an extended / multiwave
+ * bolus is active after delivering this standard bolus. (This should not
+ * normally happen, since it is not possible for users to set such a bolus
+ * while also delivering a standard bolus the TBR, but is included for
+ * completeness.)
+ */
+ suspend fun deliverBolus(bolusAmount: Int, bolusReason: StandardBolusReason, bolusStatusUpdateIntervalInMs: Long = 250) = executeCommand(
+ // Instruct executeCommand() to not set the mode on its own.
+ // This function itself switches manually between the
+ // command and remote terminal modes.
+ pumpMode = null,
+ isIdempotent = false,
+ description = DeliveringBolusCommandDesc(bolusAmount, bolusReason)
+ ) {
+ require((bolusAmount > 0) && (bolusAmount <= 250)) {
+ "Invalid bolus amount $bolusAmount (${bolusAmount.toStringWithDecimal(1)} IU)"
+ }
+ require(bolusStatusUpdateIntervalInMs >= 1) {
+ "Invalid bolus status update interval $bolusStatusUpdateIntervalInMs"
+ }
+
+ // Check that there's enough insulin in the reservoir.
+ statusFlow.value?.let { status ->
+ // Round the bolus amount. The reservoir fill level is given in whole IUs
+ // by the Combo, but the bolus amount is given in 0.1 IU units. By rounding
+ // up, we make sure that the check never misses a case where the bolus
+ // request exceeds the fill level. For example, bolus of 1.3 IU, fill
+ // level 1 IU, if we just divided by 10 to convert the bolus to whole
+ // IU units, we'd truncate the 0.3 IU from the bolus, and the check
+ // would think that it's OK, because the reservoir has 1 IU. If we instead
+ // round up, any fractional IU will be taken into account correctly.
+ val roundedBolusIU = (bolusAmount + 9) / 10
+ logger(LogLevel.DEBUG) {
+ "Checking if there is enough insulin in reservoir; reservoir fill level: " +
+ "${status.availableUnitsInReservoir} IU; bolus amount: ${bolusAmount.toStringWithDecimal(1)} IU" +
+ "(rounded: $roundedBolusIU IU)"
+ }
+ if (status.availableUnitsInReservoir < roundedBolusIU)
+ throw InsufficientInsulinAvailableException(bolusAmount, status.availableUnitsInReservoir)
+ } ?: throw IllegalStateException("Cannot deliver bolus without a known pump status")
+
+ // Switch to COMMAND mode for the actual bolus delivery
+ // and for tracking the bolus progress below.
+ pumpIO.switchMode(PumpIO.Mode.COMMAND)
+
+ logger(LogLevel.DEBUG) { "Beginning bolus delivery of ${bolusAmount.toStringWithDecimal(1)} IU" }
+ val didDeliver = pumpIO.deliverCMDStandardBolus(bolusAmount)
+ if (!didDeliver) {
+ logger(LogLevel.ERROR) { "Bolus delivery did not commence" }
+ throw BolusNotDeliveredException(bolusAmount)
+ }
+
+ bolusDeliveryProgressReporter.reset(Unit)
+
+ logger(LogLevel.DEBUG) { "Waiting until bolus delivery is complete" }
+
+ var bolusFinishedCompletely = false
+
+ // The Combo does not send bolus progress information on its own. Instead,
+ // we have to regularly poll the current bolus status. Do that in this loop.
+ // The bolusStatusUpdateIntervalInMs value controls how often we poll.
+ try {
+ while (true) {
+ delay(bolusStatusUpdateIntervalInMs)
+
+ val status = pumpIO.getCMDCurrentBolusDeliveryStatus()
+
+ logger(LogLevel.VERBOSE) { "Got current bolus delivery status: $status" }
+
+ val deliveredAmount = when (status.deliveryState) {
+ ApplicationLayer.CMDBolusDeliveryState.DELIVERING -> bolusAmount - status.remainingAmount
+ ApplicationLayer.CMDBolusDeliveryState.DELIVERED -> bolusAmount
+ ApplicationLayer.CMDBolusDeliveryState.CANCELLED_BY_USER -> {
+ logger(LogLevel.DEBUG) { "Bolus cancelled by user" }
+ throw BolusCancelledByUserException(
+ deliveredAmount = bolusAmount - status.remainingAmount,
+ totalAmount = bolusAmount
+ )
+ }
+ ApplicationLayer.CMDBolusDeliveryState.ABORTED_DUE_TO_ERROR -> {
+ logger(LogLevel.ERROR) { "Bolus aborted due to a delivery error" }
+ throw BolusAbortedDueToErrorException(
+ deliveredAmount = bolusAmount - status.remainingAmount,
+ totalAmount = bolusAmount
+ )
+ }
+ else -> continue
+ }
+
+ bolusDeliveryProgressReporter.setCurrentProgressStage(
+ RTCommandProgressStage.DeliveringBolus(
+ deliveredAmount = deliveredAmount,
+ totalAmount = bolusAmount
+ )
+ )
+
+ if (deliveredAmount >= bolusAmount) {
+ bolusDeliveryProgressReporter.setCurrentProgressStage(BasicProgressStage.Finished)
+ break
+ }
+ }
+
+ bolusFinishedCompletely = true
+ } catch (e: BolusDeliveryException) {
+ // Handle BolusDeliveryException subclasses separately,
+ // since these exceptions are thrown when the delivery
+ // was cancelled by the user or aborted due to an error.
+ // The code further below tries to cancel in case of any
+ // exception, which would make no sense with these.
+ when (e) {
+ is BolusCancelledByUserException ->
+ bolusDeliveryProgressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled)
+ else ->
+ bolusDeliveryProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(e))
+ }
+ throw e
+ } catch (e: Exception) {
+ bolusDeliveryProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(e))
+ try {
+ pumpIO.cancelCMDStandardBolus()
+ } catch (cancelBolusExc: Exception) {
+ logger(LogLevel.ERROR) { "Silently discarding caught exception while cancelling bolus: $cancelBolusExc" }
+ }
+ throw e
+ } finally {
+ // After either the bolus is finished or an error occurred,
+ // check the history delta here. Any bolus entries in the
+ // delta will be communicated to the outside via the onEvent
+ // callback.
+ // Also, if we reach this point after the bolus finished
+ // successfully (so, bolusFinishedCompletely will be true),
+ // check for discrepancies in the history delta. We expect
+ // the delta to contain exactly one StandardBolusInfused
+ // entry. If there are none, or there are more than one,
+ // or there are other bolus entries, something isn't right,
+ // and we throw exceptions. They are _not_ thrown if we reach
+ // this finally block after an exception occurred above
+ // though, since in that case, we just want to look at the
+ // delta to see what happened, whether any (partial) bolus
+ // was delivered. We still need to communicate such events
+ // to the outside even if the bolus delivery did not succeed.
+
+ try {
+ val historyDelta = fetchHistoryDelta()
+
+ if (historyDelta.isEmpty()) {
+ if (bolusFinishedCompletely) {
+ logger(LogLevel.ERROR) { "Bolus delivery did not actually occur" }
+ throw BolusNotDeliveredException(bolusAmount)
+ }
+ } else {
+ var numStandardBolusInfusedEntries = 0
+ var unexpectedBolusEntriesDetected = false
+ scanHistoryDeltaForBolusToEmit(
+ historyDelta,
+ reasonForLastStandardBolusInfusion = bolusReason
+ ) { entry ->
+ when (val detail = entry.detail) {
+ is CMDHistoryEventDetail.StandardBolusInfused -> {
+ numStandardBolusInfusedEntries++
+ if (numStandardBolusInfusedEntries > 1)
+ unexpectedBolusEntriesDetected = true
+ }
+
+ // We ignore this. It always accompanies StandardBolusInfused.
+ is CMDHistoryEventDetail.StandardBolusRequested ->
+ Unit
+
+ else -> {
+ if (detail.isBolusDetail)
+ unexpectedBolusEntriesDetected = true
+ }
+ }
+ }
+
+ if (bolusFinishedCompletely) {
+ if (numStandardBolusInfusedEntries == 0) {
+ logger(LogLevel.ERROR) { "History delta did not contain an entry about bolus infusion" }
+ throw BolusNotDeliveredException(bolusAmount)
+ } else if (unexpectedBolusEntriesDetected) {
+ logger(LogLevel.ERROR) { "History delta contained unexpected additional bolus entries" }
+ throw UnaccountedBolusDetectedException()
+ }
+ }
+ }
+ } finally {
+ // Re-read pump status. At the very least, the number of available
+ // IUs in the reservoir will have changed, so we must update the
+ // status both to make sure that future bolus calls operate with
+ // an up-to-date status and to let the user know the updated
+ // reservoir level via the statusFlow.
+ // We always re-read the pump status, even if the history delta
+ // checks above detected discrepancies, to make sure the status
+ // is up-to-date.
+ pumpIO.switchMode(PumpIO.Mode.REMOTE_TERMINAL)
+ // Not calling updateStatusImpl(), instead calling this directly.
+ // That's because updateStatusImpl() calls executeCommand(),
+ // and here, we already are running in a lambda that's run
+ // by executeCommand().
+ updateStatusByReadingMainAndQuickinfoScreens(switchStatesIfNecessary = true)
+ }
+ }
+ }
+
+ /**
+ * Total daily dosage (TDD) history entry.
+ *
+ * @property date Date of the TDD.
+ * @property totalDailyAmount Total amount of insulin used in that day.
+ * Stored as an integer-encoded-decimal; last 3 digits of that
+ * integer are the 3 most significant fractional digits of the
+ * decimal amount.
+ */
+ data class TDDHistoryEntry(val date: Instant, val totalDailyAmount: Int)
+
+ /**
+ * [ProgressReporter] flow for keeping track of the progress of [fetchTDDHistory].
+ */
+ val tddHistoryProgressFlow = tddHistoryProgressReporter.progressFlow
+
+ /**
+ * Fetches the TDD history.
+ *
+ * This suspends the calling coroutine until the entire TDD history
+ * is fetched, an error occurs, or the coroutine is cancelled.
+ *
+ * @throws IllegalStateException if the current state is not
+ * [State.ReadyForCommands].
+ * @throws AlertScreenException if alerts occurs during this call, and
+ * they aren't a W6 warning (those are handled by this function).
+ */
+ suspend fun fetchTDDHistory() = executeCommand>(
+ pumpMode = PumpIO.Mode.REMOTE_TERMINAL,
+ isIdempotent = true,
+ description = FetchingTDDHistoryCommandDesc()
+ ) {
+ tddHistoryProgressReporter.reset(Unit)
+
+ try {
+ val tddHistoryEntries = mutableListOf()
+
+ val currentSystemDateTime = Clock.System.now()
+ val currentSystemTimeZone = TimeZone.currentSystemDefault()
+ val currentLocalDate = currentSystemDateTime.toLocalDateTime(currentSystemTimeZone).date
+
+ navigateToRTScreen(rtNavigationContext, ParsedScreen.MyDataDailyTotalsScreen::class, pumpSuspended)
+
+ longPressRTButtonUntil(rtNavigationContext, RTNavigationButton.DOWN) { parsedScreen ->
+ if (parsedScreen !is ParsedScreen.MyDataDailyTotalsScreen) {
+ logger(LogLevel.DEBUG) { "Got a non-TDD screen ($parsedScreen) ; stopping TDD history scan" }
+ return@longPressRTButtonUntil LongPressRTButtonsCommand.ReleaseButton
+ }
+
+ val historyEntry = TDDHistoryEntry(
+ // Fix the date since the Combo does not show years in TDD screens.
+ date = parsedScreen.date.withFixedYearFrom(currentLocalDate).atStartOfDayIn(currentPumpUtcOffset!!.asTimeZone()),
+ totalDailyAmount = parsedScreen.totalDailyAmount
+ )
+
+ logger(LogLevel.DEBUG) {
+ "Got TDD history entry ${parsedScreen.index} / ${parsedScreen.totalNumEntries} ; " +
+ "date = ${historyEntry.date} ; " +
+ "TDD = ${historyEntry.totalDailyAmount.toStringWithDecimal(3)}"
+ }
+
+ tddHistoryEntries.add(historyEntry)
+
+ tddHistoryProgressReporter.setCurrentProgressStage(
+ RTCommandProgressStage.FetchingTDDHistory(parsedScreen.index, parsedScreen.totalNumEntries)
+ )
+
+ return@longPressRTButtonUntil if (parsedScreen.index >= parsedScreen.totalNumEntries)
+ LongPressRTButtonsCommand.ReleaseButton
+ else
+ LongPressRTButtonsCommand.ContinuePressingButton
+ }
+
+ return@executeCommand tddHistoryEntries
+ } catch (e: CancellationException) {
+ tddHistoryProgressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled)
+ throw e
+ } catch (e: Exception) {
+ tddHistoryProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(e))
+ throw e
+ }
+ }
+
+ /**
+ * Updates the value of [statusFlow].
+ *
+ * This can be called by the user in the [State.Suspended] and [State.ReadyForCommands]
+ * states. Additionally, the status is automatically updated by [connect]
+ * and after [deliverBolus] finishes (both if bolus delivery succeeds and
+ * if an exception is thrown by that function). This reads information from
+ * the main screen and the quickinfo screen, so it should not be called more
+ * than necessary, since reading remote terminal screens takes some time.
+ *
+ * @throws IllegalStateException if the current state is not
+ * [State.Suspended] or [State.ReadyForCommands].
+ * @throws AlertScreenException if alerts occurs during this call, and
+ * they aren't a W6 warning (those are handled by this function).
+ * @throws ExtendedOrMultiwaveBolusActiveException if an extended / multiwave
+ * bolus is active (these are shown on the main screen).
+ */
+ suspend fun updateStatus() = updateStatusImpl(
+ allowExecutionWhileSuspended = true,
+ allowExecutionWhileChecking = false,
+ switchStatesIfNecessary = true
+ )
+
+ // The functions below are not part of the normal Pump API. They instead
+ // are meant for interactive test applications whose UI contains widgets
+ // for pressing the UP button etc. See PumpIO for a documentation of
+ // what these functions do.
+
+ suspend fun sendShortRTButtonPress(buttons: List) {
+ pumpIO.switchMode(PumpIO.Mode.REMOTE_TERMINAL)
+ pumpIO.sendShortRTButtonPress(buttons)
+ }
+
+ suspend fun sendShortRTButtonPress(button: ApplicationLayer.RTButton) =
+ sendShortRTButtonPress(listOf(button))
+
+ suspend fun startLongRTButtonPress(buttons: List, keepGoing: (suspend () -> Boolean)? = null) {
+ pumpIO.switchMode(PumpIO.Mode.REMOTE_TERMINAL)
+ pumpIO.startLongRTButtonPress(buttons, keepGoing)
+ }
+
+ suspend fun startLongRTButtonPress(button: ApplicationLayer.RTButton, keepGoing: (suspend () -> Boolean)? = null) =
+ startLongRTButtonPress(listOf(button), keepGoing)
+
+ suspend fun stopLongRTButtonPress() =
+ pumpIO.stopLongRTButtonPress()
+
+ suspend fun waitForLongRTButtonPressToFinish() =
+ pumpIO.waitForLongRTButtonPressToFinish()
+
+ suspend fun switchMode(mode: PumpIO.Mode) =
+ pumpIO.switchMode(mode)
+
+ /*************************************
+ *** PRIVATE FUNCTIONS AND CLASSES ***
+ *************************************/
+
+ private fun processDisplayFrame(displayFrame: DisplayFrame?) =
+ parsedDisplayFrameStream.feedDisplayFrame(displayFrame)
+
+ private fun packetReceiverExceptionThrown(e: TransportLayer.PacketReceiverException) {
+ parsedDisplayFrameStream.abortDueToError(e)
+ }
+
+ private inline fun createBasalProgressReporter() =
+ ProgressReporter(
+ listOf(
+ ProgressStageSubtype::class
+ ),
+ Unit
+ ) { _: Int, _: Int, stage: ProgressStage, _: Unit ->
+ // Basal profile access progress is determined by the single
+ // stage in the reporter, which is SettingBasalProfile or
+ // GettingBasalProfile. That stage contains how many basal
+ // profile factors have been accessed so far, which is
+ // suitable for a progress indicator, so we use that for
+ // the overall progress.
+ when (stage) {
+ BasicProgressStage.Finished,
+ is BasicProgressStage.Aborted -> 1.0
+ is RTCommandProgressStage.SettingBasalProfile ->
+ stage.numSetFactors.toDouble() / NUM_COMBO_BASAL_PROFILE_FACTORS.toDouble()
+ is RTCommandProgressStage.GettingBasalProfile ->
+ stage.numSetFactors.toDouble() / NUM_COMBO_BASAL_PROFILE_FACTORS.toDouble()
+ else -> 0.0
+ }
+ }
+
+ private fun setState(newState: State) {
+ val oldState = _stateFlow.value
+
+ if (oldState == newState)
+ return
+
+ _stateFlow.value = newState
+
+ logger(LogLevel.DEBUG) { "Setting Combo driver state: old: $oldState new: $newState" }
+ }
+
+ private suspend fun executeCommand(
+ pumpMode: PumpIO.Mode?,
+ isIdempotent: Boolean,
+ description: CommandDescription,
+ allowExecutionWhileSuspended: Boolean = false,
+ allowExecutionWhileChecking: Boolean = false,
+ block: suspend CoroutineScope.() -> T
+ ): T {
+ check(
+ (stateFlow.value == State.ReadyForCommands) ||
+ (allowExecutionWhileSuspended && (stateFlow.value == State.Suspended)) ||
+ (allowExecutionWhileChecking && (stateFlow.value == State.CheckingPump))
+ ) { "Cannot execute command in the ${stateFlow.value} state" }
+
+ val previousState = stateFlow.value
+ if (stateFlow.value != State.CheckingPump)
+ setState(State.ExecutingCommand(description))
+
+ try {
+ // Verify that there have been no errors/warnings since the last time
+ // a command was executed. The Combo is not capable of pushing a
+ // notification to ComboCtl. Instead, ComboCtl has to check for the
+ // presence of command mode error/warning flags and/or look for the
+ // presence of alert screens manually.
+ checkForAlerts()
+
+ var retval: T? = null
+
+ var needsToReconnect = false
+
+ // Reset these to guarantee that the handleAlertScreenContent()
+ // calls don't use stale states.
+ rtScreenAlreadyDismissed = false
+ lastObservedAlertScreenContent = null
+
+ // A command execution is attempted a number of times. That number
+ // depends on whether it is an idempotent command. If it is, then
+ // it is possible to retry multiple times if command execution
+ // failed due to certain specific exceptions. (Any other exceptions
+ // are just rethrown; no more attempts are made then.)
+ var attemptNr = 0
+ val maxNumAttempts = if (isIdempotent) NUM_IDEMPOTENT_COMMAND_DISPATCH_ATTEMPTS else 1
+ var doAlertCheck = false
+ var commandSucceeded = false
+
+ while (!commandSucceeded && (attemptNr < maxNumAttempts)) {
+ try {
+ if (needsToReconnect) {
+ // Wait a while before attempting to reconnect. IO failure
+ // typically happens due to Bluetooth problems (including
+ // non-technical ones like when the pump is out of reach)
+ // and pump specific cases like when the user presses a
+ // button on the pump and enables its local UI (this
+ // terminates the Bluetooth connection). In these cases,
+ // it is useful to wait a bit to give the pump and/or the
+ // Bluetooth stack some time to recover. This also
+ // prevents busy loops that use 100% CPU.
+ delay(DELAY_IN_MS_BETWEEN_COMMAND_DISPATCH_ATTEMPTS)
+ reconnect()
+ // Check for alerts right after reconnect since the earlier
+ // disconnect may have produced an alert. For example, if
+ // a TBR was being set, and the pump got disconnected, a
+ // W6 alert will have been triggered.
+ checkForAlerts()
+ needsToReconnect = false
+ logger(LogLevel.DEBUG) { "Pump successfully reconnected" }
+ }
+
+ if (pumpMode != null)
+ pumpIO.switchMode(pumpMode)
+
+ retval = coroutineScope {
+ block.invoke(this)
+ }
+
+ doAlertCheck = true
+
+ commandSucceeded = true
+ } catch (e: CancellationException) {
+ // Do this check after cancelling, since when some commands
+ // are cancelled (like a TBR for example), warnings can appear.
+ doAlertCheck = true
+ throw e
+ } catch (e: AlertScreenException) {
+ // We enter this catch block if any alert screens appear
+ // _during_ the command execution. (doAlertCheck is about
+ // alerts that happen _after_ command execution, like a W6
+ // that appears after setting a 100% TBR.) In such a case,
+ // the command is considered aborted, and we have to try again
+ // (if isIdempotent is set to true).
+ handleAlertScreenContent(e.alertScreenContent)
+ } catch (e: TransportLayer.PacketReceiverException) {
+ // When the pump terminates the connection, this can happen either
+ // through an ErrorCodeException (if the pump sends a packet with an
+ // error code), or through a ComboIOException (in case of IO errors
+ // because the Combo terminated the connection). Interpret both as a
+ // "pump terminated connection" case to initiate a reconnect attempt.
+ val pumpTerminatedConnection = when (val it = e.cause) {
+ is ApplicationLayer.ErrorCodeException -> it.appLayerPacket.command == ApplicationLayer.Command.CTRL_DISCONNECT
+ is ComboIOException -> true
+ else -> false
+ }
+
+ // Packet receiver exceptions can happen for a number of reasons.
+ // To be on the safe side, we only try to reconnect if the exception
+ // happened due to the Combo terminating the connection on its end.
+
+ if (pumpTerminatedConnection) {
+ if (!reconnectAttemptsEnabled) {
+ logger(LogLevel.DEBUG) {
+ "Pump terminated connection, and reconnect attempts are currently disabled"
+ }
+ throw e
+ } else if (isIdempotent) {
+ logger(LogLevel.DEBUG) { "Pump terminated connection; will try to reconnect since this is an idempotent command" }
+ needsToReconnect = true
+ } else {
+ logger(LogLevel.DEBUG) {
+ "Pump terminated connection, but will not try to reconnect since this is a non-idempotent command"
+ }
+ throw e
+ }
+ } else
+ throw e
+ } catch (e: ComboIOException) {
+ // IO exceptions typically happen because of connection failure.
+ // This includes cases like when the pump and phone are out of
+ // reach. Try to reconnect if this is an idempotent command.
+
+ if (!reconnectAttemptsEnabled) {
+ logger(LogLevel.DEBUG) {
+ "Combo IO exception $e occurred, but reconnect attempts are currently disabled"
+ }
+ throw e
+ } else if (isIdempotent) {
+ logger(LogLevel.DEBUG) { "Combo IO exception $e occurred; will try to reconnect since this is an idempotent command" }
+ needsToReconnect = true
+ } else {
+ // Don't bother if this command is not idempotent, since in that
+ // case, we can only perform one single attempt anyway.
+ logger(LogLevel.DEBUG) {
+ "Combo IO exception $e occurred, but will not try to reconnect since this is a non-idempotent command"
+ }
+ throw e
+ }
+ } finally {
+ if (doAlertCheck) {
+ // Post-command check in case something went wrong
+ // and an alert screen appeared after the command ran.
+ // Most commonly, these are benign warning screens,
+ // especially W6, W7, W8.
+ // Using a NonCancellable context in case the command
+ // was aborted by cancellation (like a cancelled bolus).
+ // Without this context, the checkForAlerts() call would
+ // not actually do anything.
+ withContext(NonCancellable) {
+ checkForAlerts()
+ }
+ }
+ }
+
+ attemptNr++
+ }
+
+ if (commandSucceeded) {
+ setState(previousState)
+ // retval is non-null precisely when the command succeeded.
+ return retval!!
+ } else throw CommandExecutionAttemptsFailedException()
+ } catch (e: CancellationException) {
+ // Command was cancelled. Revert to the previous state (since cancellation
+ // is not an error), then rethrow the CancellationException to maintain
+ // structured concurrency.
+ setState(previousState)
+ throw e
+ } catch (e: AlertScreenException) {
+ if (e.alertScreenContent is AlertScreenContent.Error) {
+ // If we reach this point, then an alert screen with an error
+ // code showed up. That screen was dismissed and an exception
+ // was thrown to inform us about that error. Importantly, after
+ // such an error screen, the Combo automatically switches to
+ // its stopped (= suspended) state. And during this state,
+ // the Combo suspends all insulin delivery, effectively behaving
+ // like a 0% TBR. Report this state as such to the caller
+ // via onEvent().
+ reportPumpSuspendedTbr()
+ }
+ setState(State.Error(throwable = e, "Unhandled alert screen during command execution"))
+ throw e
+ } catch (t: Throwable) {
+ setState(State.Error(throwable = t, "Command execution error"))
+ throw t
+ }
+ }
+
+ // This is separate from updateStatus() to prevent that call from
+ // being made during the CHECKING state by the user.
+ // Internally, we sometimes have to update the status during that
+ // state, and this is why this function exists - internal status
+ // updates are then done by calling this instead (with
+ // allowExecutionWhileChecking set to true).
+ private suspend fun updateStatusImpl(
+ allowExecutionWhileSuspended: Boolean,
+ allowExecutionWhileChecking: Boolean,
+ switchStatesIfNecessary: Boolean
+ ) = executeCommand(
+ pumpMode = PumpIO.Mode.REMOTE_TERMINAL,
+ isIdempotent = true,
+ allowExecutionWhileSuspended = allowExecutionWhileSuspended,
+ allowExecutionWhileChecking = allowExecutionWhileChecking,
+ description = UpdatingPumpStatusCommandDesc()
+ ) {
+ updateStatusByReadingMainAndQuickinfoScreens(switchStatesIfNecessary)
+ }
+
+ private suspend fun checkForAlerts() {
+ // Alert checks differ depending on the currently active mode.
+ // That's because we want to avoid unnecessary mode changes
+ // that take extra time to complete.
+ //
+ // If we are in the command mode, then we can right away send
+ // a CMD_READ_ERROR_WARNING_STATUS packet to see if an error
+ // and/or warning is active right now. Only if one is active
+ // do we switch to the remote terminal mode to read the alert
+ // screen contents and dismiss the screen (if appropriate).
+ //
+ // If we are in the remote terminal mode, sending the
+ // CMD_READ_ERROR_WARNING_STATUS packet would require switching
+ // to the command mode first. In this situation, it is instead
+ // easier and quicker to just peek at the current RT display frame
+ // and check if it shows an alert screen. If so, read its contents
+ // and dismiss it (if appropriate).
+ if (currentModeFlow.value == PumpIO.Mode.COMMAND) {
+ val pumpStatus = pumpIO.readCMDErrorWarningStatus()
+ if (pumpStatus.warningOccurred || pumpStatus.errorOccurred) {
+ pumpIO.switchMode(PumpIO.Mode.REMOTE_TERMINAL)
+ handleAlertScreen()
+ }
+ } else {
+ while (true) {
+ // Loop until we get a non-blinked out screen (when blinked out,
+ // getParsedDisplayFrame() returns null).
+ val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(
+ filterDuplicates = false,
+ processAlertScreens = false
+ ) ?: continue
+
+ // If the pump indeed is currently showing an alert screen,
+ // handle it, passing the already seen screen to handleAlertScreen()
+ // to be able to analyze that immediately. If no such alert screen
+ // is shown however, reset the duplicate detection - subsequent
+ // calls may want to call getParsedDisplayFrame() with filterDuplicates
+ // set to true, which would cause that function call to hang, since
+ // the rtNavigationContext would store the already seen screen for
+ // detecting duplicates.
+ val parsedScreen = parsedDisplayFrame.parsedScreen
+ if (parsedScreen is ParsedScreen.AlertScreen)
+ handleAlertScreen(parsedScreen)
+ else
+ rtNavigationContext.resetDuplicate()
+
+ break
+ }
+ }
+ }
+
+ private suspend fun handleAlertScreen(previouslySeenAlertScreen: ParsedScreen.AlertScreen? = null) {
+ // previouslySeenAlertScreen is a way for the caller to pass an alert
+ // screen to here that the caller already observed. This allows us here
+ // to skip a getParsedDisplayFrame() call during the first iteration
+ // of this loop. That call would be redundant, since the first time
+ // we arrive here, the caller may already know that an alert screen
+ // appeared. Thus, if previouslySeenAlertScreen is not null, use its
+ // contents during the first iteration instead of getting a parsed
+ // display frame from the rtNavigationContext. But only do this during
+ // the first iteration, since handleAlertScreenContent() causes
+ // changes that also cause the RT screen to change.
+ var previouslySeenAlertScreenInternal = previouslySeenAlertScreen
+ while (true) {
+ val alertScreenContent = if (previouslySeenAlertScreenInternal != null) {
+ val content = previouslySeenAlertScreenInternal.content
+ previouslySeenAlertScreenInternal = null
+ content
+ } else {
+ val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(
+ filterDuplicates = true,
+ processAlertScreens = false
+ ) ?: continue
+ val parsedScreen = parsedDisplayFrame.parsedScreen
+
+ if (parsedScreen !is ParsedScreen.AlertScreen)
+ break
+ else
+ parsedScreen.content
+ }
+
+ logger(LogLevel.DEBUG) {
+ "Got alert screen with content $alertScreenContent"
+ }
+ handleAlertScreenContent(alertScreenContent)
+ }
+ }
+
+ private suspend fun handleAlertScreenContent(alertScreenContent: AlertScreenContent) {
+ when (alertScreenContent) {
+ // Alert screens blink. When the content is "blinked out",
+ // the warning/error code is hidden, and the screen contents
+ // cannot be recognized. We just ignore such blinked-out alert
+ // screens, since they are not an error. The next time
+ // handleAlertScreenContent() is called, we hopefully
+ // get recognizable content.
+ is AlertScreenContent.None -> Unit
+
+ // Error screen contents always cause a rethrow since all error
+ // screens are considered non-recoverable errors that must not
+ // be ignored / dismissed. Instead, let the code fail by rethrowing
+ // the exception. The user needs to check out the error manually.
+ is AlertScreenContent.Error -> throw AlertScreenException(alertScreenContent)
+
+ is AlertScreenContent.Warning -> {
+ // Check if the alert screen content changed in case
+ // several warnings appear one after the other. In
+ // such a case, we need to reset the dismissal count
+ // to be able to properly dismiss followup warnings.
+ if (lastObservedAlertScreenContent != alertScreenContent) {
+ lastObservedAlertScreenContent = alertScreenContent
+ rtScreenAlreadyDismissed = false
+ }
+
+ val warningCode = alertScreenContent.code
+
+ // W1 is the "reservoir almost empty" warning. Notify the caller
+ // about this, then dismiss it.
+ // W2 is the "battery almost empty" warning. Notify the caller
+ // about this, then dismiss it.
+ // W6 informs about an aborted TBR.
+ // W7 informs about a finished TBR. (This warning can be turned
+ // off permanently through the Accu-Check 360 software, but
+ // in case it wasn't turned off, we still handle it here.)
+ // W8 informs about an aborted bolus.
+ // W3 alerts that date and time need to be reviewed.
+ // W6, W7, W8 are purely informational, and can be dismissed
+ // and ignored.
+ // Any other warnings are intentionally rethrown for safety.
+ when (warningCode) {
+ 1 -> onEvent(Event.ReservoirLow)
+ 2 -> onEvent(Event.BatteryLow)
+ 3, 6, 7, 8 -> Unit
+ else -> throw AlertScreenException(alertScreenContent)
+ }
+
+ // Warning screens are dismissed by pressing CHECK twice.
+ // First time, the CHECK button press transitions the state
+ // on that screen from alert to confirm. Second time, the
+ // screen is finally dismissed. This holds true even if
+ // the screen blinks in between; the Combo still registers
+ // the two button presses, so there is no need to wait
+ // for the second screen - just press twice right away.
+ if (!rtScreenAlreadyDismissed) {
+ logger(LogLevel.DEBUG) { "Dismissing W$warningCode by short-pressing CHECK twice" }
+ rtNavigationContext.shortPressButton(RTNavigationButton.CHECK)
+ rtNavigationContext.shortPressButton(RTNavigationButton.CHECK)
+ rtScreenAlreadyDismissed = true
+ }
+ }
+ }
+ }
+
+ // The actual connect function. This has no exception handling or reconnect
+ // logic, and that is on purpose. This is the internal connect logic that
+ // callers then surround with their error handling code.
+ private suspend fun connectInternal() {
+ // Prevent reconnect() from being called if post-connect check commands
+ // fail (like the command to read the basal profile), since this internal
+ // connect function is explicitly meant to _not_ do things like attempting
+ // to reconnect in case of failures - that's up to the callers.
+ reconnectAttemptsEnabled = false
+
+ setState(State.Connecting)
+
+ try {
+ // Get the current pump state UTC offset to translate localtime
+ // timestamps from the history delta to Instant timestamps.
+ currentPumpUtcOffset = pumpStateStore.getCurrentUtcOffset(bluetoothDevice.address)
+
+ // Set the command mode as the initial mode to be able
+ // to directly check for warnings / errors through the
+ // CMD_READ_PUMP_STATUS command.
+ pumpIO.connect(initialMode = PumpIO.Mode.COMMAND, runHeartbeat = true, connectProgressReporter = connectProgressReporter)
+ connectProgressReporter.setCurrentProgressStage(BasicProgressStage.Finished)
+
+ setState(State.CheckingPump)
+ performOnConnectChecks()
+ } finally {
+ reconnectAttemptsEnabled = true
+ }
+
+ setState(if (pumpSuspended) State.Suspended else State.ReadyForCommands)
+ }
+
+ // Utility code to add a log line that specifically records
+ // that this is a *re*connect attempt.
+ private suspend fun reconnect() {
+ logger(LogLevel.DEBUG) { "Reconnecting Combo with address ${bluetoothDevice.address}" }
+ disconnect()
+ connectProgressReporter.reset(Unit)
+ connectInternal()
+ }
+
+ // The block allows callers to perform their own processing for each
+ // history delta entry, for example to check for unaccounted boluses.
+ private fun scanHistoryDeltaForBolusToEmit(
+ historyDelta: List,
+ reasonForLastStandardBolusInfusion: StandardBolusReason = StandardBolusReason.NORMAL,
+ block: (historyEntry: ApplicationLayer.CMDHistoryEvent) -> Unit = { }
+ ) {
+ var lastBolusId = 0L
+ var lastBolusAmount = 0
+ var lastBolusInfusionTimestamp: Instant? = null
+ var lastStandardBolusRequestedTypeSet = false
+ var lastStandardBolusInfusedTypeSet = false
+
+ // Traverse the history delta in reverse order. The last entries
+ // are the most recent ones, and we are particularly interested
+ // in details about the last (standard) bolus. By traversing
+ // in reverse, we encounter the last (standard) bolus first.
+ historyDelta.reversed().onEach { entry ->
+ block(entry)
+
+ val timestamp = entry.timestamp.toInstant(currentPumpUtcOffset!!)
+
+ when (val detail = entry.detail) {
+ is CMDHistoryEventDetail.QuickBolusRequested ->
+ onEvent(Event.QuickBolusRequested(
+ bolusId = entry.eventCounter,
+ timestamp = timestamp,
+ bolusAmount = detail.bolusAmount
+ ))
+ is CMDHistoryEventDetail.QuickBolusInfused -> {
+ onEvent(Event.QuickBolusInfused(
+ bolusId = entry.eventCounter,
+ timestamp = timestamp,
+ bolusAmount = detail.bolusAmount
+ ))
+ if (lastBolusInfusionTimestamp == null) {
+ lastBolusId = entry.eventCounter
+ lastBolusAmount = detail.bolusAmount
+ lastBolusInfusionTimestamp = timestamp
+ }
+ }
+ is CMDHistoryEventDetail.StandardBolusRequested -> {
+ val standardBolusReason =
+ if (lastStandardBolusRequestedTypeSet) StandardBolusReason.NORMAL else reasonForLastStandardBolusInfusion
+ onEvent(Event.StandardBolusRequested(
+ bolusId = entry.eventCounter,
+ timestamp = timestamp,
+ manual = detail.manual,
+ bolusAmount = detail.bolusAmount,
+ standardBolusReason = standardBolusReason
+ ))
+ lastStandardBolusRequestedTypeSet = true
+ }
+ is CMDHistoryEventDetail.StandardBolusInfused -> {
+ val standardBolusReason =
+ if (lastStandardBolusInfusedTypeSet) StandardBolusReason.NORMAL else reasonForLastStandardBolusInfusion
+ onEvent(Event.StandardBolusInfused(
+ bolusId = entry.eventCounter,
+ timestamp = timestamp,
+ manual = detail.manual,
+ bolusAmount = detail.bolusAmount,
+ standardBolusReason = standardBolusReason
+ ))
+ lastStandardBolusInfusedTypeSet = true
+ if (lastBolusInfusionTimestamp == null) {
+ lastBolusId = entry.eventCounter
+ lastBolusAmount = detail.bolusAmount
+ lastBolusInfusionTimestamp = timestamp
+ }
+ }
+ is CMDHistoryEventDetail.ExtendedBolusStarted ->
+ onEvent(Event.ExtendedBolusStarted(
+ bolusId = entry.eventCounter,
+ timestamp = timestamp,
+ totalBolusAmount = detail.totalBolusAmount,
+ totalDurationMinutes = detail.totalDurationMinutes
+ ))
+ is CMDHistoryEventDetail.ExtendedBolusEnded -> {
+ onEvent(Event.ExtendedBolusEnded(
+ bolusId = entry.eventCounter,
+ timestamp = timestamp,
+ totalBolusAmount = detail.totalBolusAmount,
+ totalDurationMinutes = detail.totalDurationMinutes
+ ))
+ }
+ is CMDHistoryEventDetail.MultiwaveBolusStarted ->
+ onEvent(Event.MultiwaveBolusStarted(
+ bolusId = entry.eventCounter,
+ timestamp = timestamp,
+ totalBolusAmount = detail.totalBolusAmount,
+ immediateBolusAmount = detail.immediateBolusAmount,
+ totalDurationMinutes = detail.totalDurationMinutes
+ ))
+ is CMDHistoryEventDetail.MultiwaveBolusEnded -> {
+ onEvent(Event.MultiwaveBolusEnded(
+ bolusId = entry.eventCounter,
+ timestamp = timestamp,
+ totalBolusAmount = detail.totalBolusAmount,
+ immediateBolusAmount = detail.immediateBolusAmount,
+ totalDurationMinutes = detail.totalDurationMinutes
+ ))
+ }
+ else -> Unit
+ }
+ }
+
+ if (lastBolusInfusionTimestamp != null) {
+ // The last bolus timestamp may be a little bit in advance
+ // of the current time. That's because the datetime is set
+ // through the remote terminal mode, which is slow, which
+ // is why the updatePumpDateTime() call that is done inside
+ // performOnConnectChecks() gets passed a timestamp that is
+ // not the current time, but the current time + 30 seconds.
+ // This compensates for the time it takes to set the new
+ // datetime. But as a side effect, the bolus timestamps may
+ // be ahead of the current time. In such cases, compensate
+ // for that by using the current time instead.
+ val now = Clock.System.now()
+ val bolusTimestamp = lastBolusInfusionTimestamp!!.let {
+ if (now < it) now else it
+ }
+
+ val lastBolus = LastBolus(
+ bolusId = lastBolusId,
+ bolusAmount = lastBolusAmount,
+ timestamp = bolusTimestamp
+ )
+
+ logger(LogLevel.DEBUG) {
+ "Found a last bolus in history delta; details: $lastBolus; now: $now; " +
+ "lastBolusInfusionTimestamp: $lastBolusInfusionTimestamp -> bolusTimestamp: $bolusTimestamp"
+ }
+
+ _lastBolusFlow.value = lastBolus
+ } else
+ logger(LogLevel.DEBUG) { "No last bolus found in history delta" }
+ }
+
+ private suspend fun performOnConnectChecks() {
+ require(currentPumpUtcOffset != null)
+
+ // First few operations will run in command mode.
+ pumpIO.switchMode(PumpIO.Mode.COMMAND)
+
+ // Read history delta, quickinfo etc. as a preparation
+ // for further evaluating the current pump state.
+ val historyDelta = fetchHistoryDelta()
+
+ // This reads information from the main screen and quickinfo screen.
+ // Don't switch states. The caller does that.
+ updateStatusImpl(
+ allowExecutionWhileSuspended = true,
+ allowExecutionWhileChecking = true,
+ switchStatesIfNecessary = false
+ )
+
+ // Read the timestamp when the update is read to be able to determine
+ // below what factor of the current basal profile corresponds to the
+ // factor we see on screen. This is distinct from the other datetimes
+ // we fetch later below, since several operations are in between here
+ // and there, and these operations can take some time to finish.
+ // Since this is done through the command mode, we must make sure we
+ // are in that mode before reading the datetime.
+ pumpIO.switchMode(PumpIO.Mode.COMMAND)
+ val timestampOfStatusUpdate = pumpIO.readCMDDateTime()
+
+ // Scan history delta for unaccounted bolus(es). Report all discovered ones.
+ scanHistoryDeltaForBolusToEmit(historyDelta)
+
+ if (pumpSuspended) {
+ // If the pump is suspended, no insulin is delivered. This behaves like
+ // a 0% TBR. Announce such a "fake 0% TBR" via onEvent to allow the
+ // caller to keep track of these no-delivery situations.
+ reportPumpSuspendedTbr()
+ } else {
+ // Get the current TBR state as recorded in the pump state store, then
+ // retrieve the current status that was updated above by the updateStatusImpl()
+ // call. The status gives us information about what's on the main screen.
+ // If a TBR is currently ongoing, it will show up on the main screen.
+ val currentTbrState = pumpStateStore.getCurrentTbrState(bluetoothDevice.address)
+ val status = statusFlow.value
+ require(status != null)
+
+ // Handle the following four cases:
+ //
+ // 1. currentTbrState is TbrStarted, and no TBR information is shown on the main screen.
+ // Since currentTbrState indicates a started TBR, and the main screen no longer shows an
+ // active TBR, this means that the TBR ended some time ago. Announce the ended TBR as an
+ // event, then set currentTbrState to NoTbrOngoing.
+ // 2. currentTbrState is TbrStarted, and TBR information is shown on the main screen.
+ // Check that the TBR information on screen matches the TBR information from the
+ // TbrStarted state. If there is a mismatch, emit an UnknownTbrDetected event.
+ // Otherwise, do nothing in that case other than a currentTbrFlow value update, since
+ // we know the TBR started earlier and is still ongoing.
+ // 3. currentTbrState is NoTbrOngoing, and no TBR information is shown on the main screen.
+ // Do nothing in that case other than a currentTbrFlow value update, since we already
+ // know that no TBR was ongoing.
+ // 4. currentTbrState is NoTbrOngoing, and TBR information is shown on the main screen.
+ // This is an error - a TBR is ongoing that we don't know about. We did not start it!
+ // End it immediately, then emit an UnknownTbrDetected event to inform the user about
+ // this unexpected TBR. Ideally, this exception leads to an alert shown on the UI.
+ // Also, in this case, we do a hard TBR cancel, which triggers W6, but this is an unusual
+ // situation, so the extra vibration is okay.
+ //
+ // NOTE: When no TBR information is shown on the main screen, the status.tbrPercentage is
+ // always set to 100. When there's TBR information, it is always something other than 100.
+
+ val tbrInfoShownOnMainScreen = (status.tbrPercentage != 100)
+
+ when (currentTbrState) {
+ is CurrentTbrState.TbrStarted -> {
+ if (!tbrInfoShownOnMainScreen) {
+ // Handle case #1.
+
+ val now = Clock.System.now()
+ val currentTbr = currentTbrState.tbr
+ val currentTbrDuration = currentTbr.durationInMinutes.toDuration(DurationUnit.MINUTES)
+ val (endTbrTimestamp, newDurationInMinutes) = if ((now - currentTbr.timestamp) > currentTbrDuration)
+ Pair(currentTbr.timestamp + currentTbrDuration, currentTbr.durationInMinutes)
+ else
+ Pair(now, (now - currentTbr.timestamp).inWholeMinutes.toInt())
+
+ val newTbr = Tbr(
+ timestamp = currentTbr.timestamp,
+ percentage = currentTbr.percentage,
+ durationInMinutes = newDurationInMinutes,
+ currentTbr.type
+ )
+ logger(LogLevel.DEBUG) { "Previously started TBR ended; TBR: $newTbr" }
+ pumpStateStore.setCurrentTbrState(bluetoothDevice.address, CurrentTbrState.NoTbrOngoing)
+ onEvent(Event.TbrEnded(newTbr, endTbrTimestamp))
+ _currentTbrFlow.value = null
+ } else {
+ // Handle case #2.
+
+ val now = Clock.System.now()
+ val expectedCurrentTbrPercentage = currentTbrState.tbr.percentage
+ val actualCurrentTbrPercentage = status.tbrPercentage
+ val elapsedTimeSinceTbrStart = now - currentTbrState.tbr.timestamp
+ val expectedRemainingDurationInMinutes = currentTbrState.tbr.durationInMinutes - elapsedTimeSinceTbrStart.inWholeMinutes.toInt()
+ val actualRemainingDurationInMinutes = status.remainingTbrDurationInMinutes
+
+ // The remaining duration check uses a tolerance range of 10 minutes, since
+ // TBR durations are set in 15-minute steps, and a strict value equality check
+ // would raise false positives due to jitter caused by using the current time.
+ if ((expectedCurrentTbrPercentage != actualCurrentTbrPercentage) ||
+ ((expectedRemainingDurationInMinutes - actualRemainingDurationInMinutes).absoluteValue >= 10)) {
+ logger(LogLevel.DEBUG) {
+ "Unknown/unexpected TBR detected; expected TBR with percentage $expectedCurrentTbrPercentage " +
+ "and remaining duration expectedRemainingDurationInMinutes; actual TBR has percentage " +
+ "$actualRemainingDurationInMinutes and remaining duration $actualRemainingDurationInMinutes"
+ }
+
+ pumpIO.switchMode(PumpIO.Mode.REMOTE_TERMINAL)
+ setCurrentTbr(percentage = 100, durationInMinutes = 0)
+
+ onEvent(Event.UnknownTbrDetected(
+ tbrPercentage = status.tbrPercentage,
+ remainingTbrDurationInMinutes = status.remainingTbrDurationInMinutes
+ ))
+ }
+
+ _currentTbrFlow.value = currentTbrState.tbr
+ }
+ }
+
+ is CurrentTbrState.NoTbrOngoing -> {
+ if (!tbrInfoShownOnMainScreen) {
+ // Handle case #3.
+ _currentTbrFlow.value = null
+ } else {
+ // Handle case #4.
+
+ logger(LogLevel.DEBUG) {
+ "Unknown TBR detected with percentage ${status.tbrPercentage} " +
+ "and remaining duration ${status.remainingTbrDurationInMinutes}; " +
+ "aborting this TBR"
+ }
+
+ pumpIO.switchMode(PumpIO.Mode.REMOTE_TERMINAL)
+ setCurrentTbr(percentage = 100, durationInMinutes = 0)
+
+ onEvent(Event.UnknownTbrDetected(
+ tbrPercentage = status.tbrPercentage,
+ remainingTbrDurationInMinutes = status.remainingTbrDurationInMinutes
+ ))
+ }
+ }
+ }
+ }
+
+ // Make sure that (a) we have a known current basal profile and
+ // (b) that any existing current basal profile is valid.
+ if (currentBasalProfile == null) {
+ logger(LogLevel.DEBUG) { "No current basal profile known; reading the pump's profile now" }
+ currentBasalProfile = getBasalProfile()
+ } else {
+ // Compare the basal factor shown on the RT main screen against the current
+ // factor from the basal profile. If we detect a mismatch, then the profile
+ // that is stored in currentBasalProfile is incorrect and needs to be read
+ // from the pump.
+ val currentBasalRateFactor = statusFlow.value?.currentBasalRateFactor ?: 0
+ if (currentBasalRateFactor != 0) {
+ var currentFactorFromProfile = currentBasalProfile!![timestampOfStatusUpdate.hour]
+ logger(LogLevel.DEBUG) {
+ "Current basal rate factor according to profile: $currentFactorFromProfile; current one" +
+ " according to pump: $currentBasalRateFactor"
+ }
+
+ // We don't read the profile from the pump right away, and instead retry
+ // the check. This is because of an edge case: If we happen to check for
+ // a mismatch at the same moment when the next hour starts and the pump
+ // moves on to the next basal rate factor, we might have gotten a current
+ // pump time that corresponds to one hour and a factor on screen that
+ // corresponds to another hour, leading to a false mismatch. The solution
+ // is to fetch again the pump's current datetime and retry the check.
+ // If there is again a mismatch, then it is a real one.
+ if (currentBasalRateFactor != currentFactorFromProfile) {
+ logger(LogLevel.DEBUG) { "Factors do not match; checking again" }
+
+ val currentPumpTime = pumpIO.readCMDDateTime()
+ currentFactorFromProfile = currentBasalProfile!![currentPumpTime.hour]
+
+ if (currentBasalRateFactor != currentFactorFromProfile) {
+ logger(LogLevel.DEBUG) { "Second check showed again a factor mismatch; reading basal profile" }
+ currentBasalProfile = getBasalProfile()
+ }
+ }
+ }
+ }
+
+ // The next datetime operations will run in command mode again.
+ pumpIO.switchMode(PumpIO.Mode.COMMAND)
+
+ // Get current pump and system datetime _after_ all operations above
+ // finished in case those operations take some time to finish. We need
+ // the datetimes to be as current as possible for the checks below.
+ val currentPumpLocalDateTime = pumpIO.readCMDDateTime()
+ val currentPumpDateTime = currentPumpLocalDateTime.toInstant(currentPumpUtcOffset!!)
+ val currentSystemDateTime = Clock.System.now()
+ val currentSystemTimeZone = TimeZone.currentSystemDefault()
+ val currentSystemUtcOffset = currentSystemTimeZone.offsetAt(currentSystemDateTime)
+ val dateTimeDelta = (currentSystemDateTime - currentPumpDateTime)
+
+ logger(LogLevel.DEBUG) { "History delta size: ${historyDelta.size}" }
+ logger(LogLevel.DEBUG) { "Pump local datetime: $currentPumpLocalDateTime with UTC offset: $currentPumpDateTime" }
+ logger(LogLevel.DEBUG) { "Current system datetime: $currentSystemDateTime" }
+ logger(LogLevel.DEBUG) { "Datetime delta: $dateTimeDelta" }
+
+ // The following checks update the UTC offset in the pump state and
+ // the datetime in the pump. This is done *after* all the checks above
+ // because all the timestamps that we read from the pump's history delta
+ // used a localtime that was tied to the current UTC offset that is
+ // stored in the pump state. The entry.timestamp.toInstant() above must
+ // use this current UTC offset to produce correct results. This is
+ // particularly important during daylight savings changes. Only *after*
+ // the Instant timestamps were all created we can proceed and update the
+ // pump state's UTC offset.
+ // TBRs are not affected by this, because the TBR info we store in the
+ // pump state is already stored as an Instant, so it stores the timezone
+ // offset along with the actual timestamp.
+ // For the same reason, we *first* update the pump's datetime (if there
+ // is a deviation from the system datetime) and *then* update the UTC
+ // offset. The pump is still running with the localtime that is tied
+ // to the old UTC offset.
+
+ // Check if the system's current datetime and the pump's are at least
+ // 2 minutes apart. If so, update the pump's current datetime.
+ // We use a threshold of 2 minutes (= 120 seconds) since (a) the
+ // pump datetime can only be set with a granularity at the minute
+ // level (while getting its current datetime returns seconds), and
+ // (b) setting datetime takes a while because it has to be done
+ // via the RT mode. Having this threshold avoids too frequent
+ // pump datetime updates (which, as said, are rather slow).
+ if (dateTimeDelta.absoluteValue >= 2.toDuration(DurationUnit.MINUTES)) {
+ logger(LogLevel.INFO) {
+ "Current system datetime differs from pump's too much, updating pump datetime; " +
+ "system / pump datetime (UTC): $currentSystemDateTime / $currentPumpDateTime; " +
+ "datetime delta: $dateTimeDelta"
+ }
+ // Shift the pump's new datetime into the future, using a simple
+ // heuristic that estimates how long it will take updatePumpDateTime()
+ // to complete the adjustment. If the difference between the pump's
+ // current datetime and the current system datetime is rather large,
+ // updatePumpDateTime() can take a significant amount of time to
+ // complete (in some extreme cases even more than a minute). It is
+ // possible that by the time the set datetime operation is finished,
+ // the pump's current datetime is already too far or at least almost
+ // too far in the past. By estimating the updatePumpDateTime()
+ // duration and taking it into account, we minimize the chances
+ // of the pump's new datetime being too old already.
+ val newPumpDateTimeShift = estimateDateTimeSetDurationFrom(currentPumpDateTime, currentSystemDateTime, currentSystemTimeZone)
+ updatePumpDateTime(
+ (currentSystemDateTime + newPumpDateTimeShift).toLocalDateTime(currentSystemTimeZone)
+ )
+ } else {
+ logger(LogLevel.INFO) {
+ "Current system datetime is close enough to pump's current datetime, " +
+ "no pump datetime adjustment needed; " +
+ "system / pump datetime (UTC): $currentSystemDateTime / $currentPumpDateTime; " +
+ "datetime delta: $dateTimeDelta"
+ }
+ }
+
+ // Check if the pump's current UTC offset matches that of the system.
+
+ if (currentSystemUtcOffset != currentPumpUtcOffset!!) {
+ logger(LogLevel.INFO) {
+ "System UTC offset differs from pump's; system timezone: $currentSystemTimeZone; " +
+ "system UTC offset: $currentSystemUtcOffset; pump state UTC offset: ${currentPumpUtcOffset!!}; " +
+ "updating pump state"
+ }
+ pumpStateStore.setCurrentUtcOffset(bluetoothDevice.address, currentSystemUtcOffset)
+ currentPumpUtcOffset = currentSystemUtcOffset
+ }
+ }
+
+ private suspend fun fetchHistoryDelta(): List {
+ pumpIO.switchMode(PumpIO.Mode.COMMAND)
+ return pumpIO.getCMDHistoryDelta()
+ }
+
+ private suspend fun getBasalProfile(): BasalProfile = executeCommand(
+ pumpMode = PumpIO.Mode.REMOTE_TERMINAL,
+ isIdempotent = true,
+ description = GettingBasalProfileCommandDesc(),
+ // Allow this since getBasalProfile can be called by connect() during the pump checks.
+ allowExecutionWhileChecking = true
+ ) {
+ getBasalProfileReporter.reset(Unit)
+
+ getBasalProfileReporter.setCurrentProgressStage(RTCommandProgressStage.GettingBasalProfile(0))
+
+ try {
+ val basalProfileFactors = MutableList(NUM_COMBO_BASAL_PROFILE_FACTORS) { -1 }
+
+ navigateToRTScreen(rtNavigationContext, ParsedScreen.BasalRateFactorSettingScreen::class, pumpSuspended)
+
+ var numObservedScreens = 0
+ var numRetrievedFactors = 0
+
+ // Do a long RT MENU button press to quickly navigate
+ // through all basal profile factors, keeping count on
+ // all observed screens and all retrieved factors to
+ // be able to later check if all factors were observed.
+ longPressRTButtonUntil(rtNavigationContext, RTNavigationButton.MENU) { parsedScreen ->
+ if (parsedScreen !is ParsedScreen.BasalRateFactorSettingScreen) {
+ logger(LogLevel.ERROR) { "Got a non-profile screen ($parsedScreen)" }
+ throw UnexpectedRTScreenException(
+ ParsedScreen.BasalRateFactorSettingScreen::class,
+ parsedScreen::class
+ )
+ }
+
+ numObservedScreens++
+
+ val factorIndexOnScreen = parsedScreen.beginTime.hour
+
+ // numUnits null means the basal profile factor
+ // is currently not shown due to blinking.
+ if (parsedScreen.numUnits == null)
+ return@longPressRTButtonUntil LongPressRTButtonsCommand.ContinuePressingButton
+
+ // If the factor in the profile is >= 0,
+ // it means it was already read earlier.
+ if (basalProfileFactors[factorIndexOnScreen] >= 0)
+ return@longPressRTButtonUntil LongPressRTButtonsCommand.ContinuePressingButton
+
+ val factor = parsedScreen.numUnits
+ basalProfileFactors[factorIndexOnScreen] = factor
+ logger(LogLevel.DEBUG) { "Got basal profile factor #$factorIndexOnScreen : $factor" }
+
+ getBasalProfileReporter.setCurrentProgressStage(
+ RTCommandProgressStage.GettingBasalProfile(numRetrievedFactors)
+ )
+
+ numRetrievedFactors++
+
+ return@longPressRTButtonUntil if (numObservedScreens >= NUM_COMBO_BASAL_PROFILE_FACTORS)
+ LongPressRTButtonsCommand.ReleaseButton
+ else
+ LongPressRTButtonsCommand.ContinuePressingButton
+ }
+
+ // Failsafe in the unlikely case that the longPressRTButtonUntil()
+ // call above skipped over some basal profile factors. In such
+ // a case, numRetrievedFactors will be less than 24 (the value of
+ // NUM_COMBO_BASAL_PROFILE_FACTORS).
+ // The corresponding items in the basalProfile int list will be set to
+ // -1, since those items will have been skipped as well. Therefore,
+ // for each negative item, revisit the corresponding screen.
+ if (numRetrievedFactors < NUM_COMBO_BASAL_PROFILE_FACTORS) {
+ for (index in basalProfileFactors.indices) {
+ // We are only interested in those entries that have been
+ // skipped. Those entries are set to their initial value (-1).
+ if (basalProfileFactors[index] >= 0)
+ continue
+
+ logger(LogLevel.DEBUG) { "Re-reading missing basal profile factor $index" }
+
+ shortPressRTButtonsUntil(rtNavigationContext) { parsedScreen ->
+ if (parsedScreen !is ParsedScreen.BasalRateFactorSettingScreen) {
+ logger(LogLevel.ERROR) { "Got a non-profile screen ($parsedScreen)" }
+ throw UnexpectedRTScreenException(
+ ParsedScreen.BasalRateFactorSettingScreen::class,
+ parsedScreen::class
+ )
+ }
+
+ val factorIndexOnScreen = parsedScreen.beginTime.hour
+
+ if (factorIndexOnScreen == index) {
+ // Do nothing if the factor is currently not
+ // shown due to blinking. Eventually, the
+ // factor becomes visible again.
+ val factor = parsedScreen.numUnits ?: return@shortPressRTButtonsUntil ShortPressRTButtonsCommand.DoNothing
+
+ basalProfileFactors[index] = factor
+ logger(LogLevel.DEBUG) { "Got basal profile factor #$index : $factor" }
+
+ // We got the factor, so we can stop short-pressing the RT button.
+ return@shortPressRTButtonsUntil ShortPressRTButtonsCommand.Stop
+ } else {
+ // This is not the correct basal profile factor, so keep
+ // navigating through them to find the correct factor.
+ return@shortPressRTButtonsUntil ShortPressRTButtonsCommand.PressButton(
+ RTNavigationButton.MENU)
+ }
+ }
+
+ getBasalProfileReporter.setCurrentProgressStage(
+ RTCommandProgressStage.GettingBasalProfile(numRetrievedFactors)
+ )
+ numRetrievedFactors++
+ }
+ }
+
+ // All factors retrieved. Press CHECK once to get back to the total
+ // basal rate screen, and then CHECK again to return to the main menu.
+
+ rtNavigationContext.shortPressButton(RTNavigationButton.CHECK)
+ waitUntilScreenAppears(rtNavigationContext, ParsedScreen.BasalRateTotalScreen::class)
+
+ rtNavigationContext.shortPressButton(RTNavigationButton.CHECK)
+ waitUntilScreenAppears(rtNavigationContext, ParsedScreen.MainScreen::class)
+
+ getBasalProfileReporter.setCurrentProgressStage(BasicProgressStage.Finished)
+
+ return@executeCommand BasalProfile(basalProfileFactors)
+ } catch (e: CancellationException) {
+ getBasalProfileReporter.setCurrentProgressStage(BasicProgressStage.Cancelled)
+ throw e
+ } catch (e: Exception) {
+ getBasalProfileReporter.setCurrentProgressStage(BasicProgressStage.Error(e))
+ throw e
+ }
+ }
+
+ // NOTE: The reportPumpSuspendedTbr() and reportStartedTbr() functions
+ // do NOT call setCurrentTbr() themselves. They just report TBR changes,
+ // and do nothing else.
+
+ // If the pump is suspended, there is no insulin delivery. Model this
+ // as a 0% TBR that started just now and lasts for 60 minutes.
+ private fun reportPumpSuspendedTbr() =
+ reportStartedTbr(Tbr(timestamp = Clock.System.now(), percentage = 0, durationInMinutes = 60, Tbr.Type.COMBO_STOPPED))
+
+ private fun reportStartedTbr(tbr: Tbr) {
+ // If a TBR is already ongoing, it will be aborted. We have to
+ // take this into account here, and report the old TBR as ended.
+ reportOngoingTbrAsStopped()
+
+ pumpStateStore.setCurrentTbrState(bluetoothDevice.address, CurrentTbrState.TbrStarted(tbr))
+ onEvent(Event.TbrStarted(tbr))
+ _currentTbrFlow.value = tbr
+ }
+
+ private fun reportOngoingTbrAsStopped() {
+ val currentTbrState = pumpStateStore.getCurrentTbrState(bluetoothDevice.address)
+ if (currentTbrState is CurrentTbrState.TbrStarted) {
+ // In a TemporaryBasalRateEnded event, the timestamp indicates the
+ // time when a TBR ended. The ongoing TBR we know of may already have
+ // expired. If so, we have to be careful to use the correct timestamp.
+ // Compare the duration between the TBR start and now with the duration
+ // as indicated by the TBR's durationInMinutes field. If the duration
+ // between TBR start and now is longer than durationInMinutes, then
+ // the TBR ended a while ago, and the timestamp has to reflect that,
+ // meaning that using the current time as the timestamp would be wrong
+ // in this case. If however the duration between TBR start and now
+ // is _shorter_ than durationInMinutes, it means that we stopped TBR
+ // before its planned end, so using the current time as timestamp
+ // is the correct approach then.
+ val now = Clock.System.now()
+ val tbr = currentTbrState.tbr
+ val tbrDuration = tbr.durationInMinutes.toDuration(DurationUnit.MINUTES)
+ val (endTbrTimestamp, newDurationInMinutes) = if ((now - tbr.timestamp) > tbrDuration)
+ Pair(tbr.timestamp + tbrDuration, tbr.durationInMinutes)
+ else
+ Pair(now, (now - tbr.timestamp).inWholeMinutes.toInt())
+
+ onEvent(Event.TbrEnded(Tbr(
+ timestamp = tbr.timestamp,
+ percentage = tbr.percentage,
+ durationInMinutes = newDurationInMinutes,
+ tbr.type
+ ), endTbrTimestamp))
+ _currentTbrFlow.value = null
+ }
+ }
+
+ private suspend fun updatePumpDateTime(
+ newPumpLocalDateTime: LocalDateTime
+ ) = executeCommand(
+ pumpMode = PumpIO.Mode.REMOTE_TERMINAL,
+ isIdempotent = true,
+ description = UpdatingPumpDateTimeCommandDesc(newPumpLocalDateTime),
+ allowExecutionWhileSuspended = true,
+ allowExecutionWhileChecking = true
+ ) {
+ setDateTimeProgressReporter.reset(Unit)
+
+ // In the time and date setting screens, only long-press the RT button if the
+ // quantity differs by more than 5. 5 and less means <= 5 button short button
+ // presses, which are faster than a long- and short-press sequence.
+ val longRTButtonPressPredicate = fun(targetQuantity: Int, quantityOnScreen: Int): Boolean =
+ ((targetQuantity - quantityOnScreen).absoluteValue) >= PUMP_DATETIME_UPDATE_LONG_RT_BUTTON_PRESS_THRESHOLD
+
+ try {
+ setDateTimeProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingDateTimeHour)
+
+ // Navigate from our current location to the first screen - the hour screen.
+ navigateToRTScreen(rtNavigationContext, ParsedScreen.TimeAndDateSettingsHourScreen::class, pumpSuspended)
+ adjustQuantityOnScreen(
+ rtNavigationContext,
+ targetQuantity = newPumpLocalDateTime.hour,
+ longRTButtonPressPredicate = longRTButtonPressPredicate,
+ cyclicQuantityRange = 24,
+ incrementSteps = arrayOf(Pair(0, 1))
+ ) { parsedScreen ->
+ (parsedScreen as ParsedScreen.TimeAndDateSettingsHourScreen).hour
+ }
+
+ // From here on, we just need to press MENU to move to the next datetime screen.
+
+ setDateTimeProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingDateTimeMinute)
+ rtNavigationContext.shortPressButton(RTNavigationButton.MENU)
+ waitUntilScreenAppears(rtNavigationContext, ParsedScreen.TimeAndDateSettingsMinuteScreen::class)
+ adjustQuantityOnScreen(
+ rtNavigationContext,
+ targetQuantity = newPumpLocalDateTime.minute,
+ longRTButtonPressPredicate = longRTButtonPressPredicate,
+ cyclicQuantityRange = 60,
+ incrementSteps = arrayOf(Pair(0, 1))
+ ) { parsedScreen ->
+ (parsedScreen as ParsedScreen.TimeAndDateSettingsMinuteScreen).minute
+ }
+
+ setDateTimeProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingDateTimeYear)
+ rtNavigationContext.shortPressButton(RTNavigationButton.MENU)
+ waitUntilScreenAppears(rtNavigationContext, ParsedScreen.TimeAndDateSettingsYearScreen::class)
+ adjustQuantityOnScreen(
+ rtNavigationContext,
+ targetQuantity = newPumpLocalDateTime.year,
+ longRTButtonPressPredicate = longRTButtonPressPredicate,
+ incrementSteps = arrayOf(Pair(0, 1))
+ ) { parsedScreen ->
+ (parsedScreen as ParsedScreen.TimeAndDateSettingsYearScreen).year
+ }
+
+ setDateTimeProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingDateTimeMonth)
+ rtNavigationContext.shortPressButton(RTNavigationButton.MENU)
+ waitUntilScreenAppears(rtNavigationContext, ParsedScreen.TimeAndDateSettingsMonthScreen::class)
+ adjustQuantityOnScreen(
+ rtNavigationContext,
+ targetQuantity = newPumpLocalDateTime.monthNumber,
+ longRTButtonPressPredicate = longRTButtonPressPredicate,
+ cyclicQuantityRange = 12,
+ incrementSteps = arrayOf(Pair(0, 1))
+ ) { parsedScreen ->
+ (parsedScreen as ParsedScreen.TimeAndDateSettingsMonthScreen).month
+ }
+
+ // TODO: Set the cyclicQuantityRange for days. This is a little tricky
+ // though, since the exact number of days varies not only between
+ // months, but also between years (see February 29th). See if something
+ // in kotlinx.datetime can be used for this. Avoid self-made calendar
+ // logic here; such logic is easily error prone.
+ setDateTimeProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingDateTimeDay)
+ rtNavigationContext.shortPressButton(RTNavigationButton.MENU)
+ waitUntilScreenAppears(rtNavigationContext, ParsedScreen.TimeAndDateSettingsDayScreen::class)
+ adjustQuantityOnScreen(
+ rtNavigationContext,
+ targetQuantity = newPumpLocalDateTime.dayOfMonth,
+ longRTButtonPressPredicate = longRTButtonPressPredicate,
+ incrementSteps = arrayOf(Pair(0, 1))
+ ) { parsedScreen ->
+ (parsedScreen as ParsedScreen.TimeAndDateSettingsDayScreen).day
+ }
+
+ // Everything configured. Press CHECK to confirm the new datetime.
+ rtNavigationContext.shortPressButton(RTNavigationButton.CHECK)
+
+ setDateTimeProgressReporter.setCurrentProgressStage(BasicProgressStage.Finished)
+ } catch (e: CancellationException) {
+ setDateTimeProgressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled)
+ throw e
+ } catch (e: Exception) {
+ setDateTimeProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(e))
+ throw e
+ }
+ }
+
+ private fun estimateDateTimeSetDurationFrom(
+ currentPumpDateTime: Instant,
+ currentSystemDateTime: Instant,
+ timezone: TimeZone
+ ): Duration {
+ // In here, we use a simple heuristic to estimate how long it will take the updatePumpDateTime()
+ // function to adjust the pump's local datetime to match the system datetime. It looks at the
+ // individual differences in minute/hour/day/month/year values, evaluates them, and decides
+ // based on that how long it should take updatePumpDateTime() to bring the current quantity
+ // to the target quantity. This is a conservative estimate that does not factor in blinked-out
+ // screens and is always shorter than the actual duration updatePumpDateTime() takes to finish
+ // the adjustment. This is important, otherwise we may end up setting the pump's local datetime
+ // to a timestamp that lies in the future. This is OK if it is only maybe at most a few seconds
+ // in the future, but anything beyond that could cause problems, because then, the timestamps
+ // of following bolus deliveries etc. would all be shifted into the future, and potentially
+ // confuse AAPS and its IOB calculations (since the bolus dosage would not be factored in
+ // right away if the bolus timestamp is shifted into the future).
+
+ val currentLocalPumpDateTime = currentPumpDateTime.toLocalDateTime(timezone)
+ val currentLocalSystemDateTime = currentSystemDateTime.toLocalDateTime(timezone)
+
+ fun calcCyclicDistance(begin: Int, end: Int, range: Int): Int {
+ val simpleDistance = (end - begin).absoluteValue
+ return if (simpleDistance <= (range / 2)) simpleDistance else (range - simpleDistance)
+ }
+ fun calcNormalDistance(begin: Int, end: Int): Int {
+ return (end - begin).absoluteValue
+ }
+ fun calcLongRTButtonPressObservationPeriod(distance: Int): Duration {
+ // Check if the distance is large enough to trigger a long RT button press. If so,
+ // factor in 2 seconds. This is the time it takes after the long button
+ // press completes to wait until the quantity post-button press can be read.
+ // After the long RT button press is over, the code observes the RT screen
+ // updates for about 2 seconds before it extracts the new current quantity
+ // from the RT screen.
+ // If however no long RT button press is expected, then no such waiting /
+ // observation period will happen, so return 0 then instead.
+ return (if (distance >= PUMP_DATETIME_UPDATE_LONG_RT_BUTTON_PRESS_THRESHOLD) 2 else 0).toDuration(DurationUnit.SECONDS)
+ }
+
+ // When calculating the "distances" (= how many button in/decrements it takes to reach the
+ // target quantity), also factor in cyclic behavior if there is one.
+ val hourDistance = calcCyclicDistance(currentLocalPumpDateTime.hour, currentLocalSystemDateTime.hour, 24)
+ val minuteDistance = calcCyclicDistance(currentLocalPumpDateTime.minute, currentLocalSystemDateTime.minute, 60)
+ val yearDistance = calcNormalDistance(currentLocalPumpDateTime.year, currentLocalSystemDateTime.year)
+ val monthDistance = calcCyclicDistance(currentLocalPumpDateTime.monthNumber, currentLocalSystemDateTime.monthNumber, 12)
+ val dayDistance = calcNormalDistance(currentLocalPumpDateTime.dayOfMonth, currentLocalSystemDateTime.dayOfMonth)
+ val totalDistance = hourDistance + minuteDistance + yearDistance + monthDistance + dayDistance
+
+ val estimatedDuration =
+ // 2 seconds to account for navigation to the time and date settings screens.
+ 2.toDuration(DurationUnit.SECONDS) +
+ // 1 second per quantity to factor in the waiting period while reading each initial quantity.
+ // We handle 5 quantities (hour/minute/year/month/day), so we factor in 5*1 seconds.
+ 5.toDuration(DurationUnit.SECONDS) +
+ // Factor in the individual factor changes (1 increment/decrement takes ~300 ms to finish).
+ (totalDistance * 300).toDuration(DurationUnit.MILLISECONDS) +
+ // if a long RT button press happens, there's a waiting period after the button press stopped.
+ // IMPORTANT: This is evaluated for each distance individually instead of evaluating
+ // totalDistance once. That's because whether to do long RT button press is decided per-quantity
+ // and not once for all quantities.
+ calcLongRTButtonPressObservationPeriod(hourDistance) +
+ calcLongRTButtonPressObservationPeriod(minuteDistance) +
+ calcLongRTButtonPressObservationPeriod(yearDistance) +
+ calcLongRTButtonPressObservationPeriod(monthDistance) +
+ calcLongRTButtonPressObservationPeriod(dayDistance)
+
+ logger(LogLevel.DEBUG) {
+ "Current local pump / system datetime: $currentLocalPumpDateTime / $currentLocalSystemDateTime " +
+ "; estimated duration: $estimatedDuration"
+ }
+
+ return estimatedDuration
+ }
+
+ private suspend fun setCurrentTbr(
+ percentage: Int,
+ durationInMinutes: Int
+ ) {
+ setTbrProgressReporter.reset(Unit)
+
+ setTbrProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingTBRPercentage(0))
+
+ try {
+ var initialQuantityDistance: Int? = null
+
+ // Only long-press the RT button if we have to increase / decrease
+ // the TBR percentage by more than 50. Otherwise, short-pressing
+ // is sufficient; adjusting by 50 requires only 5 button presses.
+ val longRTButtonPressPercentagePredicate = fun(targetQuantity: Int, quantityOnScreen: Int): Boolean =
+ ((targetQuantity - quantityOnScreen).absoluteValue) >= 50
+
+ // First, set the TBR percentage.
+ navigateToRTScreen(rtNavigationContext, ParsedScreen.TemporaryBasalRatePercentageScreen::class, pumpSuspended)
+ adjustQuantityOnScreen(
+ rtNavigationContext,
+ targetQuantity = percentage,
+ longRTButtonPressPredicate = longRTButtonPressPercentagePredicate,
+ // TBR duration is in/decremented in 10-minute steps
+ incrementSteps = arrayOf(Pair(0, 10))
+ ) {
+ val currentPercentage = (it as ParsedScreen.TemporaryBasalRatePercentageScreen).percentage
+
+ // Calculate the progress out of the "distance" from the
+ // current percentage to the target percentage. As we adjust
+ // the quantity, that "distance" shrinks. When it is 0, we
+ // consider the adjustment to be complete, or in other words,
+ // the settingProgress to be at 100.
+ // In the corner case where the current percentage displayed
+ // on screen is already the target percentage, we just set
+ // settingProgress straight to 100.
+ if (currentPercentage != null) {
+ if (initialQuantityDistance == null) {
+ initialQuantityDistance = currentPercentage - percentage
+ } else {
+ val settingProgress = if (initialQuantityDistance == 0) {
+ 100
+ } else {
+ val currentQuantityDistance = currentPercentage - percentage
+ (100 - currentQuantityDistance * 100 / initialQuantityDistance!!).coerceIn(0, 100)
+ }
+ setTbrProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingTBRPercentage(settingProgress))
+ }
+ }
+
+ currentPercentage
+ }
+
+ // If the percentage is 100%, we are done (and navigating to
+ // the duration screen is not possible). Otherwise, continue.
+ if (percentage != 100) {
+ initialQuantityDistance = null
+
+ // Only long-press the RT button if we have to increase / decrease
+ // the TBR duration by more than 60 minutes. Otherwise, short-pressing
+ // is sufficient; adjusting by 60 minutes requires only 4 button presses.
+ val longRTButtonPressDurationPredicate = fun(targetQuantity: Int, quantityOnScreen: Int): Boolean =
+ ((targetQuantity - quantityOnScreen).absoluteValue) >= 60
+
+ setTbrProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingTBRDuration(0))
+
+ navigateToRTScreen(rtNavigationContext, ParsedScreen.TemporaryBasalRateDurationScreen::class, pumpSuspended)
+
+ adjustQuantityOnScreen(
+ rtNavigationContext,
+ targetQuantity = durationInMinutes,
+ longRTButtonPressPredicate = longRTButtonPressDurationPredicate,
+ // TBR percentage is in/decremented in 15 percentage point steps
+ incrementSteps = arrayOf(Pair(0, 15))
+ ) {
+ val currentDuration = (it as ParsedScreen.TemporaryBasalRateDurationScreen).durationInMinutes
+
+ // Do the settingProgress calculation just like before when setting the percentage.
+ if (currentDuration != null) {
+ if (initialQuantityDistance == null) {
+ initialQuantityDistance = currentDuration - durationInMinutes
+ } else {
+ val settingProgress = if (initialQuantityDistance == 0) {
+ 100
+ } else {
+ val currentQuantityDistance = currentDuration - durationInMinutes
+ (100 - currentQuantityDistance * 100 / initialQuantityDistance!!).coerceIn(0, 100)
+ }
+ setTbrProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingTBRDuration(settingProgress))
+ }
+ }
+
+ currentDuration
+ }
+ }
+
+ setTbrProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingTBRDuration(100))
+
+ // TBR set. Press CHECK to confirm it and exit back to the main menu.
+ rtNavigationContext.shortPressButton(RTNavigationButton.CHECK)
+
+ setTbrProgressReporter.setCurrentProgressStage(BasicProgressStage.Finished)
+ } catch (e: CancellationException) {
+ setTbrProgressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled)
+ throw e
+ } catch (e: Exception) {
+ setTbrProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(e))
+ throw e
+ }
+ }
+
+ private suspend fun updateStatusByReadingMainAndQuickinfoScreens(switchStatesIfNecessary: Boolean) {
+ val mainScreen = navigateToRTScreen(rtNavigationContext, ParsedScreen.MainScreen::class, pumpSuspended)
+
+ val mainScreenContent = when (mainScreen) {
+ is ParsedScreen.MainScreen -> mainScreen.content
+ else -> throw NoUsableRTScreenException()
+ }
+
+ val quickinfoScreen = navigateToRTScreen(rtNavigationContext, ParsedScreen.QuickinfoMainScreen::class, pumpSuspended)
+
+ val quickinfo = when (quickinfoScreen) {
+ is ParsedScreen.QuickinfoMainScreen -> {
+ // After parsing the quickinfo screen, exit back to the main screen by pressing BACK.
+ rtNavigationContext.shortPressButton(RTNavigationButton.BACK)
+ quickinfoScreen.quickinfo
+ }
+ else -> throw NoUsableRTScreenException()
+ }
+
+ _statusFlow.value = when (mainScreenContent) {
+ is MainScreenContent.Normal -> {
+ pumpSuspended = false
+ Status(
+ availableUnitsInReservoir = quickinfo.availableUnits,
+ activeBasalProfileNumber = mainScreenContent.activeBasalProfileNumber,
+ currentBasalRateFactor = mainScreenContent.currentBasalRateFactor,
+ tbrOngoing = false,
+ remainingTbrDurationInMinutes = 0,
+ tbrPercentage = 100,
+ reservoirState = quickinfo.reservoirState,
+ batteryState = mainScreenContent.batteryState
+ )
+ }
+
+ is MainScreenContent.Stopped -> {
+ pumpSuspended = true
+ Status(
+ availableUnitsInReservoir = quickinfo.availableUnits,
+ activeBasalProfileNumber = 0,
+ // The stopped screen does not show any basal rate
+ // factor. Set this to 0 to let the caller know
+ // that the current factor is unknown.
+ currentBasalRateFactor = 0,
+ tbrOngoing = false,
+ remainingTbrDurationInMinutes = 0,
+ tbrPercentage = 0,
+ reservoirState = quickinfo.reservoirState,
+ batteryState = mainScreenContent.batteryState
+ )
+ }
+
+ is MainScreenContent.Tbr -> {
+ pumpSuspended = false
+ Status(
+ availableUnitsInReservoir = quickinfo.availableUnits,
+ activeBasalProfileNumber = mainScreenContent.activeBasalProfileNumber,
+ // The main screen shows the basal rate factor with the TBR
+ // percentage applied (= multiplied) to it. Undo this operation
+ // to get the original basal rate factor. We can't undo a
+ // multiplication by zero though, so just set the rate to 0
+ // if TBR is 0%.
+ currentBasalRateFactor = if (mainScreenContent.tbrPercentage != 0)
+ mainScreenContent.currentBasalRateFactor * 100 / mainScreenContent.tbrPercentage
+ else
+ 0,
+ tbrOngoing = true,
+ remainingTbrDurationInMinutes = mainScreenContent.remainingTbrDurationInMinutes,
+ tbrPercentage = mainScreenContent.tbrPercentage,
+ reservoirState = quickinfo.reservoirState,
+ batteryState = mainScreenContent.batteryState
+ )
+ }
+
+ is MainScreenContent.ExtendedOrMultiwaveBolus ->
+ throw ExtendedOrMultiwaveBolusActiveException(mainScreenContent)
+ }
+
+ if (switchStatesIfNecessary) {
+ // See if the pump was suspended and now isn't anymore, or vice versa.
+ // In these cases, we must update the current state.
+ if (pumpSuspended && (stateFlow.value == State.ReadyForCommands))
+ setState(State.Suspended)
+ else if (!pumpSuspended && (stateFlow.value == State.Suspended))
+ setState(State.ReadyForCommands)
+ }
+ }
+}
diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/PumpManager.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/PumpManager.kt
new file mode 100644
index 0000000000..15ec8117c9
--- /dev/null
+++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/PumpManager.kt
@@ -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()
+
+ /**
+ * 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()
+
+ 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?
+ ) {
+ // 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" }
+ }
+}
diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/RTNavigation.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/RTNavigation.kt
new file mode 100644
index 0000000000..437735a41e
--- /dev/null
+++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/RTNavigation.kt
@@ -0,0 +1,1048 @@
+package info.nightscout.comboctl.main
+
+import info.nightscout.comboctl.base.ApplicationLayer
+import info.nightscout.comboctl.base.ComboException
+import info.nightscout.comboctl.base.Graph
+import info.nightscout.comboctl.base.LogLevel
+import info.nightscout.comboctl.base.Logger
+import info.nightscout.comboctl.base.PumpIO
+import info.nightscout.comboctl.base.connectBidirectionally
+import info.nightscout.comboctl.base.connectDirectionally
+import info.nightscout.comboctl.base.findShortestPath
+import info.nightscout.comboctl.parser.ParsedScreen
+import kotlin.math.absoluteValue
+import kotlin.math.min
+import kotlin.reflect.KClassifier
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+private val logger = Logger.get("RTNavigation")
+
+private const val WAIT_PERIOD_DURING_LONG_RT_BUTTON_PRESS_IN_MS = 110L
+
+/**
+ * RT navigation buttons.
+ *
+ * These are essentially the [ApplicationLayer.RTButton] values, but
+ * also include combined button presses for navigating back (which
+ * requires pressing both MENU and UP buttons at the same time).
+ */
+enum class RTNavigationButton(val rtButtonCodes: List) {
+ UP(listOf(ApplicationLayer.RTButton.UP)),
+ DOWN(listOf(ApplicationLayer.RTButton.DOWN)),
+ MENU(listOf(ApplicationLayer.RTButton.MENU)),
+ CHECK(listOf(ApplicationLayer.RTButton.CHECK)),
+
+ BACK(listOf(ApplicationLayer.RTButton.MENU, ApplicationLayer.RTButton.UP)),
+ UP_DOWN(listOf(ApplicationLayer.RTButton.UP, ApplicationLayer.RTButton.DOWN))
+}
+
+internal data class RTEdgeValue(val button: RTNavigationButton, val edgeValidityCondition: EdgeValidityCondition = EdgeValidityCondition.ALWAYS) {
+ enum class EdgeValidityCondition {
+ ONLY_IF_COMBO_STOPPED,
+ ONLY_IF_COMBO_RUNNING,
+ ALWAYS
+ }
+
+ // Exclude edgeValidityCondition from comparisons. This is mainly
+ // done to make it easier to test the RT navigation code.
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other == null || this::class != other::class) return false
+
+ other as RTEdgeValue
+
+ if (button != other.button) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ return button.hashCode()
+ }
+}
+
+// Directed cyclic graph for navigating between RT screens. The edge
+// values indicate what button to press to reach the edge's target node
+// (= target screen). The button may have to be pressed more than once
+// until the target screen appears if other screens are in between.
+internal val rtNavigationGraph = Graph().apply {
+ // Set up graph nodes for each ParsedScreen, to be able
+ // to connect them below.
+ val mainNode = node(ParsedScreen.MainScreen::class)
+ val quickinfoNode = node(ParsedScreen.QuickinfoMainScreen::class)
+ val tbrMenuNode = node(ParsedScreen.TemporaryBasalRateMenuScreen::class)
+ val tbrPercentageNode = node(ParsedScreen.TemporaryBasalRatePercentageScreen::class)
+ val tbrDurationNode = node(ParsedScreen.TemporaryBasalRateDurationScreen::class)
+ val myDataMenuNode = node(ParsedScreen.MyDataMenuScreen::class)
+ val myDataBolusDataMenuNode = node(ParsedScreen.MyDataBolusDataScreen::class)
+ val myDataErrorDataMenuNode = node(ParsedScreen.MyDataErrorDataScreen::class)
+ val myDataDailyTotalsMenuNode = node(ParsedScreen.MyDataDailyTotalsScreen::class)
+ val myDataTbrDataMenuNode = node(ParsedScreen.MyDataTbrDataScreen::class)
+ val basalRate1MenuNode = node(ParsedScreen.BasalRate1ProgrammingMenuScreen::class)
+ val basalRateTotalNode = node(ParsedScreen.BasalRateTotalScreen::class)
+ val basalRateFactorSettingNode = node(ParsedScreen.BasalRateFactorSettingScreen::class)
+ val timeDateSettingsMenuNode = node(ParsedScreen.TimeAndDateSettingsMenuScreen::class)
+ val timeDateSettingsHourNode = node(ParsedScreen.TimeAndDateSettingsHourScreen::class)
+ val timeDateSettingsMinuteNode = node(ParsedScreen.TimeAndDateSettingsMinuteScreen::class)
+ val timeDateSettingsYearNode = node(ParsedScreen.TimeAndDateSettingsYearScreen::class)
+ val timeDateSettingsMonthNode = node(ParsedScreen.TimeAndDateSettingsMonthScreen::class)
+ val timeDateSettingsDayNode = node(ParsedScreen.TimeAndDateSettingsDayScreen::class)
+
+ // Below, nodes are connected. Connections are edges in the graph.
+
+ // Main screen and quickinfo.
+ connectBidirectionally(RTEdgeValue(RTNavigationButton.CHECK), RTEdgeValue(RTNavigationButton.BACK), mainNode, quickinfoNode)
+
+ connectBidirectionally(
+ RTEdgeValue(RTNavigationButton.MENU), RTEdgeValue(RTNavigationButton.BACK),
+ myDataMenuNode, basalRate1MenuNode
+ )
+
+ // Connection between main menu and time and date settings menu. Note that there
+ // is only this one connection to the time and date settings menu, even though it
+ // is actually possible to reach that menu from for example the basal rate 1
+ // programming one by pressing MENU several times. That's because depending on
+ // the Combo's configuration, significantly more menus may actually lie between
+ // basal rate 1 and time and date settings, causing the navigation to take
+ // significantly longer. Also, in pretty much all cases, any access to the time
+ // and date settings menu starts from the main menu, so it makes sense to establish
+ // only one connection between the main menu and the time and date settings menu.
+ connectBidirectionally(
+ RTEdgeValue(RTNavigationButton.BACK), RTEdgeValue(RTNavigationButton.MENU),
+ mainNode,
+ timeDateSettingsMenuNode
+ )
+
+ // Connections to the TBR menu do not always exist - if the Combo
+ // is stopped, the TBR menu is disabled, so create separate connections
+ // for it and mark them as being invalid if the Combo is stopped to
+ // prevent the RTNavigation code from traversing them if the Combo
+ // is currently in the stopped state.
+
+ // These are the TBR menu connections. In the running state, the
+ // TBR menu is then directly reachable from the main menu and is
+ // placed in between the main and the My Data menu.
+ connectBidirectionally(
+ RTEdgeValue(RTNavigationButton.MENU, RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_RUNNING),
+ RTEdgeValue(RTNavigationButton.BACK, RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_RUNNING),
+ mainNode, tbrMenuNode
+ )
+ connectBidirectionally(
+ RTEdgeValue(RTNavigationButton.MENU, RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_RUNNING),
+ RTEdgeValue(RTNavigationButton.BACK, RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_RUNNING),
+ tbrMenuNode, myDataMenuNode
+ )
+
+ // In the stopped state, the My Data menu can directly be reached from the
+ // main mode, since the TBR menu that is in between is turned off.
+ connectBidirectionally(
+ RTEdgeValue(RTNavigationButton.MENU, RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_STOPPED),
+ RTEdgeValue(RTNavigationButton.BACK, RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_STOPPED),
+ mainNode, myDataMenuNode
+ )
+
+ // These are the connections between TBR screens. A specialty of these
+ // screens is that transitioning between the percentage and duration
+ // screens is done with the MENU screen in both directions
+ // (percentage->duration and duration->percentage). The TBR menu screen
+ // can be reached from both of these screens by pressing BACK. But the
+ // duration screen cannot be reached directly from the TBR menu screen,
+ // which is why there's a direct edge from the duration to the menu
+ // screen but not one in the other direction.
+ connectBidirectionally(RTEdgeValue(RTNavigationButton.CHECK), RTEdgeValue(RTNavigationButton.BACK), tbrMenuNode, tbrPercentageNode)
+ connectBidirectionally(RTEdgeValue(RTNavigationButton.MENU), RTEdgeValue(RTNavigationButton.MENU), tbrPercentageNode, tbrDurationNode)
+ connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), tbrDurationNode, tbrMenuNode)
+
+ // The basal rate programming screens. Going to the basal rate factors requires
+ // two transitions (basal rate 1 -> basal rate total -> basal rate factor).
+ // Going back requires one, but directly goes back to basal rate 1.
+ connectBidirectionally(RTEdgeValue(RTNavigationButton.CHECK), RTEdgeValue(RTNavigationButton.BACK), basalRate1MenuNode, basalRateTotalNode)
+ connectDirectionally(RTEdgeValue(RTNavigationButton.MENU), basalRateTotalNode, basalRateFactorSettingNode)
+ connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), basalRateFactorSettingNode, basalRate1MenuNode)
+
+ // Connections between myData screens. Navigation through these screens
+ // is rather straightforward. Pressing CHECK when at the my data menu
+ // transitions to the bolus data screen. Pressing MENU then transitions
+ // through the various myData screens. The order is: bolus data, error
+ // data, daily totals, TBR data. Pressing MENU when at the TBR data
+ // screen cycles back to the bolus data screen. Pressing BACK in any
+ // of these screens transitions back to the my data menu screen.
+ connectDirectionally(RTEdgeValue(RTNavigationButton.CHECK), myDataMenuNode, myDataBolusDataMenuNode)
+ connectDirectionally(
+ RTEdgeValue(RTNavigationButton.MENU),
+ myDataBolusDataMenuNode, myDataErrorDataMenuNode, myDataDailyTotalsMenuNode, myDataTbrDataMenuNode
+ )
+ connectDirectionally(RTEdgeValue(RTNavigationButton.MENU), myDataTbrDataMenuNode, myDataBolusDataMenuNode)
+ connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), myDataBolusDataMenuNode, myDataMenuNode)
+ connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), myDataErrorDataMenuNode, myDataMenuNode)
+ connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), myDataDailyTotalsMenuNode, myDataMenuNode)
+ connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), myDataTbrDataMenuNode, myDataMenuNode)
+
+ // Time and date settings screen. These work just like the my data screens.
+ // That is: Navigating between the "inner" time and date screens works
+ // by pressing MENU, and when pressing MENU at the last of these screens,
+ // navigation transitions back to the first of these screens. Pressing
+ // BACK transitions back to the time and date settings menu screen.
+ connectDirectionally(RTEdgeValue(RTNavigationButton.CHECK), timeDateSettingsMenuNode, timeDateSettingsHourNode)
+ connectDirectionally(
+ RTEdgeValue(RTNavigationButton.MENU),
+ timeDateSettingsHourNode, timeDateSettingsMinuteNode, timeDateSettingsYearNode,
+ timeDateSettingsMonthNode, timeDateSettingsDayNode
+ )
+ connectDirectionally(RTEdgeValue(RTNavigationButton.MENU), timeDateSettingsDayNode, timeDateSettingsHourNode)
+ connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), timeDateSettingsHourNode, timeDateSettingsMenuNode)
+ connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), timeDateSettingsMinuteNode, timeDateSettingsMenuNode)
+ connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), timeDateSettingsYearNode, timeDateSettingsMenuNode)
+ connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), timeDateSettingsMonthNode, timeDateSettingsMenuNode)
+ connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), timeDateSettingsDayNode, timeDateSettingsMenuNode)
+}
+
+/**
+ * Base class for exceptions thrown when navigating through remote terminal (RT) screens.
+ *
+ * @param message The detail message.
+ */
+open class RTNavigationException(message: String) : ComboException(message)
+
+/**
+ * Exception thrown when the RT navigation could not find a screen of the searched type.
+ *
+ * @property targetScreenType Type of the screen that was searched.
+ */
+class CouldNotFindRTScreenException(val targetScreenType: KClassifier) :
+ RTNavigationException("Could not find RT screen $targetScreenType")
+
+/**
+ * Exception thrown when the RT navigation encountered an unexpected screen type.
+ *
+ * @property expectedScreenType Type of the screen that was expected.
+ * @property encounteredScreenType Type of the screen that was encountered.
+ */
+class UnexpectedRTScreenException(
+ val expectedScreenType: KClassifier,
+ val encounteredScreenType: KClassifier
+) : RTNavigationException("Unexpected RT screen; expected $expectedScreenType, encountered $encounteredScreenType")
+
+/**
+ * Exception thrown when in spite of repeatedly trying to exit to the main screen, no recognizable RT screen is found.
+ *
+ * This is different from [NoUsableRTScreenException] in that the code tried to get out
+ * of whatever unrecognized part of the RT menu and failed because it kept seeing unfamiliar
+ * screens, while that other exception is about not getting a specific RT screen.
+ */
+class CouldNotRecognizeAnyRTScreenException : RTNavigationException("Could not recognize any RT screen")
+
+/**
+ * Exception thrown when a function needed a specific screen type but could not get it.
+ *
+ * Typically, this happens because a display frame could not be parsed
+ * (= the screen is [ParsedScreen.UnrecognizedScreen]).
+ */
+class NoUsableRTScreenException : RTNavigationException("No usable RT screen available")
+
+/**
+ * Remote terminal (RT) navigation context.
+ *
+ * This provides the necessary functionality for functions that navigate through RT screens
+ * like [cycleToRTScreen]. These functions analyze [ParsedScreen] instances contained
+ * in incoming [ParsedDisplayFrame] ones, and apply changes & transitions with the provided
+ * abstract button actions.
+ *
+ * The button press functions are almost exactly like the ones from [PumpIO]. The only
+ * difference is how buttons are specified - the underlying PumpIO functions get the
+ * [RTNavigationButton.rtButtonCodes] value of their "button" arguments, and not the
+ * "button" argument directly.
+ */
+interface RTNavigationContext {
+ /**
+ * Maximum number of times functions like [cycleToRTScreen] can cycle through screens.
+ *
+ * This is a safeguard to prevent infinite loops in case these functions like [cycleToRTScreen]
+ * fail to find the screen they are looking for. This is a quantity that defines how
+ * often these functions can transition to other screens without getting to the screen
+ * they are looking for. Past that amount, they throw [CouldNotFindRTScreenException].
+ *
+ * This is always >= 1, and typically a value like 20.
+ */
+ val maxNumCycleAttempts: Int
+
+ fun resetDuplicate()
+
+ suspend fun getParsedDisplayFrame(filterDuplicates: Boolean, processAlertScreens: Boolean = true): ParsedDisplayFrame?
+
+ suspend fun startLongButtonPress(button: RTNavigationButton, keepGoing: (suspend () -> Boolean)? = null)
+ suspend fun stopLongButtonPress()
+ suspend fun waitForLongButtonPressToFinish()
+ suspend fun shortPressButton(button: RTNavigationButton)
+}
+
+/**
+ * [PumpIO] based implementation of [RTNavigationContext].
+ *
+ * This uses a [PumpIO] instance to pass button actions to, and provides a stream
+ * of [ParsedDisplayFrame] instances. It is the implementation suited for
+ * production use. [maxNumCycleAttempts] is set to 20 by default.
+ */
+class RTNavigationContextProduction(
+ private val pumpIO: PumpIO,
+ private val parsedDisplayFrameStream: ParsedDisplayFrameStream,
+ override val maxNumCycleAttempts: Int = 20
+) : RTNavigationContext {
+ init {
+ require(maxNumCycleAttempts > 0)
+ }
+
+ override fun resetDuplicate() = parsedDisplayFrameStream.resetDuplicate()
+
+ override suspend fun getParsedDisplayFrame(filterDuplicates: Boolean, processAlertScreens: Boolean) =
+ parsedDisplayFrameStream.getParsedDisplayFrame(filterDuplicates, processAlertScreens)
+
+ override suspend fun startLongButtonPress(button: RTNavigationButton, keepGoing: (suspend () -> Boolean)?) =
+ pumpIO.startLongRTButtonPress(button.rtButtonCodes, keepGoing)
+
+ override suspend fun stopLongButtonPress() = pumpIO.stopLongRTButtonPress()
+
+ override suspend fun waitForLongButtonPressToFinish() = pumpIO.waitForLongRTButtonPressToFinish()
+
+ override suspend fun shortPressButton(button: RTNavigationButton) = pumpIO.sendShortRTButtonPress(button.rtButtonCodes)
+}
+
+sealed class ShortPressRTButtonsCommand {
+ object DoNothing : ShortPressRTButtonsCommand()
+ object Stop : ShortPressRTButtonsCommand()
+ data class PressButton(val button: RTNavigationButton) : ShortPressRTButtonsCommand()
+}
+
+sealed class LongPressRTButtonsCommand {
+ object ContinuePressingButton : LongPressRTButtonsCommand()
+ object ReleaseButton : LongPressRTButtonsCommand()
+}
+
+/**
+ * Holds down a specific button until the specified screen check callback returns true.
+ *
+ * This is useful for performing an ongoing activity based on the screen contents.
+ * [adjustQuantityOnScreen] uses this internally for adjusting a quantity on screen.
+ * [button] is kept pressed until [checkScreen] returns [LongPressRTButtonsCommand.ReleaseButton],
+ * at which point that RT button is released.
+ *
+ * NOTE: The RT button may actually be released a little past the time [checkScreen]
+ * indicates that the RT button is to be released. This is due to limitations in how
+ * the RT screen UX works. It is recommended to add checks after running the long RT
+ * button press if the state of the RT screen afterwards is important. For example,
+ * when adjusting a quantity on the RT screen, check afterwards the quantity once it
+ * stops in/decrementing and correct it with short RT button presses if needed.
+ *
+ * @param rtNavigationContext Context to use for the long RT button press.
+ * @param button Button to long-press.
+ * @param checkScreen Callback that returns whether to continue
+ * long-pressing the button or releasing it.
+ * @return The last observed [ParsedScreen].
+ * @throws info.nightscout.comboctl.parser.AlertScreenException if alert screens are seen.
+ */
+suspend fun longPressRTButtonUntil(
+ rtNavigationContext: RTNavigationContext,
+ button: RTNavigationButton,
+ checkScreen: (parsedScreen: ParsedScreen) -> LongPressRTButtonsCommand
+): ParsedScreen {
+ val channel = Channel(capacity = Channel.CONFLATED)
+
+ lateinit var lastParsedScreen: ParsedScreen
+
+ logger(LogLevel.DEBUG) { "Long-pressing RT button $button until predicate indicates otherwise" }
+
+ rtNavigationContext.resetDuplicate()
+
+ coroutineScope {
+ launch {
+ while (true) {
+ val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue
+ val parsedScreen = parsedDisplayFrame.parsedScreen
+ val predicateResult = checkScreen(parsedScreen)
+ val releaseButton = (predicateResult == LongPressRTButtonsCommand.ReleaseButton)
+ logger(LogLevel.VERBOSE) {
+ "Observed parsed screen $parsedScreen while long-pressing RT button; predicate result = $predicateResult"
+ }
+ channel.send(releaseButton)
+ if (releaseButton) {
+ lastParsedScreen = parsedScreen
+ break
+ }
+ }
+ }
+
+ launch {
+ logger(LogLevel.VERBOSE) { "Starting long press RT button coroutine" }
+
+ rtNavigationContext.startLongButtonPress(button) {
+ // This block is called by startLongButtonPress() every time
+ // before sending an RT button update to the Combo. This is
+ // important, because in RT screens that show a quantity that
+ // is to be in/decrement, said in/decrement will not happen
+ // until that update has been sent.
+ //
+ // We send this update regularly, independently of whether
+ // a new screen arrives. This risks overshooting a bit when
+ // in/decrementing (because we might send more than one
+ // RT button update before the quantity on screen visibly
+ // in/decrements), but is the robust alternative to updating
+ // after a screen update. The latter does not overshoot, but
+ // breaks if screen updates only arrive after an RT button
+ // update (this happens in the TDD screen for example).
+ //
+ // Also, when in/decrementing, the Combo's UX has a special
+ // case - when holding down a button, there is one screen
+ // update, followed by a period of inactivity, followed by
+ // more updates. The Combo does this because otherwise it
+ // would not be possible for the user to reliably specify
+ // whether a button press is a short or a long one. This
+ // inactivity period though breaks the second, less robust
+ // option mentioned above.
+ //
+ // Therefore, just send updates regularly, after the
+ // WAIT_PERIOD_DURING_LONG_RT_BUTTON_PRESS_IN_MS period.
+ val receiveAttemptResult = channel.tryReceive()
+ val stop = if (!receiveAttemptResult.isSuccess)
+ false
+ else
+ receiveAttemptResult.getOrThrow()
+
+ if (!stop) {
+ delay(WAIT_PERIOD_DURING_LONG_RT_BUTTON_PRESS_IN_MS)
+ }
+
+ return@startLongButtonPress !stop
+ }
+
+ rtNavigationContext.waitForLongButtonPressToFinish()
+ logger(LogLevel.VERBOSE) { "Stopped long press RT button coroutine" }
+ }
+ }
+
+ logger(LogLevel.DEBUG) { "Long-pressing RT button $button stopped after predicate returned true" }
+
+ return lastParsedScreen
+}
+
+/**
+ * Short-presses a button until the specified screen check callback returns true.
+ *
+ * This is the short-press counterpart to [longPressRTButtonUntil]. For each observed
+ * [ParsedScreen], it invokes the specified [processScreen] callback. That callback
+ * then returns a command, telling this function what to do next. If that command is
+ * [ShortPressRTButtonsCommand.PressButton], this function short-presses the button
+ * specified in that sealed subclass, and then waits for the next [ParsedScreen].
+ * If the command is [ShortPressRTButtonsCommand.Stop], this function finishes.
+ * If the command is [ShortPressRTButtonsCommand.DoNothing], this function skips
+ * the current [ParsedScreen]. The last command is useful for example when the
+ * screen contents are blinking. By returning DoNothing, the callback effectively
+ * causes this function to wait until another screen (hopefully without the blinking)
+ * arrives and can be processed by that callback.
+ *
+ * @param rtNavigationContext Context to use for the short RT button press.
+ * @param processScreen Callback that returns the command this function shall execute next.
+ * @return The last observed [ParsedScreen].
+ * @throws info.nightscout.comboctl.parser.AlertScreenException if alert screens are seen.
+ */
+suspend fun shortPressRTButtonsUntil(
+ rtNavigationContext: RTNavigationContext,
+ processScreen: (parsedScreen: ParsedScreen) -> ShortPressRTButtonsCommand
+): ParsedScreen {
+ logger(LogLevel.DEBUG) { "Repeatedly short-pressing RT button according to callback commands" }
+
+ rtNavigationContext.resetDuplicate()
+
+ while (true) {
+ val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue
+ val parsedScreen = parsedDisplayFrame.parsedScreen
+
+ logger(LogLevel.VERBOSE) { "Got new screen $parsedScreen" }
+
+ val command = processScreen(parsedScreen)
+ logger(LogLevel.VERBOSE) { "Short-press RT button callback returned $command" }
+
+ when (command) {
+ ShortPressRTButtonsCommand.DoNothing -> Unit
+ ShortPressRTButtonsCommand.Stop -> return parsedScreen
+ is ShortPressRTButtonsCommand.PressButton -> rtNavigationContext.shortPressButton(command.button)
+ }
+ }
+}
+
+/**
+ * Repeatedly presses the [button] until a screen of the required [targetScreenType] appears.
+ *
+ * @param rtNavigationContext Context for navigating to the target screen.
+ * @param button Button to press for cycling to the target screen.
+ * @param targetScreenType Type of the target screen.
+ * @return The last observed [ParsedScreen].
+ * @throws CouldNotFindRTScreenException if the screen was not found even
+ * after this function moved [RTNavigationContext.maxNumCycleAttempts]
+ * times from screen to screen.
+ * @throws info.nightscout.comboctl.parser.AlertScreenException if alert screens are seen.
+ */
+suspend fun cycleToRTScreen(
+ rtNavigationContext: RTNavigationContext,
+ button: RTNavigationButton,
+ targetScreenType: KClassifier
+): ParsedScreen {
+ logger(LogLevel.DEBUG) { "Running shortPressRTButtonsUntil() until screen of type $targetScreenType is observed" }
+ var cycleCount = 0
+ return shortPressRTButtonsUntil(rtNavigationContext) { parsedScreen ->
+ if (cycleCount >= rtNavigationContext.maxNumCycleAttempts)
+ throw CouldNotFindRTScreenException(targetScreenType)
+
+ when (parsedScreen::class) {
+ targetScreenType -> {
+ logger(LogLevel.DEBUG) { "Target screen of type $targetScreenType reached; cycleCount = $cycleCount" }
+ ShortPressRTButtonsCommand.Stop
+ }
+ else -> {
+ cycleCount++
+ logger(LogLevel.VERBOSE) { "Did not yet reach target screen type; cycleCount increased to $cycleCount" }
+ ShortPressRTButtonsCommand.PressButton(button)
+ }
+ }
+ }
+}
+
+/**
+ * Keeps watching out for incoming screens until one of the desired type is observed.
+ *
+ * @param rtNavigationContext Context for observing incoming screens.
+ * @param targetScreenType Type of the target screen.
+ * @return The last observed [ParsedScreen], which is the screen this
+ * function was waiting for.
+ * @throws CouldNotFindRTScreenException if the screen was not seen even after
+ * this function observed [RTNavigationContext.maxNumCycleAttempts]
+ * screens coming in.
+ * @throws info.nightscout.comboctl.parser.AlertScreenException if alert screens are seen.
+ */
+suspend fun waitUntilScreenAppears(
+ rtNavigationContext: RTNavigationContext,
+ targetScreenType: KClassifier
+): ParsedScreen {
+ logger(LogLevel.DEBUG) { "Observing incoming parsed screens and waiting for screen of type $targetScreenType to appear" }
+ var cycleCount = 0
+
+ rtNavigationContext.resetDuplicate()
+
+ while (true) {
+ if (cycleCount >= rtNavigationContext.maxNumCycleAttempts)
+ throw CouldNotFindRTScreenException(targetScreenType)
+
+ val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue
+ val parsedScreen = parsedDisplayFrame.parsedScreen
+
+ if (parsedScreen::class == targetScreenType) {
+ logger(LogLevel.DEBUG) { "Target screen of type $targetScreenType appeared; cycleCount = $cycleCount" }
+ return parsedScreen
+ } else {
+ logger(LogLevel.VERBOSE) { "Target screen type did not appear yet; cycleCount increased to $cycleCount" }
+ cycleCount++
+ }
+ }
+}
+
+/**
+ * Adjusts a quantity that is shown currently on screen, using the specified in/decrement buttons.
+ *
+ * Internally, this first uses a long RT button press to quickly change the quantity
+ * to be as close as possible to the [targetQuantity]. Then, with short RT button
+ * presses, any leftover differences between the currently shown quantity and
+ * [targetQuantity] is corrected.
+ *
+ * The current quantity is extracted from the current [ParsedScreen] with the
+ * [getQuantity] callback. That callback returns null if the quantity currently
+ * is not available (typically happens because the screen is blinking). This
+ * will not cause an error; instead, this function will just wait until the
+ * callback returns a non-null value.
+ *
+ * Some quantities may be cyclic in nature. For example, a minute value has a valid range
+ * of 0-59, but if the current value is 55, and the target value is 3, it is faster to press
+ * the [incrementButton] until the value wraps around from 59 to 0 and then keeps increasing
+ * to 3. The alternative would be to press the [decrementButton] 52 times, which is slower.
+ * This requires a non-null [cyclicQuantityRange] value. If that argument is null, this
+ * function will not do such a cyclic logic.
+ *
+ * Sometimes, it may be beneficial to _not_ long-press the RT button. This is typically
+ * the case if the quantity on screen is already very close to [targetQuantity]. In such
+ * a case, [longRTButtonPressPredicate] becomes useful. A long RT button press only takes
+ * place if [longRTButtonPressPredicate] returns true. Its arguments are [targetQuantity]
+ * and the quantity on screen. The default predicate always returns true.
+ *
+ * [incrementSteps] specifies how the quantity on screen would increment/decrement if the
+ * [incrementButton] or [decrementButton] were pressed. This is an array of Pair integers.
+ * For each pair, the first integer in the Pair specifies the threshold, the second integer
+ * is the step size. Example value: `arrayOf(Pair(0, 10), Pair(100, 50), Pair(1000, 100))`.
+ * This means: Values in the 0-100 range are in/decremented by a step size of 10. Values
+ * in the 100-1000 range are incremented by a step size of 50. Values at or above 1000
+ * are incremented by a step size of 100.
+ *
+ * NOTE: If [cyclicQuantityRange] is not null, [incrementSteps] must have exactly one item.
+ *
+ * @param rtNavigationContext Context to use for adjusting the quantity.
+ * @param targetQuantity Quantity to set the on-screen quantity to.
+ * @param incrementButton What RT button to press for incrementing the on-screen quantity.
+ * @param decrementButton What RT button to press for decrementing the on-screen quantity.
+ * @param cyclicQuantityRange The cyclic quantity range, or null if no such range exists.
+ * @param longRTButtonPressPredicate Quantity delta predicate for enabling RT button presses.
+ * @param incrementSteps The step sizes and thresholds the pump uses for in/decrementing.
+ * Must contain at least one item.
+ * @param getQuantity Callback for extracting the on-screen quantity.
+ * @throws info.nightscout.comboctl.parser.AlertScreenException if alert screens are seen.
+ */
+suspend fun adjustQuantityOnScreen(
+ rtNavigationContext: RTNavigationContext,
+ targetQuantity: Int,
+ incrementButton: RTNavigationButton = RTNavigationButton.UP,
+ decrementButton: RTNavigationButton = RTNavigationButton.DOWN,
+ cyclicQuantityRange: Int? = null,
+ longRTButtonPressPredicate: (targetQuantity: Int, quantityOnScreen: Int) -> Boolean = { _, _ -> true },
+ incrementSteps: Array>,
+ getQuantity: (parsedScreen: ParsedScreen) -> Int?
+) {
+ require(incrementSteps.isNotEmpty()) { "There must be at least one incrementSteps item" }
+ require((cyclicQuantityRange == null) || (incrementSteps.size == 1)) {
+ "If cyclicQuantityRange is not null, incrementSteps must contain " +
+ "exactly one item; actually contains ${incrementSteps.size}"
+ }
+
+ fun checkIfNeedsToIncrement(currentQuantity: Int): Boolean {
+ return if (cyclicQuantityRange != null) {
+ val distance = (targetQuantity - currentQuantity)
+ if (distance.absoluteValue <= (cyclicQuantityRange / 2))
+ (currentQuantity < targetQuantity)
+ else
+ (currentQuantity > targetQuantity)
+ } else
+ (currentQuantity < targetQuantity)
+ }
+
+ logger(LogLevel.DEBUG) {
+ "Adjusting quantity on RT screen; targetQuantity = $targetQuantity; " +
+ "increment / decrement buttons = $incrementButton / $decrementButton; " +
+ "cyclicQuantityRange = $cyclicQuantityRange"
+ }
+
+ val initialQuantity: Int
+ rtNavigationContext.resetDuplicate()
+
+ // Get the quantity that is initially shown on screen.
+ // This is necessary to (a) check if anything needs to
+ // be done at all and (b) decide what button to long-press
+ // in the code block below.
+ while (true) {
+ val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue
+ val parsedScreen = parsedDisplayFrame.parsedScreen
+ val quantity = getQuantity(parsedScreen)
+ if (quantity != null) {
+ initialQuantity = quantity
+ break
+ }
+ }
+
+ logger(LogLevel.DEBUG) { "Initial observed quantity: $initialQuantity" }
+
+ if (initialQuantity == targetQuantity) {
+ logger(LogLevel.DEBUG) { "Initial quantity is already the target quantity; nothing to do" }
+ return
+ }
+
+ if (longRTButtonPressPredicate(targetQuantity, initialQuantity)) {
+ val needToIncrement = checkIfNeedsToIncrement(initialQuantity)
+ logger(LogLevel.DEBUG) {
+ "First phase; long-pressing RT button to " +
+ "${if (needToIncrement) "increment" else "decrement"} quantity"
+ }
+
+ // First phase: Adjust quantity with a long RT button press.
+ // This is (much) faster than using short RT button presses,
+ // but can overshoot, especially since the Combo increases the
+ // increment/decrement steps over time.
+ longPressRTButtonUntil(
+ rtNavigationContext,
+ if (needToIncrement) incrementButton else decrementButton
+ ) { parsedScreen ->
+ val currentQuantity = getQuantity(parsedScreen)
+ logger(LogLevel.VERBOSE) { "Current quantity in first phase: $currentQuantity; need to increment: $needToIncrement" }
+ if (currentQuantity == null) {
+ LongPressRTButtonsCommand.ContinuePressingButton
+ } else {
+ // If we are incrementing, and did not yet reach the
+ // quantity, then we expect checkIfNeedsToIncrement()
+ // to indicate that further incrementing is required.
+ // The opposite is also true: If we are decrementing,
+ // and didn't reach the quantity yet, we expect
+ // checkIfNeedsToIncrement() to return false. We use
+ // this to determine if we need to continue long-pressing
+ // the RT button. If the current quantity is at the
+ // target, we don't have to anymore. And if we overshot,
+ // checkIfNeedsToIncrement() will return the opposite
+ // of what we expect. In both of these cases, keepPressing
+ // will be set to false, indicating that the long RT
+ // button press needs to stop.
+ val keepPressing =
+ if (currentQuantity == targetQuantity)
+ false
+ else if (needToIncrement)
+ checkIfNeedsToIncrement(currentQuantity)
+ else
+ !checkIfNeedsToIncrement(currentQuantity)
+
+ if (keepPressing)
+ LongPressRTButtonsCommand.ContinuePressingButton
+ else
+ LongPressRTButtonsCommand.ReleaseButton
+ }
+ }
+
+ var lastQuantity: Int? = null
+ var sameQuantityObservedCount = 0
+ rtNavigationContext.resetDuplicate()
+
+ // Observe the screens until we see a screen whose quantity
+ // is the same as the previous screen's, and we see the quantity
+ // not changing 3 times. This "debouncing" is necessary because
+ // the Combo may be somewhat behind with the display frames it
+ // sends to the client. This means that even after the
+ // longPressRTButtonUntil() call above finished, the Combo may
+ // still send several send updates, and the on-screen quantity
+ // may still be in/decremented. We need to wait until that
+ // in/decrementing is over before we can do any corrections
+ // with short RT button presses. And to be sure that it is
+ // over, we have to observe the frames for a short while.
+ // This also implies that long-pressing the RT button should
+ // really only be done if the quantity on screen differs
+ // significantly from the target quantity, otherwise the
+ // waiting / observation period for this "debouncing" will
+ // overshadow any speed gains the long-press may yield.
+ // See the longRTButtonPressPredicate documentation.
+ while (true) {
+ // Do not filter for duplicates, since a duplicate
+ // is pretty much what we are waiting for.
+ val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = false) ?: continue
+ val parsedScreen = parsedDisplayFrame.parsedScreen
+ val currentQuantity = getQuantity(parsedScreen)
+
+ logger(LogLevel.DEBUG) {
+ "Observed quantity after long-pressing RT button: " +
+ "last / current quantity: $lastQuantity / $currentQuantity"
+ }
+
+ if (currentQuantity != null) {
+ if (currentQuantity == lastQuantity) {
+ sameQuantityObservedCount++
+ if (sameQuantityObservedCount >= 3)
+ break
+ } else {
+ lastQuantity = currentQuantity
+ sameQuantityObservedCount = 0
+ }
+ }
+ }
+
+ if (lastQuantity == targetQuantity) {
+ logger(LogLevel.DEBUG) { "Last seen quantity $lastQuantity is the target quantity; adjustment finished" }
+ return
+ }
+
+ logger(LogLevel.DEBUG) {
+ "Second phase: last seen quantity $lastQuantity is not the target quantity; " +
+ "short-pressing RT button(s) to finetune it"
+ }
+ }
+
+ val currentQuantity: Int
+
+ while (true) {
+ val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue
+ val parsedScreen = parsedDisplayFrame.parsedScreen
+ val quantity = getQuantity(parsedScreen)
+ if (quantity != null) {
+ currentQuantity = quantity
+ break
+ }
+ }
+
+ // If the on-screen quantity is not the target quantity, we may
+ // have overshot, or the in/decrement factor may have been increased
+ // over time by the Combo. Perform short RT button presses to nudge
+ // the quantity until it reaches the target value. Alternatively,
+ // the long RT button press was skipped by request. In that case,
+ // we must adjust with short RT button presses.
+ val (numNeededShortRTButtonPresses: Int, shortRTButtonToPress) = computeShortRTButtonPress(
+ currentQuantity = currentQuantity,
+ targetQuantity = targetQuantity,
+ cyclicQuantityRange = cyclicQuantityRange,
+ incrementSteps = incrementSteps,
+ incrementButton = incrementButton,
+ decrementButton = decrementButton
+ )
+ logger(LogLevel.DEBUG) {
+ "Need to short-press the $shortRTButtonToPress " +
+ "RT button $numNeededShortRTButtonPresses time(s)"
+ }
+ repeat(numNeededShortRTButtonPresses) {
+ rtNavigationContext.shortPressButton(shortRTButtonToPress)
+ }
+}
+
+/**
+ * Navigates from the current screen to the screen of the given type.
+ *
+ * This performs a navigation by pressing the appropriate RT buttons to
+ * transition between screens until the target screen is reached. This uses
+ * an internal navigation tree to compute the shortest path from the current
+ * to the target screen. If no path to the target screen can be found,
+ * [CouldNotFindRTScreenException] is thrown.
+ *
+ * Depending on the value of [isComboStopped], the pathfinding algorithm may
+ * take different routes, since some screens are only enabled when the pump
+ * is running/stopped.
+ *
+ * @param rtNavigationContext Context to use for navigating.
+ * @param targetScreenType Type of the target screen.
+ * @param isComboStopped True if the Combo is currently stopped.
+ * @return The target screen.
+ * @throws CouldNotFindRTScreenException if the screen was not seen even after
+ * this function observed [RTNavigationContext.maxNumCycleAttempts]
+ * screens coming in, or if no path from the current screen to
+ * [targetScreenType] could be found.
+ * @throws CouldNotRecognizeAnyRTScreenException if the RT menu is at an
+ * unknown, unrecognized screen at the moment, and in spite of repeatedly
+ * pressing the BACK button to exit back to the main menu, the code
+ * kept seeing unrecognized screens.
+ * @throws info.nightscout.comboctl.parser.AlertScreenException if alert screens are seen.
+ */
+suspend fun navigateToRTScreen(
+ rtNavigationContext: RTNavigationContext,
+ targetScreenType: KClassifier,
+ isComboStopped: Boolean
+): ParsedScreen {
+ logger(LogLevel.DEBUG) { "About to navigate to RT screen of type $targetScreenType" }
+
+ // Get the current screen to know the starting point. If it is an
+ // unrecognized screen, press BACK until we are at the main screen.
+ var numAttemptsToRecognizeScreen = 0
+ lateinit var currentParsedScreen: ParsedScreen
+
+ rtNavigationContext.resetDuplicate()
+
+ while (true) {
+ val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue
+ val parsedScreen = parsedDisplayFrame.parsedScreen
+
+ if (parsedScreen is ParsedScreen.UnrecognizedScreen) {
+ numAttemptsToRecognizeScreen++
+ if (numAttemptsToRecognizeScreen >= rtNavigationContext.maxNumCycleAttempts)
+ throw CouldNotRecognizeAnyRTScreenException()
+ rtNavigationContext.shortPressButton(RTNavigationButton.BACK)
+ } else {
+ currentParsedScreen = parsedScreen
+ break
+ }
+ }
+
+ if (currentParsedScreen::class == targetScreenType) {
+ logger(LogLevel.DEBUG) { "Already at target; exiting" }
+ return currentParsedScreen
+ }
+
+ logger(LogLevel.DEBUG) { "Navigation starts at screen of type ${currentParsedScreen::class} and ends at screen of type $targetScreenType" }
+
+ // Figure out the shortest path.
+ var path = try {
+ findShortestRtPath(currentParsedScreen::class, targetScreenType, isComboStopped)
+ } catch (e: IllegalArgumentException) {
+ // Happens when currentParsedScreen::class or targetScreenType are not found in the navigation tree.
+ null
+ }
+
+ if (path?.isEmpty() ?: false)
+ return currentParsedScreen
+
+ if (path == null) {
+ // If we reach this place, then the currentParsedScreen was recognized by the parser,
+ // but there is no known path in the rtNavigationGraph to get from there to the target.
+ // Try exiting by repeatedly pressing BACK. cycleToRTScreen() takes care of that.
+ // If it fails to find the main screen, it throws a CouldNotFindRTScreenException.
+
+ logger(LogLevel.WARN) {
+ "We are at screen of type ${currentParsedScreen::class}, which is unknown " +
+ "to findRTNavigationPath(); exiting back to the main screen"
+ }
+ currentParsedScreen = cycleToRTScreen(
+ rtNavigationContext,
+ RTNavigationButton.BACK,
+ ParsedScreen.MainScreen::class
+ )
+
+ // Now try again to find a path. We should get a valid path now. We would
+ // not be here otherwise, since cycleToRTScreen() throws an exception then.
+ path = try {
+ findShortestRtPath(currentParsedScreen::class, targetScreenType, isComboStopped)
+ } catch (e: IllegalArgumentException) {
+ listOf()
+ }
+
+ if (path == null) {
+ // Should not happen due to the cycleToRTScreen() call above.
+ logger(LogLevel.ERROR) { "Could not find RT navigation path even after navigating back to the main menu" }
+ throw CouldNotFindRTScreenException(targetScreenType)
+ }
+ }
+
+ rtNavigationContext.resetDuplicate()
+
+ // Navigate from the current to the target screen.
+ var cycleCount = 0
+ val pathIt = path.iterator()
+ var nextPathItem = pathIt.next()
+ while (true) {
+ if (cycleCount >= rtNavigationContext.maxNumCycleAttempts)
+ throw CouldNotFindRTScreenException(targetScreenType)
+
+ val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue
+ val parsedScreen = parsedDisplayFrame.parsedScreen
+
+ // A path item's targetNodeValue is the screen type we are trying
+ // to reach, and the edgeValue is the RT button to press to reach it.
+ // We stay at the same path item until we reach the screen type that
+ // is specified by targetNodeValue. When that happens, we move on
+ // to the next path item. Importantly, we _first_ move on to the next
+ // item, and _then_ send the short RT button press based on that next
+ // item, to avoid sending the RT button from the incorrect path item.
+ // Example: Path item 1 contains target screen type A and RT button
+ // MENU. Path item 2 contains target screen type B and RT button CHECK.
+ // On every iteration, we first check if the current screen is of type
+ // A. If it isn't, we need to press MENU again and check in the next
+ // iteration again. If it is of type A however, then pressing MENU
+ // would be incorrect, since we already are at A. Instead, we _first_
+ // must move on to the next path item, and _that_ one says to press
+ // CHECK until type B is reached.
+
+ val nextTargetScreenTypeInPath = nextPathItem.targetNodeValue
+
+ logger(LogLevel.DEBUG) { "We are currently at screen $parsedScreen; next target screen type: $nextTargetScreenTypeInPath" }
+
+ if (parsedScreen::class == nextTargetScreenTypeInPath) {
+ cycleCount = 0
+ if (pathIt.hasNext()) {
+ nextPathItem = pathIt.next()
+ logger(LogLevel.DEBUG) {
+ "Reached screen type $nextTargetScreenTypeInPath in path; " +
+ "continuing to ${nextPathItem.targetNodeValue}"
+ }
+ } else {
+ // If this is the last path item, it implies
+ // that we reached our destination.
+ logger(LogLevel.DEBUG) { "Target screen type $targetScreenType reached" }
+ return parsedScreen
+ }
+ }
+
+ val navButtonToPress = nextPathItem.edgeValue.button
+ logger(LogLevel.DEBUG) { "Pressing button $navButtonToPress to navigate further" }
+ rtNavigationContext.shortPressButton(navButtonToPress)
+
+ cycleCount++
+ }
+}
+
+internal fun findShortestRtPath(from: KClassifier, to: KClassifier, isComboStopped: Boolean) =
+ rtNavigationGraph.findShortestPath(from, to) {
+ when (it.edgeValidityCondition) {
+ RTEdgeValue.EdgeValidityCondition.ALWAYS -> true
+ RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_RUNNING -> !isComboStopped
+ RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_STOPPED -> isComboStopped
+ }
+ }
+
+internal fun computeShortRTButtonPress(
+ currentQuantity: Int,
+ targetQuantity: Int,
+ cyclicQuantityRange: Int?,
+ incrementSteps: Array>,
+ incrementButton: RTNavigationButton,
+ decrementButton: RTNavigationButton
+): Pair {
+ val numNeededShortRTButtonPresses: Int
+ val shortRTButtonToPress: RTNavigationButton
+
+ // Compute the number of RT button press steps to cover the given distance.
+ // Use the (x + (d-1)) / d formula (with integer division) to round up the
+ // result. That's because in case of "half steps", these must be counted as
+ // one full step. For example, if the current quantity on screen is 21, the
+ // target quantity is 40, and the step size is 20, then pressing UP will
+ // cause the Combo to increment the quantity from 21 to 40. A further UP
+ // button press would then increment from 40 to 60 etc. If we didn't round
+ // up, the "half-step" would not be counted. In the example above, this
+ // would compute 0, since (40-21)/20 = 19/20 = 0 (integer division). The
+ // rounding formula by contrast: (40-21+(20-1))/20 = (19+19)/20 = 38/20 = 1.
+ fun computeNumSteps(stepSize: Int, distance: Int) = (distance + (stepSize - 1)) / stepSize
+
+ if (currentQuantity == targetQuantity) {
+ numNeededShortRTButtonPresses = 0
+ shortRTButtonToPress = RTNavigationButton.CHECK
+ } else if (incrementSteps.size == 1) {
+ val stepSize = incrementSteps[0].second
+ require(stepSize > 0)
+ val distance = (targetQuantity - currentQuantity).absoluteValue
+ if (cyclicQuantityRange != null) {
+ if (distance > (cyclicQuantityRange / 2)) {
+ val firstPart = (cyclicQuantityRange - targetQuantity)
+ val secondPart = currentQuantity - 0
+ numNeededShortRTButtonPresses = computeNumSteps(stepSize, firstPart + secondPart)
+ shortRTButtonToPress = if (targetQuantity < currentQuantity) incrementButton else decrementButton
+ } else {
+ numNeededShortRTButtonPresses = computeNumSteps(stepSize, distance)
+ shortRTButtonToPress = if (targetQuantity > currentQuantity) incrementButton else decrementButton
+ }
+ } else {
+ numNeededShortRTButtonPresses = computeNumSteps(stepSize, distance)
+ shortRTButtonToPress = if (targetQuantity > currentQuantity) incrementButton else decrementButton
+ }
+ } else {
+ val (start, end, button) = if (currentQuantity < targetQuantity)
+ Triple(currentQuantity, targetQuantity, incrementButton)
+ else
+ Triple(targetQuantity, currentQuantity, decrementButton)
+
+ shortRTButtonToPress = button
+
+ var currentValue = start
+ var numPresses = 0
+
+ for (index in incrementSteps.indices) {
+ val incrementStep = incrementSteps[index]
+ val stepSize = incrementStep.second
+ require(stepSize > 0)
+ val curRangeStart = incrementStep.first
+ val curRangeEnd = if (index == incrementSteps.size - 1)
+ end
+ else
+ min(incrementSteps[index + 1].first, end)
+
+ if (currentValue >= curRangeEnd)
+ continue
+
+ if (currentValue < curRangeStart)
+ currentValue = curRangeStart
+
+ numPresses += computeNumSteps(stepSize, curRangeEnd - currentValue)
+
+ currentValue = curRangeEnd
+
+ if (currentValue >= end)
+ break
+ }
+
+ numNeededShortRTButtonPresses = numPresses
+ }
+
+ return Pair(numNeededShortRTButtonPresses, shortRTButtonToPress)
+}
diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Parser.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Parser.kt
new file mode 100644
index 0000000000..4aeb883910
--- /dev/null
+++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Parser.kt
@@ -0,0 +1,1718 @@
+package info.nightscout.comboctl.parser
+
+import info.nightscout.comboctl.base.ComboException
+import info.nightscout.comboctl.base.DisplayFrame
+import info.nightscout.comboctl.base.combinedDateTime
+import info.nightscout.comboctl.base.timeWithoutDate
+import kotlin.reflect.KClassifier
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.atTime
+
+/*****************************************
+ *** Screen and screen content classes ***
+ *****************************************/
+
+/* Screens are the final result of parser runs. */
+
+/**
+ * Possible bolus types in the bolus data screen in the "My Data" bolus history.
+ */
+enum class MyDataBolusType {
+ STANDARD,
+ MULTI_WAVE,
+ EXTENDED
+}
+
+/**
+ * Possible battery states in the main screens.
+ */
+enum class BatteryState {
+ NO_BATTERY,
+ LOW_BATTERY,
+ FULL_BATTERY
+}
+
+private fun batteryStateFromSymbol(symbol: SmallSymbol?): BatteryState =
+ when (symbol) {
+ SmallSymbol.NO_BATTERY -> BatteryState.NO_BATTERY
+ SmallSymbol.LOW_BATTERY -> BatteryState.LOW_BATTERY
+ else -> BatteryState.FULL_BATTERY
+ }
+
+/**
+ * Possible contents of [ParsedScreen.MainScreen].
+ */
+sealed class MainScreenContent {
+ data class Normal(
+ val currentTime: LocalDateTime,
+ val activeBasalProfileNumber: Int,
+ val currentBasalRateFactor: Int,
+ val batteryState: BatteryState
+ ) : MainScreenContent()
+
+ data class Stopped(
+ val currentDateTime: LocalDateTime,
+ val batteryState: BatteryState
+ ) : MainScreenContent()
+
+ data class Tbr(
+ val currentTime: LocalDateTime,
+ val remainingTbrDurationInMinutes: Int,
+ val tbrPercentage: Int,
+ val activeBasalProfileNumber: Int,
+ val currentBasalRateFactor: Int,
+ val batteryState: BatteryState
+ ) : MainScreenContent()
+
+ data class ExtendedOrMultiwaveBolus(
+ val currentTime: LocalDateTime,
+ val remainingBolusDurationInMinutes: Int,
+ val isExtendedBolus: Boolean,
+ val remainingBolusAmount: Int,
+ val activeBasalProfileNumber: Int,
+ val currentBasalRateFactor: Int,
+ val batteryState: BatteryState
+ ) : MainScreenContent()
+}
+
+/**
+ * Possible contents of alert (= warning/error) screens.
+ */
+sealed class AlertScreenContent {
+ data class Warning(val code: Int) : AlertScreenContent()
+ data class Error(val code: Int) : AlertScreenContent()
+
+ /**
+ * "Content" while the alert symbol & code currently are "blinked out".
+ */
+ object None : AlertScreenContent()
+}
+
+/**
+ * Exception thrown when an alert screens appear.
+ *
+ * @property alertScreenContent The content of the alert screen(s).
+ */
+class AlertScreenException(val alertScreenContent: AlertScreenContent) :
+ ComboException("RT alert screen appeared with content: $alertScreenContent")
+
+/**
+ * Result of a successful [ToplevelScreenParser] run.
+ *
+ * Subclasses which have hour quantities use a 0..23 range for the hour.
+ * (Even if the screen showed the hour in the 12-hour AM/PM format, it is
+ * converted to the 24-hour format.) Minute quantities use a 0..59 range.
+ *
+ * Insulin units use an integer-encoded-decimal scheme. The last 3 digits of
+ * the integer make up the 3 most significant fractional digits of a decimal.
+ * For example, "37.5" is encoded as 37500, "10" as 10000, "0.02" as 20 etc.
+ *
+ * If [isBlinkedOut] is true, then the actual contents of the screen are
+ * currently "blinked out", that is, the screen is blinking, and it is
+ * at the moment in the phase when the contents aren't shown.
+ */
+sealed class ParsedScreen(val isBlinkedOut: Boolean = false) {
+ object UnrecognizedScreen : ParsedScreen()
+
+ data class MainScreen(val content: MainScreenContent) : ParsedScreen()
+
+ object BasalRateProfileSelectionMenuScreen : ParsedScreen()
+ object BluetoothSettingsMenuScreen : ParsedScreen()
+ object ExtendedBolusMenuScreen : ParsedScreen()
+ object MultiwaveBolusMenuScreen : ParsedScreen()
+ object MenuSettingsMenuScreen : ParsedScreen()
+ object MyDataMenuScreen : ParsedScreen()
+ object BasalRate1ProgrammingMenuScreen : ParsedScreen()
+ object BasalRate2ProgrammingMenuScreen : ParsedScreen()
+ object BasalRate3ProgrammingMenuScreen : ParsedScreen()
+ object BasalRate4ProgrammingMenuScreen : ParsedScreen()
+ object BasalRate5ProgrammingMenuScreen : ParsedScreen()
+ object PumpSettingsMenuScreen : ParsedScreen()
+ object ReminderSettingsMenuScreen : ParsedScreen()
+ object TimeAndDateSettingsMenuScreen : ParsedScreen()
+ object StandardBolusMenuScreen : ParsedScreen()
+ object StopPumpMenuScreen : ParsedScreen()
+ object TemporaryBasalRateMenuScreen : ParsedScreen()
+ object TherapySettingsMenuScreen : ParsedScreen()
+
+ data class AlertScreen(val content: AlertScreenContent) :
+ ParsedScreen(isBlinkedOut = (content is AlertScreenContent.None))
+
+ data class BasalRateTotalScreen(val totalNumUnits: Int, val basalRateNumber: Int) : ParsedScreen()
+ data class BasalRateFactorSettingScreen(
+ val beginTime: LocalDateTime,
+ val endTime: LocalDateTime,
+ val numUnits: Int?,
+ val basalRateNumber: Int
+ ) : ParsedScreen(isBlinkedOut = (numUnits == null))
+
+ data class TemporaryBasalRatePercentageScreen(val percentage: Int?) :
+ ParsedScreen(isBlinkedOut = (percentage == null))
+ data class TemporaryBasalRateDurationScreen(val durationInMinutes: Int?) :
+ ParsedScreen(isBlinkedOut = (durationInMinutes == null))
+
+ data class QuickinfoMainScreen(val quickinfo: Quickinfo) : ParsedScreen()
+
+ data class TimeAndDateSettingsHourScreen(val hour: Int?) :
+ ParsedScreen(isBlinkedOut = (hour == null))
+ data class TimeAndDateSettingsMinuteScreen(val minute: Int?) :
+ ParsedScreen(isBlinkedOut = (minute == null))
+ data class TimeAndDateSettingsYearScreen(val year: Int?) :
+ ParsedScreen(isBlinkedOut = (year == null))
+ data class TimeAndDateSettingsMonthScreen(val month: Int?) :
+ ParsedScreen(isBlinkedOut = (month == null))
+ data class TimeAndDateSettingsDayScreen(val day: Int?) :
+ ParsedScreen(isBlinkedOut = (day == null))
+
+ /**
+ * Bolus history entry in the "My Data" section.
+ */
+ data class MyDataBolusDataScreen(
+ /**
+ * Index of the currently shown bolus. Valid range is 1 to [totalNumEntries].
+ */
+ val index: Int,
+
+ /**
+ * Total number of bolus entries in the pump's history.
+ */
+ val totalNumEntries: Int,
+
+ /**
+ * Timestamp of when the bolus finished, in localtime.
+ */
+ val timestamp: LocalDateTime,
+
+ /**
+ * Bolus amount in 0.1 IU units.
+ */
+ val bolusAmount: Int,
+
+ /**
+ * Type of the bolus (standard / extended / multiwave).
+ */
+ val bolusType: MyDataBolusType,
+
+ /**
+ * Duration of the bolus in minutes. Set to null if this is a standard bolus.
+ */
+ val durationInMinutes: Int?
+ ) : ParsedScreen()
+
+ /**
+ * Alert history entry in the "My Data" section.
+ *
+ * (These can be both errors and warnings. The section is called "error data" though.)
+ */
+ data class MyDataErrorDataScreen(
+ /**
+ * Index of the currently shown alert. Valid range is 1 to [totalNumEntries].
+ */
+ val index: Int,
+
+ /**
+ * Total number of alert entries in the pump's history.
+ */
+ val totalNumEntries: Int,
+
+ /**
+ * Timestamp of when the alert occurred, in localtime.
+ */
+ val timestamp: LocalDateTime,
+
+ /**
+ * The alert that occurred.
+ */
+ val alert: AlertScreenContent
+ ) : ParsedScreen()
+
+ /**
+ * Total daily dosage (TDD) history entry in the "My Data" section.
+ */
+ data class MyDataDailyTotalsScreen(
+ /**
+ * Index of the currently shown TDD entry. Valid range is 1 to [totalNumEntries].
+ */
+ val index: Int,
+
+ /**
+ * Total number of TDD entries in the pump's history.
+ */
+ val totalNumEntries: Int,
+
+ /**
+ * Day for which this entry specifies the TDD amount, in localtime.
+ */
+ val date: LocalDate,
+
+ /**
+ * TDD amount in 1 IU units.
+ */
+ val totalDailyAmount: Int
+ ) : ParsedScreen()
+
+ /**
+ * TBR history entry in the "My Data" section.
+ */
+ data class MyDataTbrDataScreen(
+ /**
+ * Index of the currently shown TBR entry. Valid range is 1 to [totalNumEntries].
+ */
+ val index: Int,
+
+ /**
+ * Total number of TBR entries in the pump's history.
+ */
+ val totalNumEntries: Int,
+
+ /**
+ * Timestamp when this TBR ended.
+ */
+ val timestamp: LocalDateTime,
+
+ /**
+ * TBR percentage, in the 0-500 range.
+ */
+ val percentage: Int,
+
+ /**
+ * TBR duration in minutes, in the 15-1440 range.
+ */
+ val durationInMinutes: Int
+ ) : ParsedScreen()
+}
+
+/***************************************************
+ *** Fundamental parsers and parser base classes ***
+ ***************************************************/
+
+private fun amPmTo24Hour(hour: Int, amPm: String) =
+ if ((hour == 12) && (amPm == "AM"))
+ 0
+ else if ((hour != 12) && (amPm == "PM"))
+ hour + 12
+ else if (hour == 24)
+ 0
+ else
+ hour
+
+/**
+ * Context used to keep track of parse state.
+ */
+class ParseContext(
+ val tokens: List,
+ var currentIndex: Int,
+ var topLeftTime: LocalDateTime? = null
+) {
+ fun hasMoreTokens() = (currentIndex < tokens.size)
+
+ fun nextToken() = tokens[currentIndex]
+
+ fun advance() = currentIndex++
+}
+
+/**
+ * Possible parser results.
+ *
+ * @property isSuccess true if the result is considered a success.
+ */
+sealed class ParseResult(val isSuccess: Boolean) {
+ /** Used when the parser returns a value. This encapsulates said value. */
+ class Value(val value: T) : ParseResult(true)
+
+ /**
+ * Indicates that the parser successfully parsed the expected
+ * content, but that the content has no values. This is used
+ * if for a certain symbol is expected to be there, but is not
+ * actually needed as a value. NoValue results will not be
+ * included in @Sequence results produced by @SequenceParser.*/
+ object NoValue : ParseResult(true)
+
+ /**
+ * Used by @OptionalParser, and returned if the optional
+ * content specified in that parser was not found. This
+ * value is still considered a success since the missing
+ * content is _optional_.
+ */
+ object Null : ParseResult(true)
+
+ /**
+ * Returned by @Parser.parse if the @ParseContext
+ * reaches the end of the list of tokens.
+ */
+ object EndOfTokens : ParseResult(false)
+
+ /**
+ * Indicates that the parser did not find the expected content.
+ */
+ object Failed : ParseResult(false)
+
+ /**
+ * Result of a @SequenceParser.
+ *
+ * For convenience, this has the @valueAt and @valueAtOrNull
+ * functions to take a value out of that sequence. Example:
+ * If element no. 2 in the sequence is an Int, then this
+ * call gets it:
+ *
+ * val value = (parseResult as ParseResult.Sequence).valueAt(2)
+ *
+ * @valueAtOrNull works similarly, except that it returns null
+ * if the parse result at that index is not of type Value<*>.
+ *
+ * Note that trying to access an index beyond the valid range
+ * still produces an [IndexOutOfBoundsException] even when
+ * using @valueAtOrNull.
+ */
+ class Sequence(val values: List) : ParseResult(true) {
+ inline fun valueAt(index: Int) = (values[index] as Value<*>).value as T
+
+ inline fun valueAtOrNull(index: Int): T? {
+ return when (val value = values[index]) {
+ is Value<*> -> value.value as T
+ else -> null
+ }
+ }
+
+ val size: Int
+ get() = values.size
+ }
+}
+
+/**
+ * Parser base class.
+ *
+ * @property returnsValue If true, parsing will produce a value.
+ * The main parser which doesn't do that is [SingleGlyphParser].
+ */
+open class Parser(val returnsValue: Boolean = true) {
+ fun parse(parseContext: ParseContext): ParseResult {
+ if (!parseContext.hasMoreTokens())
+ return ParseResult.EndOfTokens
+
+ // Preserve the original index in the context. That way, should
+ // parseImpl fail, the original value of currentIndex prior to
+ // the parseImpl call can be restored. This is especially important
+ // when using the FirstSuccessParser, since that one tries multiple
+ // parsers until one succeds. Restoring the currentIndex is essential
+ // to give all those parsers the chance to parse the same tokens.
+ var originalIndex = parseContext.currentIndex
+ val result = parseImpl(parseContext)
+ if (!result.isSuccess)
+ parseContext.currentIndex = originalIndex
+
+ return result
+ }
+
+ protected open fun parseImpl(parseContext: ParseContext): ParseResult = ParseResult.Failed
+}
+
+/**
+ * Parses a single specific glyph.
+ *
+ * This is used in cases where the screen is expected to have a specific
+ * glyph at a certain position in the list of tokens. One example would
+ * be a clock symbol at the top left corner.
+ *
+ * Since this looks for a specific glyph, it does not have an actual
+ * return value. Instead, the information about whether or not parsing
+ * succeeded already tells everything. That's why this either returns
+ * [ParseResult.NoValue] (when the specified glyph was found) or
+ * [ParseResult.Failed] (when the glyph was not found).
+ */
+class SingleGlyphParser(private val glyph: Glyph) : Parser(returnsValue = false) {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ return if (parseContext.nextToken().glyph == glyph) {
+ parseContext.advance()
+ ParseResult.NoValue
+ } else
+ ParseResult.Failed
+ }
+}
+
+/**
+ * Parses a single glyph of a specific type.
+ *
+ * Similarly to [SingleGlyphParser], this parses the next token as
+ * a glyph, returning [ParseResult.Failed] if that token is not a
+ * glyph or the specified [glyphType]. Unlike [SingleGlyphParser],
+ * this does have an actual return value, since this "only" specifies
+ * the glyph _type_, not the actual glyph.
+ *
+ * This parses is used for example when a screen can contain a token
+ * that has a symbol at a specific position, and the symbol indicates
+ * something (like whether an alert screen contains a warning or an error).
+ *
+ * The type is specified via the ::class property. Example:
+ *
+ * SingleGlyphTypeParser(Glyph.LargeSymbol::class)
+ *
+ * @property glyphType Type of the glyph to expect.
+ */
+class SingleGlyphTypeParser(private val glyphType: KClassifier) : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val token = parseContext.nextToken()
+
+ return if (token.glyph::class == glyphType) {
+ parseContext.advance()
+ ParseResult.Value(token.glyph)
+ } else
+ ParseResult.Failed
+ }
+}
+
+/**
+ * Parses the available tokens as one string until a non-string glyph is found or the end is reached.
+ *
+ * Strings can consist of characters and one of the ".:/()-" symbols.
+ * This also parses whitespaces and adds them to the produced string.
+ * Whitespaces are detected by measuring the distance between glyphs.
+ */
+class StringParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ var parsedString = ""
+ var lastToken: Token? = null
+
+ while (parseContext.hasMoreTokens()) {
+ val token = parseContext.nextToken()
+ val glyph = token.glyph
+
+ // Check if there's a newline or space between the matches.
+ // If so, we'll insert a whitespace character into the string.
+ val prependWhitespace = if (lastToken != null)
+ checkForWhitespaceAndNewline(lastToken, token)
+ else
+ false
+
+ val character = when (glyph) {
+ is Glyph.SmallCharacter -> glyph.character
+ Glyph.SmallSymbol(SmallSymbol.DOT) -> '.'
+ Glyph.SmallSymbol(SmallSymbol.SEPARATOR) -> ':'
+ Glyph.SmallSymbol(SmallSymbol.DIVIDE) -> '/'
+ Glyph.SmallSymbol(SmallSymbol.BRACKET_LEFT) -> '('
+ Glyph.SmallSymbol(SmallSymbol.BRACKET_RIGHT) -> ')'
+ Glyph.SmallSymbol(SmallSymbol.MINUS) -> '-'
+ else -> break
+ }
+
+ if (prependWhitespace)
+ parsedString += ' '
+
+ parsedString += character
+ parseContext.advance()
+ lastToken = token
+ }
+
+ return if (parsedString.isEmpty())
+ ParseResult.Failed
+ else
+ ParseResult.Value(parsedString.uppercase())
+ }
+
+ // If true, then there is a whitespace between the matches,
+ // or the second match is located in a line below the first one.
+ private fun checkForWhitespaceAndNewline(firstToken: Token, secondToken: Token): Boolean {
+ val y1 = firstToken.y
+ val y2 = secondToken.y
+
+ if ((y1 + firstToken.pattern.height + 1) == y2)
+ return true
+
+ val x1 = firstToken.x
+ val x2 = secondToken.x
+
+ if ((x1 + firstToken.pattern.width + 1 + 3) < x2)
+ return true
+
+ return false
+ }
+}
+
+/**
+ * Parses the available tokens as one integer until a non-integer glyph is found or the end is reached.
+ *
+ * @property parseMode Parse mode. Useful for restricting the valid integer glyphs.
+ * @property checkForWhitespace If set to true, this checks for whitespaces
+ * and stops parsing if a whitespace is found. Useful for when there
+ * are multiple integers visually in a sequence.
+ */
+class IntegerParser(
+ private val parseMode: Mode = Mode.ALL_DIGITS,
+ private val checkForWhitespace: Boolean = false
+) : Parser() {
+ enum class Mode {
+ ALL_DIGITS,
+ SMALL_DIGITS_ONLY,
+ LARGE_DIGITS_ONLY
+ }
+
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ var integer = 0
+ var foundDigits = false
+ var previousToken: Token? = null
+
+ while (parseContext.hasMoreTokens()) {
+ val token = parseContext.nextToken()
+
+ if (checkForWhitespace && (previousToken != null)) {
+ val x1 = previousToken.x
+ val x2 = token.x
+ if ((x1 + previousToken.pattern.width + 1 + 3) < x2)
+ break
+ }
+
+ when (val glyph = token.glyph) {
+ is Glyph.SmallDigit ->
+ when (parseMode) {
+ Mode.ALL_DIGITS,
+ Mode.SMALL_DIGITS_ONLY -> integer = integer * 10 + glyph.digit
+ else -> break
+ }
+
+ is Glyph.LargeDigit ->
+ when (parseMode) {
+ Mode.ALL_DIGITS,
+ Mode.LARGE_DIGITS_ONLY -> integer = integer * 10 + glyph.digit
+ else -> break
+ }
+
+ else -> break
+ }
+
+ foundDigits = true
+
+ parseContext.advance()
+
+ previousToken = token
+ }
+
+ return if (foundDigits)
+ ParseResult.Value(integer)
+ else
+ ParseResult.Failed
+ }
+}
+
+/**
+ * Parses the available tokens as one decimal until a non-decimal glyph is found or the end is reached.
+ *
+ * Decimals are made of digits and the dot symbol. They are encoded in an Int value,
+ * using a fixed-point decimal representation. The point is shifted by 3 digits to
+ * the left. If for example the decimal "2.13" is parsed, the resulting Int is set
+ * to 2130. This is preferred over floating point data types, since the latter can
+ * be lossy, depending on the parsed value (because some decimals cannot be directly
+ * represented by IEEE 754 floating point math).
+ *
+ * This parser also works if the dot symbol is missing. Then, the parsed number
+ * is treated as a decimal that only has an integer portion.
+ */
+class DecimalParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ var integerPart = 0
+ var fractionalPart = 0
+ var parseFractional = false
+ var foundDigits = false
+
+ while (parseContext.hasMoreTokens()) {
+ val token = parseContext.nextToken()
+
+ when (val glyph = token.glyph) {
+ is Glyph.SmallDigit -> integerPart = integerPart * 10 + glyph.digit
+ is Glyph.LargeDigit -> integerPart = integerPart * 10 + glyph.digit
+
+ Glyph.SmallSymbol(SmallSymbol.DOT),
+ Glyph.LargeSymbol(LargeSymbol.DOT) -> {
+ parseFractional = true
+ parseContext.advance()
+ break
+ }
+
+ else -> break
+ }
+
+ foundDigits = true
+
+ parseContext.advance()
+ }
+
+ if (parseFractional) {
+ var numFractionalDigits = 0
+ while (parseContext.hasMoreTokens() && (numFractionalDigits < 3)) {
+ val token = parseContext.nextToken()
+
+ when (val glyph = token.glyph) {
+ is Glyph.SmallDigit -> {
+ fractionalPart = fractionalPart * 10 + glyph.digit
+ numFractionalDigits++
+ }
+
+ is Glyph.LargeDigit -> {
+ fractionalPart = fractionalPart * 10 + glyph.digit
+ numFractionalDigits++
+ }
+
+ else -> break
+ }
+
+ foundDigits = true
+
+ parseContext.advance()
+ }
+
+ for (i in 0 until (3 - numFractionalDigits)) {
+ fractionalPart *= 10
+ }
+ }
+
+ return if (foundDigits)
+ ParseResult.Value(integerPart * 1000 + fractionalPart)
+ else
+ ParseResult.Failed
+ }
+}
+
+/**
+ * Parses the available tokens as a date.
+ *
+ * The following date formats are used by the Combo:
+ *
+ * DD.MM
+ * MM/DD
+ * DD.MM.YY
+ * MM/DD/YY
+ *
+ * The parser handles all of these cases.
+ *
+ * The result is a [DateTime] instance with the hour/minute/second fields set to zero.
+ */
+class DateParser : Parser() {
+ private val dateRegex = "(\\d\\d)([/\\.])(\\d\\d)([/\\.](\\d\\d))?".toRegex()
+ private val asciiDigitOffset = '0'.code
+
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ // To be able to handle all date formats without too much
+ // convoluted (and error prone) parsing code, we use regex.
+
+ var dateString = ""
+
+ while (parseContext.hasMoreTokens()) {
+ val token = parseContext.nextToken()
+ val glyph = token.glyph
+
+ dateString += when (glyph) {
+ // Valid glyphs are converted to characters and added to the string.
+ is Glyph.SmallDigit -> (glyph.digit + asciiDigitOffset).toChar()
+ is Glyph.LargeDigit -> (glyph.digit + asciiDigitOffset).toChar()
+ is Glyph.SmallCharacter -> glyph.character
+ is Glyph.LargeCharacter -> glyph.character
+ Glyph.SmallSymbol(SmallSymbol.DIVIDE) -> '/'
+ Glyph.SmallSymbol(SmallSymbol.DOT) -> '.'
+ Glyph.LargeSymbol(LargeSymbol.DOT) -> '.'
+
+ // Invalid glyph -> the date string ended, stop scan.
+ else -> break
+ }
+
+ parseContext.advance()
+ }
+
+ val regexResult = dateRegex.find(dateString) ?: return ParseResult.Failed
+
+ val regexGroups = regexResult.groups
+ val separator = regexGroups[2]!!.value
+ var year = 0
+ var month: Int
+ var day: Int
+
+ if (separator == ".") {
+ day = regexGroups[1]!!.value.toInt(radix = 10)
+ month = regexGroups[3]!!.value.toInt(radix = 10)
+ } else if (separator == "/") {
+ day = regexGroups[3]!!.value.toInt(radix = 10)
+ month = regexGroups[1]!!.value.toInt(radix = 10)
+ } else
+ return ParseResult.Failed
+
+ if (regexGroups[5] != null) {
+ year = regexGroups[5]!!.value.toInt(radix = 10) + 2000 // Combo years always start at the year 2000
+ }
+
+ return ParseResult.Value(LocalDate(year = year, monthNumber = month, dayOfMonth = day))
+ }
+}
+
+/**
+ * Parses the available tokens as a time.
+ *
+ * The following time formats are used by the Combo:
+ *
+ * HH:MM
+ * HH:MM(AM/PM)
+ * HH(AM/PM)
+ *
+ * Examples:
+ * 14:00
+ * 11:47AM
+ * 09PM
+ *
+ * The parser handles all of these cases.
+ *
+ * The result is a [DateTime] instance with the year/month/day fields set to zero.
+ */
+class TimeParser : Parser() {
+ private val timeRegex = "(\\d\\d):?(\\d\\d)(AM|PM)?|(\\d\\d)(AM|PM)".toRegex()
+ private val asciiDigitOffset = '0'.code
+
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ // To be able to handle all time formats without too much
+ // convoluted (and error prone) parsing code, we use regex.
+
+ var timeString = ""
+
+ while (parseContext.hasMoreTokens()) {
+ val token = parseContext.nextToken()
+ val glyph = token.glyph
+
+ timeString += when (glyph) {
+ // Valid glyphs are converted to characters and added to the string.
+ is Glyph.SmallDigit -> (glyph.digit + asciiDigitOffset).toChar()
+ is Glyph.LargeDigit -> (glyph.digit + asciiDigitOffset).toChar()
+ is Glyph.SmallCharacter -> glyph.character
+ is Glyph.LargeCharacter -> glyph.character
+ Glyph.SmallSymbol(SmallSymbol.SEPARATOR) -> ':'
+ Glyph.LargeSymbol(LargeSymbol.SEPARATOR) -> ':'
+
+ // Invalid glyph -> the time string ended, stop scan.
+ else -> break
+ }
+
+ parseContext.advance()
+ }
+
+ val regexResult = timeRegex.find(timeString) ?: return ParseResult.Failed
+
+ // Analyze the regex find result.
+ // The Regex result groups are:
+ //
+ // #0: The entire string
+ // #1: Hour from a HH:MM or HH:MM(AM/PM) format
+ // #2: Minute from a HH:MM or HH:MM(AM/PM) format
+ // #3: AM/PM specifier from a HH:MM or HH:MM(AM/PM) format
+ // #4: Hour from a HH(AM/PM) format
+ // #5: AM/PM specifier from a HH(AM/PM) format
+ //
+ // Groups without a found value are set to null.
+
+ val regexGroups = regexResult.groups
+ var hour: Int
+ var minute = 0
+
+ if (regexGroups[1] != null) {
+ // Possibility 1: This is a time string that matches
+ // one of these two formats:
+ //
+ // HH:MM
+ // HH:MM(AM/PM)
+ //
+ // This means that group #2 must not be null, since it
+ // contains the minute, and these are required here.
+
+ if (regexGroups[2] == null)
+ return ParseResult.Failed
+
+ hour = regexGroups[1]!!.value.toInt(radix = 10)
+ minute = regexGroups[2]!!.value.toInt(radix = 10)
+
+ // Special case that can happen in basal rate factor
+ // setting screens. The screen that shows the factor
+ // that starts at 23:00 and ends at 00:00 shows a
+ // time range from 23:00 to 24:00, and _not to 00:00
+ // for some reason. Catch this here, otherwise the
+ // LocalDateTime class will throw an IllegalArgumentException.
+ if (hour == 24)
+ hour = 0
+
+ // If there is an AM/PM specifier, convert the hour
+ // to the 24-hour format.
+ if (regexGroups[3] != null)
+ hour = amPmTo24Hour(hour, regexGroups[3]!!.value)
+ } else if (regexGroups[4] != null) {
+ // Possibility 2: This is a time string that matches
+ // this format:
+ //
+ // HH(AM/PM)
+ //
+ // This means that group #5 must not be null, since it
+ // contains the AM/PM specifier, and it is required here.
+
+ if (regexGroups[5] == null)
+ return ParseResult.Failed
+
+ hour = amPmTo24Hour(
+ regexGroups[4]!!.value.toInt(radix = 10),
+ regexGroups[5]!!.value
+ )
+ } else
+ return ParseResult.Failed
+
+ return ParseResult.Value(timeWithoutDate(hour = hour, minute = minute))
+ }
+}
+
+/******************************************
+ *** Intermediate-level utility parsers ***
+ ******************************************/
+
+/**
+ * Parses tokens using the specified subparser, returning [ParseResult.Null] or [ParseResult.NoValue] if that subparser fails to parse.
+ *
+ * This is useful when contents in a screen are not always available. One prominent
+ * example is a blinking text or number. The subparser does the actual token parsing.
+ * If that subparser returns [ParseResult.Failed], the OptionalParser returns
+ * [ParseResult.Null] or [ParseResult.NoValue] instead. (It returns the latter if
+ * the subparer's returnsValue property is set to true.) This is particularly useful
+ * with the other utility parsers which themselves return [ParseResult.Failed] if at
+ * least one of their subparsers fail. Using OptionalParser as one of their subparsers
+ * prevents that.
+ *
+ * @property subParser Parser to parse tokens with.
+ */
+class OptionalParser(private val subParser: Parser) : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val parseResult = subParser.parse(parseContext)
+
+ return if (parseResult.isSuccess) {
+ parseResult
+ } else {
+ if (subParser.returnsValue)
+ ParseResult.Null
+ else
+ ParseResult.NoValue
+ }
+ }
+}
+
+/**
+ * Tries to parse tokens with the specified subparsers, stopping when one subparser succeeds or all subparsers failed.
+ *
+ * This parser tries its subparsers in the order by which they are stored in the
+ * [subParsers] list. The first subparser that succeeds is the one whose return
+ * value is forwarded and used as this parser's return value.
+ *
+ * @property subParsers List of parsers to try to parse tokens with.
+ */
+class FirstSuccessParser(private val subParsers: List) : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ for (subParser in subParsers) {
+ val parseResult = subParser.parse(parseContext)
+
+ if (parseResult.isSuccess)
+ return parseResult
+ }
+
+ return ParseResult.Failed
+ }
+}
+
+/**
+ * Parses a sequence of tokens using the specified subparsers.
+ *
+ * This is useful for parsing entire screens at once. For example, if
+ * a screen contains a date, followed by a specific symbol, followed by
+ * another symbol (not a specific one though) and an optional integer,
+ * then parsing looks like this:
+ *
+ * val parseResult = SequenceParser(
+ * listOf(
+ * DateParser(),
+ * SingleGlyphParser(Glyph.SmallSymbol(Symbol.SMALL_CLOCK)),
+ * SingleGlyphTypeParser(Glyph.LargeSymbol::class),
+ * OptionalParser(DecimalParser())
+ * )
+ * ).parse(parseContext)
+ *
+ * Retrieving the values then looks like this:
+ *
+ * parseResult as ParseResult.Sequence
+ * val date = parseResult.valueAt(0)
+ * val symbolGlyph = parseResult.valueAt(1)
+ * val optionalInteger = parseResult.valueAtOrNull(2)
+ *
+ * Note that the [SingleGlyphParser] is skipped (the indices go from 0 to 2).
+ * This is because SingleGlyphParser's returnsValue property is set to false.
+ * The valueAt function skips parsers whose returnsValue property is false.
+ * Also, with optional parsers, it is recommended to use valueAtOrNull<>
+ * instead of value<>, since the former returns null if the OptionalParser
+ * returns [ParseResult.Null], while the latter raises an exception (cast error).
+ *
+ * @property subParsers List of parsers to parse tokens with.
+ * @property allowIncompleteSequences If true, then partial results are
+ * OK; that is, as soon as one of the subparsers returns
+ * [ParseResult.EndOfTokens], this function call returns the
+ * sequence of values parsed so far. If instead set to true,
+ * [ParseResult.EndOfTokens] is returned in that case.
+ */
+class SequenceParser(private val subParsers: List, private val allowIncompleteSequences: Boolean = false) : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val parseResults = mutableListOf()
+ for (subParser in subParsers) {
+ val parseResult = subParser.parse(parseContext)
+
+ when (parseResult) {
+ is ParseResult.Value<*> -> parseResults.add(parseResult)
+ is ParseResult.Sequence -> parseResults.add(parseResult)
+ ParseResult.NoValue -> Unit
+ ParseResult.Null -> parseResults.add(ParseResult.Null)
+ ParseResult.EndOfTokens -> if (allowIncompleteSequences) break else return ParseResult.EndOfTokens
+ ParseResult.Failed -> return ParseResult.Failed
+ }
+ }
+
+ return ParseResult.Sequence(parseResults)
+ }
+}
+
+/*************************************
+ *** Top-level screen parser class ***
+ *************************************/
+
+/**
+ * Top-level parser.
+ *
+ * This is the main entrypoint for parsing tokens that were previously
+ * extracted out of a [DisplayFrame]. Typically, this is not used directly.
+ * Instead, this is used by [parseDisplayFrame].
+ */
+class ToplevelScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext) = FirstSuccessParser(
+ listOf(
+ TopLeftClockScreenParser(),
+ MenuScreenParser(),
+ TitleStringScreenParser()
+ )
+ ).parse(parseContext)
+}
+
+/**
+ * Parses a [DisplayFrame] by tokenizing it and then parsing the tokens.
+ *
+ * @param displayFrame Display frame to parse.
+ * @return Parsed screen, or [ParsedScreen.UnrecognizedScreen] if parsing failed.
+ */
+fun parseDisplayFrame(displayFrame: DisplayFrame): ParsedScreen {
+ val tokens = findTokens(displayFrame)
+ val parseContext = ParseContext(tokens, 0)
+ val parseResult = ToplevelScreenParser().parse(parseContext)
+ return when (parseResult) {
+ is ParseResult.Value<*> -> parseResult.value as ParsedScreen
+ else -> ParsedScreen.UnrecognizedScreen
+ }
+}
+
+/******************************************
+ *** Screen parser intermediate classes ***
+ ******************************************/
+
+class TitleStringScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val parseResult = StringParser().parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ val titleString = (parseResult as ParseResult.Value<*>).value as String
+
+ // Get an ID for the title. This ID is language independent
+ // and thus much more useful for identifying the screen here.
+ val titleId = knownScreenTitles[titleString]
+
+ when (titleId) {
+ TitleID.QUICK_INFO -> return QuickinfoScreenParser().parse(parseContext)
+ TitleID.TBR_PERCENTAGE -> return TemporaryBasalRatePercentageScreenParser().parse(parseContext)
+ TitleID.TBR_DURATION -> return TemporaryBasalRateDurationScreenParser().parse(parseContext)
+ TitleID.HOUR,
+ TitleID.MINUTE,
+ TitleID.YEAR,
+ TitleID.MONTH,
+ TitleID.DAY -> return TimeAndDateSettingsScreenParser(titleId).parse(parseContext)
+ TitleID.BOLUS_DATA -> return MyDataBolusDataScreenParser().parse(parseContext)
+ TitleID.ERROR_DATA -> return MyDataErrorDataScreenParser().parse(parseContext)
+ TitleID.DAILY_TOTALS -> return MyDataDailyTotalsScreenParser().parse(parseContext)
+ TitleID.TBR_DATA -> return MyDataTbrDataScreenParser().parse(parseContext)
+ else -> Unit
+ }
+
+ // Further parsers follow that do not actually use
+ // the title string for identification, and instead
+ // just skip the title string. To not have to parse
+ // that string again, these parsers are run here,
+ // after the string was already parsed.
+
+ return FirstSuccessParser(
+ listOf(
+ AlertScreenParser(),
+ BasalRateTotalScreenParser()
+ )
+ ).parse(parseContext)
+ }
+}
+
+class MenuScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val lastGlyph = parseContext.tokens.last().glyph
+
+ when (lastGlyph) {
+ Glyph.LargeSymbol(LargeSymbol.BOLUS) -> return ParseResult.Value(ParsedScreen.StandardBolusMenuScreen)
+ Glyph.LargeSymbol(LargeSymbol.EXTENDED_BOLUS) -> return ParseResult.Value(ParsedScreen.ExtendedBolusMenuScreen)
+ Glyph.LargeSymbol(LargeSymbol.MULTIWAVE_BOLUS) -> return ParseResult.Value(ParsedScreen.MultiwaveBolusMenuScreen)
+ Glyph.LargeSymbol(LargeSymbol.BLUETOOTH_SETTINGS) -> return ParseResult.Value(ParsedScreen.BluetoothSettingsMenuScreen)
+ Glyph.LargeSymbol(LargeSymbol.MENU_SETTINGS) -> return ParseResult.Value(ParsedScreen.MenuSettingsMenuScreen)
+ Glyph.LargeSymbol(LargeSymbol.MY_DATA) -> return ParseResult.Value(ParsedScreen.MyDataMenuScreen)
+ Glyph.LargeSymbol(LargeSymbol.BASAL) -> return ParseResult.Value(ParsedScreen.BasalRateProfileSelectionMenuScreen)
+ Glyph.LargeSymbol(LargeSymbol.PUMP_SETTINGS) -> return ParseResult.Value(ParsedScreen.PumpSettingsMenuScreen)
+ Glyph.LargeSymbol(LargeSymbol.REMINDER_SETTINGS) -> return ParseResult.Value(ParsedScreen.ReminderSettingsMenuScreen)
+ Glyph.LargeSymbol(LargeSymbol.CALENDAR_AND_CLOCK) -> return ParseResult.Value(ParsedScreen.TimeAndDateSettingsMenuScreen)
+ Glyph.LargeSymbol(LargeSymbol.STOP) -> return ParseResult.Value(ParsedScreen.StopPumpMenuScreen)
+ Glyph.LargeSymbol(LargeSymbol.TBR) -> return ParseResult.Value(ParsedScreen.TemporaryBasalRateMenuScreen)
+ Glyph.LargeSymbol(LargeSymbol.THERAPY_SETTINGS) -> return ParseResult.Value(ParsedScreen.TherapySettingsMenuScreen)
+ else -> Unit
+ }
+
+ // Special case: If the semi-last glyph is a LARGE_BASAL symbol,
+ // and the last glyph is a large digit, this may be one of the
+ // basal rate programming menu screens.
+ if ((parseContext.tokens.size >= 2) &&
+ (lastGlyph is Glyph.LargeDigit) &&
+ (parseContext.tokens[parseContext.tokens.size - 2].glyph == Glyph.LargeSymbol(LargeSymbol.BASAL))) {
+ return ParseResult.Value(when (lastGlyph.digit) {
+ 1 -> ParsedScreen.BasalRate1ProgrammingMenuScreen
+ 2 -> ParsedScreen.BasalRate2ProgrammingMenuScreen
+ 3 -> ParsedScreen.BasalRate3ProgrammingMenuScreen
+ 4 -> ParsedScreen.BasalRate4ProgrammingMenuScreen
+ 5 -> ParsedScreen.BasalRate5ProgrammingMenuScreen
+ else -> ParsedScreen.UnrecognizedScreen
+ })
+ }
+
+ return ParseResult.Failed
+ }
+}
+
+class TopLeftClockScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val parseResult = SequenceParser(
+ listOf(
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CLOCK)),
+ TimeParser()
+ )
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+
+ parseContext.topLeftTime = parseResult.valueAtOrNull(0)
+
+ return FirstSuccessParser(
+ listOf(
+ BasalRateFactorSettingScreenParser(),
+ NormalMainScreenParser(),
+ TbrMainScreenParser(),
+ StoppedMainScreenParser(),
+ ExtendedAndMultiwaveBolusMainScreenParser()
+ )
+ ).parse(parseContext)
+ }
+}
+
+/*****************************
+ *** Screen parser classes ***
+ *****************************/
+
+class AlertScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val parseResult = SequenceParser(
+ listOf(
+ OptionalParser(SingleGlyphTypeParser(Glyph.LargeSymbol::class)), // warning/error symbol
+ OptionalParser(SingleGlyphTypeParser(Glyph.LargeCharacter::class)), // "W" or "E"
+ OptionalParser(IntegerParser()), // warning/error number
+ OptionalParser(SingleGlyphTypeParser(Glyph.LargeSymbol::class)), // stop symbol (only with errors)
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CHECK))
+ )
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+
+ return when (parseResult.valueAtOrNull(0)) {
+ Glyph.LargeSymbol(LargeSymbol.WARNING) -> {
+ ParseResult.Value(ParsedScreen.AlertScreen(
+ AlertScreenContent.Warning(parseResult.valueAt(2))
+ ))
+ }
+
+ Glyph.LargeSymbol(LargeSymbol.ERROR) -> {
+ ParseResult.Value(ParsedScreen.AlertScreen(
+ AlertScreenContent.Error(parseResult.valueAt(2))
+ ))
+ }
+
+ else -> ParseResult.Value(ParsedScreen.AlertScreen(AlertScreenContent.None))
+ }
+ }
+}
+
+class QuickinfoScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val parseResult = SequenceParser(
+ listOf(
+ SingleGlyphTypeParser(Glyph.LargeSymbol::class),
+ IntegerParser()
+ )
+ ).parse(parseContext)
+
+ parseResult as ParseResult.Sequence
+
+ val reservoirState = when (parseResult.valueAt(0)) {
+ Glyph.LargeSymbol(LargeSymbol.RESERVOIR_EMPTY) -> ReservoirState.EMPTY
+ Glyph.LargeSymbol(LargeSymbol.RESERVOIR_LOW) -> ReservoirState.LOW
+ Glyph.LargeSymbol(LargeSymbol.RESERVOIR_FULL) -> ReservoirState.FULL
+ else -> return ParseResult.Failed
+ }
+
+ val availableUnits = parseResult.valueAt(1)
+
+ return ParseResult.Value(
+ ParsedScreen.QuickinfoMainScreen(
+ Quickinfo(availableUnits = availableUnits, reservoirState = reservoirState)
+ )
+ )
+ }
+}
+
+class BasalRateTotalScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val parseResult = SequenceParser(
+ listOf(
+ SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.BASAL_SET)),
+ DecimalParser(),
+ SingleGlyphParser(Glyph.LargeCharacter('u')),
+ IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY),
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CHECK)),
+ SingleGlyphTypeParser(Glyph.SmallCharacter::class)
+ )
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+
+ return ParseResult.Value(
+ ParsedScreen.BasalRateTotalScreen(
+ totalNumUnits = parseResult.valueAt(0),
+ basalRateNumber = parseResult.valueAt(1)
+ )
+ )
+ }
+}
+
+class TemporaryBasalRatePercentageScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val parseResult = SequenceParser(
+ listOf(
+ SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.BASAL)),
+ OptionalParser(IntegerParser()),
+ SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.PERCENT))
+ )
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+
+ return ParseResult.Value(
+ ParsedScreen.TemporaryBasalRatePercentageScreen(percentage = parseResult.valueAtOrNull(0))
+ )
+ }
+}
+
+class TemporaryBasalRateDurationScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val parseResult = SequenceParser(
+ listOf(
+ SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.ARROW)),
+ OptionalParser(TimeParser())
+ )
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+ val durationParseResult = parseResult.valueAtOrNull(0)
+
+ return ParseResult.Value(
+ ParsedScreen.TemporaryBasalRateDurationScreen(
+ durationInMinutes = if (durationParseResult != null)
+ durationParseResult.hour * 60 + durationParseResult.minute
+ else
+ null
+ )
+ )
+ }
+}
+
+class NormalMainScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ require(parseContext.topLeftTime != null)
+
+ val parseResult = SequenceParser(
+ listOf(
+ SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.BASAL)),
+ DecimalParser(), // Current basal rate factor
+ SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.UNITS_PER_HOUR)),
+ SingleGlyphTypeParser(Glyph.SmallDigit::class), // Basal rate number,
+ SingleGlyphTypeParser(Glyph.SmallSymbol::class) // Battery state
+ ),
+ allowIncompleteSequences = true
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+ if (parseResult.size < 2)
+ return ParseResult.Failed
+
+ val batteryState = batteryStateFromSymbol(
+ if (parseResult.size >= 3) parseResult.valueAt(2).symbol else null
+ )
+
+ return ParseResult.Value(
+ ParsedScreen.MainScreen(
+ MainScreenContent.Normal(
+ currentTime = parseContext.topLeftTime!!,
+ activeBasalProfileNumber = parseResult.valueAt(1).digit,
+ currentBasalRateFactor = parseResult.valueAt(0),
+ batteryState = batteryState
+ )
+ )
+ )
+ }
+}
+
+class TbrMainScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ require(parseContext.topLeftTime != null)
+
+ val parseResult = SequenceParser(
+ listOf(
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.ARROW)),
+ TimeParser(), // Remaining TBR duration
+ SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.BASAL)),
+ FirstSuccessParser(
+ listOf(
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.UP)),
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.DOWN))
+ )
+ ),
+ IntegerParser(IntegerParser.Mode.LARGE_DIGITS_ONLY), // TBR percentage
+ SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.PERCENT)),
+ SingleGlyphTypeParser(Glyph.SmallDigit::class), // Basal rate number
+ DecimalParser(), // Current basal rate factor
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.UNITS_PER_HOUR)),
+ SingleGlyphTypeParser(Glyph.SmallSymbol::class) // Battery state
+ ),
+ allowIncompleteSequences = true
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+ if (parseResult.size < 4)
+ return ParseResult.Failed
+
+ val batteryState = batteryStateFromSymbol(
+ if (parseResult.size >= 5) parseResult.valueAt(4).symbol else null
+ )
+
+ val remainingTbrDuration = parseResult.valueAt(0)
+
+ return ParseResult.Value(
+ ParsedScreen.MainScreen(
+ MainScreenContent.Tbr(
+ currentTime = parseContext.topLeftTime!!,
+ remainingTbrDurationInMinutes = remainingTbrDuration.hour * 60 + remainingTbrDuration.minute,
+ tbrPercentage = parseResult.valueAt(1),
+ activeBasalProfileNumber = parseResult.valueAt(2).digit,
+ currentBasalRateFactor = parseResult.valueAt(3),
+ batteryState = batteryState
+ )
+ )
+ )
+ }
+}
+
+class ExtendedAndMultiwaveBolusMainScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ require(parseContext.topLeftTime != null)
+
+ val parseResult = SequenceParser(
+ listOf(
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.ARROW)),
+ TimeParser(), // Remaining extended/multiwave bolus duration
+ SingleGlyphTypeParser(Glyph.LargeSymbol::class), // Extended / multiwave symbol
+ DecimalParser(), // Remaining bolus amount
+ SingleGlyphParser(Glyph.LargeCharacter('u')),
+ SingleGlyphTypeParser(Glyph.SmallDigit::class), // Active basal rate number
+ DecimalParser(), // Current basal rate factor
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.UNITS_PER_HOUR)),
+ SingleGlyphTypeParser(Glyph.SmallSymbol::class) // Battery state
+ ),
+ allowIncompleteSequences = true
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+ if (parseResult.size < 5)
+ return ParseResult.Failed
+
+ // At that location, only the extended and multiwave bolus symbols
+ // are valid. Otherwise, this isn't an extended/multiwave bolus screen.
+ val isExtendedBolus = when (parseResult.valueAt(1).symbol) {
+ LargeSymbol.EXTENDED_BOLUS -> true
+ LargeSymbol.MULTIWAVE_BOLUS -> false
+ else -> return ParseResult.Failed
+ }
+
+ val batteryState = batteryStateFromSymbol(
+ if (parseResult.size >= 6) parseResult.valueAt(5).symbol else null
+ )
+
+ val remainingBolusDuration = parseResult.valueAt(0)
+
+ return ParseResult.Value(
+ ParsedScreen.MainScreen(
+ MainScreenContent.ExtendedOrMultiwaveBolus(
+ currentTime = parseContext.topLeftTime!!,
+ remainingBolusDurationInMinutes = remainingBolusDuration.hour * 60 + remainingBolusDuration.minute,
+ isExtendedBolus = isExtendedBolus,
+ remainingBolusAmount = parseResult.valueAt(2),
+ activeBasalProfileNumber = parseResult.valueAt(3).digit,
+ currentBasalRateFactor = parseResult.valueAt(4),
+ batteryState = batteryState
+ )
+ )
+ )
+ }
+}
+
+class StoppedMainScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ require(parseContext.topLeftTime != null)
+
+ val parseResult = SequenceParser(
+ listOf(
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CALENDAR)),
+ DateParser(), // Current date
+ SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.STOP)),
+ SingleGlyphTypeParser(Glyph.SmallSymbol::class) // Battery state
+ ),
+ allowIncompleteSequences = true
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+ if (parseResult.size < 1)
+ return ParseResult.Failed
+
+ val currentDate = parseResult.valueAt(0)
+
+ val batteryState = batteryStateFromSymbol(
+ if (parseResult.size >= 2) parseResult.valueAt(1).symbol else null
+ )
+
+ return ParseResult.Value(
+ ParsedScreen.MainScreen(
+ MainScreenContent.Stopped(
+ currentDateTime = currentDate.atTime(
+ hour = parseContext.topLeftTime!!.hour,
+ minute = parseContext.topLeftTime!!.minute,
+ second = 0,
+ nanosecond = 0
+ ),
+ batteryState = batteryState
+ )
+ )
+ )
+ }
+}
+
+class BasalRateFactorSettingScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ require(parseContext.topLeftTime != null)
+
+ val parseResult = SequenceParser(
+ listOf(
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.MINUS)),
+ TimeParser(),
+ SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.BASAL)),
+ OptionalParser(DecimalParser()),
+ SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.UNITS_PER_HOUR)),
+ IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY)
+ )
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+ val beginTime = parseContext.topLeftTime!!
+ val endTime = parseResult.valueAt(0)
+ val numUnits = parseResult.valueAtOrNull(1)
+ val basalRateNumber = parseResult.valueAt(2)
+
+ return ParseResult.Value(
+ ParsedScreen.BasalRateFactorSettingScreen(
+ beginTime = beginTime,
+ endTime = endTime,
+ numUnits = numUnits,
+ basalRateNumber = basalRateNumber
+ )
+ )
+ }
+}
+
+class TimeAndDateSettingsScreenParser(val titleId: TitleID) : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val parseResult = SequenceParser(
+ listOf(
+ SingleGlyphTypeParser(Glyph.LargeSymbol::class),
+ OptionalParser(IntegerParser(IntegerParser.Mode.LARGE_DIGITS_ONLY)), // Quantity
+ OptionalParser(StringParser()) // AM/PM
+ )
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+ val symbolGlyph = parseResult.valueAt(0)
+ val ampm = parseResult.valueAtOrNull(2)
+ var quantity = parseResult.valueAtOrNull(1)
+
+ // The AM/PM -> 24 hour translation must only be attempted if the
+ // quantity is an hour. If it is a minute, this translation might
+ // incorrectly change minute 24 into minute 0 for example.
+ if ((titleId == TitleID.HOUR) && (quantity != null))
+ quantity = amPmTo24Hour(quantity, ampm ?: "")
+
+ val expectedSymbol = when (titleId) {
+ TitleID.HOUR,
+ TitleID.MINUTE -> LargeSymbol.CLOCK
+ TitleID.YEAR,
+ TitleID.MONTH,
+ TitleID.DAY -> LargeSymbol.CALENDAR
+ else -> return ParseResult.Failed
+ }
+
+ if (symbolGlyph.symbol != expectedSymbol)
+ return ParseResult.Failed
+
+ return ParseResult.Value(
+ when (titleId) {
+ TitleID.HOUR -> ParsedScreen.TimeAndDateSettingsHourScreen(quantity)
+ TitleID.MINUTE -> ParsedScreen.TimeAndDateSettingsMinuteScreen(quantity)
+ TitleID.YEAR -> ParsedScreen.TimeAndDateSettingsYearScreen(quantity)
+ TitleID.MONTH -> ParsedScreen.TimeAndDateSettingsMonthScreen(quantity)
+ TitleID.DAY -> ParsedScreen.TimeAndDateSettingsDayScreen(quantity)
+ else -> return ParseResult.Failed
+ }
+ )
+ }
+}
+
+class MyDataBolusDataScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val parseResult = SequenceParser(
+ listOf(
+ SingleGlyphTypeParser(Glyph.SmallSymbol::class), // Bolus type
+ DecimalParser(), // Bolus amount,
+ SingleGlyphParser(Glyph.SmallCharacter('U')),
+ IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Index
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.DIVIDE)),
+ IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Total num entries
+ OptionalParser(SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.ARROW))),
+ OptionalParser(TimeParser()), // Duration - only present in multiwave and extended bolus entries
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CLOCK)),
+ TimeParser(), // Timestamp time
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CALENDAR)),
+ DateParser() // Timestamp date
+ )
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+ val bolusType = when (parseResult.valueAt(0).symbol) {
+ SmallSymbol.BOLUS -> MyDataBolusType.STANDARD
+ SmallSymbol.MULTIWAVE_BOLUS -> MyDataBolusType.MULTI_WAVE
+ SmallSymbol.EXTENDED_BOLUS -> MyDataBolusType.EXTENDED
+ else -> return ParseResult.Failed
+ }
+ val bolusAmount = parseResult.valueAt(1)
+ val index = parseResult.valueAt(2)
+ val totalNumEntries = parseResult.valueAt(3)
+ val duration = parseResult.valueAtOrNull(4)
+ val timestamp = combinedDateTime(
+ date = parseResult.valueAt(6),
+ time = parseResult.valueAt(5)
+ )
+
+ return ParseResult.Value(
+ ParsedScreen.MyDataBolusDataScreen(
+ index = index,
+ totalNumEntries = totalNumEntries,
+ timestamp = timestamp,
+ bolusAmount = bolusAmount,
+ bolusType = bolusType,
+ durationInMinutes = if (duration != null) (duration.hour * 60 + duration.minute) else null
+ )
+ )
+ }
+}
+
+class MyDataErrorDataScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val parseResult = SequenceParser(
+ listOf(
+ SingleGlyphTypeParser(Glyph.SmallSymbol::class), // Alert type
+ SingleGlyphTypeParser(Glyph.SmallCharacter::class), // Alert letter ('W' or 'E')
+ IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY, checkForWhitespace = true), // Alert number
+ IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Index
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.DIVIDE)),
+ IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Total num entries
+ StringParser(), // Alert description - ignored
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CLOCK)),
+ TimeParser(), // Timestamp time
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CALENDAR)),
+ DateParser() // Timestamp date
+ )
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+ val alertType = parseResult.valueAt(0).symbol
+ // skipping value #1 (the alert letter)
+ val alertNumber = parseResult.valueAt(2)
+ val index = parseResult.valueAt(3)
+ val totalNumEntries = parseResult.valueAt(4)
+ // skipping value #5 (the alert description)
+ val timestamp = combinedDateTime(
+ date = parseResult.valueAt(7),
+ time = parseResult.valueAt(6)
+ )
+
+ return ParseResult.Value(
+ ParsedScreen.MyDataErrorDataScreen(
+ index = index,
+ totalNumEntries = totalNumEntries,
+ timestamp = timestamp,
+ alert = if (alertType == SmallSymbol.WARNING) AlertScreenContent.Warning(alertNumber) else AlertScreenContent.Error(alertNumber)
+ )
+ )
+ }
+}
+
+class MyDataDailyTotalsScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val parseResult = SequenceParser(
+ listOf(
+ IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Index
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.DIVIDE)),
+ IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Total num entries
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.SUM)),
+ DecimalParser(), // Total daily amount
+ SingleGlyphParser(Glyph.SmallCharacter('U')),
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CALENDAR)),
+ DateParser() // Timestamp date
+ )
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+ val index = parseResult.valueAt(0)
+ val totalNumEntries = parseResult.valueAt(1)
+ val totalDailyAmount = parseResult.valueAt(2)
+ val date = parseResult.valueAt(3)
+
+ return ParseResult.Value(
+ ParsedScreen.MyDataDailyTotalsScreen(
+ index = index,
+ totalNumEntries = totalNumEntries,
+ date = date,
+ totalDailyAmount = totalDailyAmount
+ )
+ )
+ }
+}
+
+class MyDataTbrDataScreenParser : Parser() {
+ override fun parseImpl(parseContext: ParseContext): ParseResult {
+ val parseResult = SequenceParser(
+ listOf(
+ SingleGlyphTypeParser(Glyph.SmallSymbol::class), // TBR type - is ignored (it only indicates whether or not TBR was < or > 100%)
+ IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Percentage
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.PERCENT)),
+ IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Index
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.DIVIDE)),
+ IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Total num entries
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.ARROW)),
+ TimeParser(), // Duration
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CLOCK)),
+ TimeParser(), // Timestamp time
+ SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CALENDAR)),
+ DateParser() // Timestamp date
+ )
+ ).parse(parseContext)
+
+ if (!parseResult.isSuccess)
+ return ParseResult.Failed
+
+ parseResult as ParseResult.Sequence
+ val percentage = parseResult.valueAt(1)
+ val index = parseResult.valueAt(2)
+ val totalNumEntries = parseResult.valueAt(3)
+ val duration = parseResult.valueAt(4)
+ val timestamp = combinedDateTime(
+ date = parseResult.valueAt(6),
+ time = parseResult.valueAt(5)
+ )
+
+ return ParseResult.Value(
+ ParsedScreen.MyDataTbrDataScreen(
+ index = index,
+ totalNumEntries = totalNumEntries,
+ timestamp = timestamp,
+ percentage = percentage,
+ durationInMinutes = duration.hour * 60 + duration.minute
+ )
+ )
+ }
+}
diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Pattern.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Pattern.kt
new file mode 100644
index 0000000000..dba6ec1f14
--- /dev/null
+++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Pattern.kt
@@ -0,0 +1,2034 @@
+package info.nightscout.comboctl.parser
+
+/**
+ * Two-dimensional binary pattern for searching in display frames.
+ *
+ * This stores pixels of a pattern as a boolean array. These pixels are
+ * immutable and used for parsing display frames coming from the Combo.
+ *
+ * The pattern is not directly constructed out of a boolean array, since
+ * that is impractical. Rather, it is constructed out of an array of strings.
+ * This array is the "template" for the pattern, and its items are the "rows".
+ * A whitespace character is interpreted as the boolean value "false", any
+ * other character as "true". This makes it much easier to hardcode a pattern
+ * template in a human-readable form. All template rows must have the exact
+ * same length (at least 1 character), since patterns are rectangular. The
+ * width property is derived from the length of the rows, while the height
+ * equals the number of rows.
+ *
+ * The pixels BooleanArray contains the actual pixels, which are stored in
+ * row-major order. That is: Given coordinates x and y (both starting at 0),
+ * then the corresponding index in the array is (x + y * width).
+ *
+ * Pixels whose value is "true" are considered to be "set", while pixels with
+ * the value "false" are considered to be "cleared". The number of set pixels
+ * is available via the numSetPixels property. This amount is used when
+ * resolving pattern match overlaps to decide if one of the overlapping matches
+ * "wins" and the other has to be ignored.
+ *
+ * @param templateRows The string rows that make up the template.
+ * @property width Width of the pattern, in pixels.
+ * @property height Height of the pattern, in pixels.
+ * @property pixels Boolean array housing the pixels.
+ * @property numSetPixels Number of pixels in the array that are set
+ * (= whose value is true).
+ */
+class Pattern(templateRows: Array) {
+ val width: Int
+ val height: Int
+ val pixels: BooleanArray
+ val numSetPixels: Int
+
+ init {
+ // Sanity checks. The pattern must have at least one row,
+ // and rows must not be empty.
+ height = templateRows.size
+ if (height < 1)
+ throw IllegalArgumentException("Could not generate pattern; no template rows available)")
+
+ width = templateRows[0].length
+ if (height < 1)
+ throw IllegalArgumentException("Could not generate pattern; empty template row detected")
+
+ // Initialize the pixels array and count the number of pixels.
+ // The latter will be needed during pattern matching in case
+ // matched patterns overlap in the display frame.
+
+ pixels = BooleanArray(width * height) { false }
+
+ var tempNumSetPixels = 0
+
+ templateRows.forEachIndexed { y, row ->
+ // Sanity check in case the pattern is malformed and
+ // this row is of different length than the others.
+ if (row.length != width)
+ throw IllegalArgumentException(
+ "Not all rows are of equal length; row #0: $width row #$y: ${row.length}"
+ )
+
+ // Fill the pixel array with pixels from the template rows.
+ // These contain whitespace for clear pixels and something
+ // else (typically a solid block character) for set pixels.
+ for (x in 0 until width) {
+ val pixel = row[x] != ' '
+ pixels[x + y * width] = pixel
+ if (pixel)
+ tempNumSetPixels++
+ }
+ }
+
+ numSetPixels = tempNumSetPixels
+ }
+}
+
+/**
+ * Available small symbol glyphs.
+ */
+enum class SmallSymbol {
+ CLOCK,
+ LOCK_CLOSED,
+ LOCK_OPENED,
+ CHECK,
+ LOW_BATTERY,
+ NO_BATTERY,
+ WARNING,
+ DIVIDE,
+ RESERVOIR_LOW,
+ RESERVOIR_EMPTY,
+ CALENDAR,
+ SEPARATOR,
+ ARROW,
+ UNITS_PER_HOUR,
+ BOLUS,
+ MULTIWAVE_BOLUS,
+ SPEAKER,
+ ERROR,
+ DOT,
+ UP,
+ DOWN,
+ SUM,
+ BRACKET_RIGHT,
+ BRACKET_LEFT,
+ EXTENDED_BOLUS,
+ PERCENT,
+ BASAL,
+ MINUS,
+ WARRANTY,
+}
+
+/**
+ * Available large symbol glyphs.
+ */
+enum class LargeSymbol {
+ CLOCK,
+ CALENDAR,
+ DOT,
+ SEPARATOR,
+ WARNING,
+ PERCENT,
+ UNITS_PER_HOUR,
+ BASAL_SET,
+ RESERVOIR_FULL,
+ RESERVOIR_LOW,
+ RESERVOIR_EMPTY,
+ ARROW,
+ STOP,
+ CALENDAR_AND_CLOCK,
+ TBR,
+ BOLUS,
+ MULTIWAVE_BOLUS,
+ MULTIWAVE_BOLUS_IMMEDIATE,
+ EXTENDED_BOLUS,
+ BLUETOOTH_SETTINGS,
+ THERAPY_SETTINGS,
+ PUMP_SETTINGS,
+ MENU_SETTINGS,
+ BASAL,
+ MY_DATA,
+ REMINDER_SETTINGS,
+ CHECK,
+ ERROR
+}
+
+/**
+ * Class specifying a glyph.
+ *
+ * A "glyph" is a character, digit, or symbol for which a pattern exists that
+ * can be search for in a Combo display frame. Glyphs can be "small" or "large"
+ * (this primarily refers to the glyph's height). During pattern matching,
+ * if matches overlap, and one match is for a small glyph and the other is for
+ * a large glyph, the large one "wins", and the small match is ignored.
+ *
+ * By using the sealed class and its subclasses, it becomes possible to add
+ * context to the hard-coded patterns below. When a pattern matches a subregion
+ * in a frame, the corresponding Glyph subclass informs about what the discovered
+ * subregion stands for.
+ *
+ * @property isLarge true if this is a "large" glyph.
+ */
+sealed class Glyph(val isLarge: Boolean) {
+ data class SmallDigit(val digit: Int) : Glyph(false)
+ data class SmallCharacter(val character: Char) : Glyph(false)
+ data class SmallSymbol(val symbol: info.nightscout.comboctl.parser.SmallSymbol) : Glyph(false)
+ data class LargeDigit(val digit: Int) : Glyph(true)
+ data class LargeCharacter(val character: Char) : Glyph(true)
+ data class LargeSymbol(val symbol: info.nightscout.comboctl.parser.LargeSymbol) : Glyph(true)
+}
+
+/**
+ * Map of hard-coded patterns, each associated with a glyph specifying what the pattern stands for.
+ */
+val glyphPatterns = mapOf(
+ Glyph.LargeSymbol(LargeSymbol.CLOCK) to Pattern(arrayOf(
+ " ",
+ " █████ ",
+ " ██ ██ ",
+ " █ █ █ ",
+ " █ █ ██ ",
+ "█ █ █ ",
+ "█ █ ██",
+ "█ ████ ██",
+ "█ ██",
+ "█ ██",
+ " █ ███",
+ " █ ██ ",
+ " ██ ████ ",
+ " █████████ ",
+ " █████ "
+ )),
+ Glyph.LargeSymbol(LargeSymbol.CALENDAR) to Pattern(arrayOf(
+ " ",
+ "█████████████ ",
+ "█ ██",
+ "██████████████",
+ "█ █ █ █ █ █ ██",
+ "██████████████",
+ "█ █ █ █ █ █ ██",
+ "██████████████",
+ "█ █ █ █ █ █ ██",
+ "██████████████",
+ "█ █ █ █ ██████",
+ "██████████████",
+ " █████████████",
+ " ",
+ " "
+ )),
+ Glyph.LargeSymbol(LargeSymbol.DOT) to Pattern(arrayOf(
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ███ ",
+ " ███ ",
+ " ███ "
+ )),
+ Glyph.LargeSymbol(LargeSymbol.SEPARATOR) to Pattern(arrayOf(
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ███ ",
+ " ███ ",
+ " ███ ",
+ " ",
+ " ",
+ " ███ ",
+ " ███ ",
+ " ███ ",
+ " ",
+ " "
+ )),
+ Glyph.LargeSymbol(LargeSymbol.WARNING) to Pattern(arrayOf(
+ " ██ ",
+ " ████ ",
+ " █ █ ",
+ " ██ ██ ",
+ " █ █ ",
+ " ██ ██ ██ ",
+ " █ ██ █ ",
+ " ██ ██ ██ ",
+ " █ ██ █ ",
+ " ██ ██ ██ ",
+ " █ █ ",
+ " ██ ██ ██ ",
+ " █ █ ",
+ "████████████████",
+ " ███████████████"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.PERCENT) to Pattern(arrayOf(
+ " ██ ██",
+ "████ ██ ",
+ "████ ██ ",
+ " ██ ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ██ ",
+ " ██ ████",
+ " ██ ████",
+ "██ ██ ",
+ " "
+ )),
+ Glyph.LargeSymbol(LargeSymbol.UNITS_PER_HOUR) to Pattern(arrayOf(
+ "██ ██ ██ ██ ",
+ "██ ██ ██ ██ ",
+ "██ ██ ██ ██ ",
+ "██ ██ ██ ██ ",
+ "██ ██ ██ █████ ",
+ "██ ██ ██ ███ ██",
+ "██ ██ ██ ██ ██",
+ "██ ██ ██ ██ ██",
+ "██ ██ ██ ██ ██",
+ "██ ██ ██ ██ ██",
+ "██ ██ ██ ██ ██",
+ " ████ ██ ██ ██"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.BASAL_SET) to Pattern(arrayOf(
+ " ",
+ " ███████ ",
+ " ███████ ",
+ " ██ █ ██ ",
+ " ███ ████████",
+ " ██ █ ███████",
+ "████████ █ █ █ ██",
+ "███████ █ █ █ ███",
+ "██ █ █ █ █ █ █ ██",
+ "███ █ █ █ █ █ ███",
+ "██ █ █ █ █ █ █ ██",
+ "███ █ █ █ █ █ ███",
+ "██ █ █ █ █ █ █ ██",
+ "███ █ █ █ █ █ ███",
+ "██ █ █ █ █ █ █ ██"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.RESERVOIR_FULL) to Pattern(arrayOf(
+ " ",
+ "████████████████████ ",
+ "████████████████████ ",
+ "████████████████████ ███",
+ "██████████████████████ █",
+ "██████████████████████ █",
+ "██████████████████████ █",
+ "██████████████████████ █",
+ "████████████████████ ███",
+ "████████████████████ ",
+ "████████████████████ ",
+ " "
+ )),
+ Glyph.LargeSymbol(LargeSymbol.RESERVOIR_LOW) to Pattern(arrayOf(
+ " ",
+ "████████████████████ ",
+ "█ █ █ █ ████ ",
+ "█ █ █ █ ████ ███",
+ "█ ██████ █",
+ "█ ██████ █",
+ "█ ██████ █",
+ "█ ██████ █",
+ "█ ████ ███",
+ "█ ████ ",
+ "████████████████████ ",
+ " "
+ )),
+ Glyph.LargeSymbol(LargeSymbol.RESERVOIR_EMPTY) to Pattern(arrayOf(
+ " ",
+ "████████████████████ ",
+ "█ █ █ █ █ █ ",
+ "█ █ █ █ █ █ ███",
+ "█ ███ █",
+ "█ █ █",
+ "█ █ █",
+ "█ ███ █",
+ "█ █ ███",
+ "█ █ ",
+ "████████████████████ ",
+ " "
+ )),
+ Glyph.LargeSymbol(LargeSymbol.ARROW) to Pattern(arrayOf(
+ " ",
+ " ██ ",
+ " ███ ",
+ " ████ ",
+ " █████ ",
+ " ██████ ",
+ "███████████████ ",
+ "████████████████",
+ "████████████████",
+ "███████████████ ",
+ " ██████ ",
+ " █████ ",
+ " ████ ",
+ " ███ ",
+ " ██ "
+ )),
+ Glyph.LargeSymbol(LargeSymbol.EXTENDED_BOLUS) to Pattern(arrayOf(
+ " ",
+ "█████████████ ",
+ "█████████████ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ █████",
+ "██ █████"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.MULTIWAVE_BOLUS) to Pattern(arrayOf(
+ " ",
+ "██████ ",
+ "██████ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ ████████████",
+ "██ ████████████",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.BOLUS) to Pattern(arrayOf(
+ " ██████ ",
+ " ██████ ",
+ " ██ ██ ",
+ " ██ ██ ",
+ " ██ ██ ",
+ " ██ ██ ",
+ " ██ ██ ",
+ " ██ ██ ",
+ " ██ ██ ",
+ " ██ ██ ",
+ " ██ ██ ",
+ " ██ ██ ",
+ " ██ ██ ",
+ "█████ ████████",
+ "█████ ████████"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.MULTIWAVE_BOLUS_IMMEDIATE) to Pattern(arrayOf(
+ "██████ ",
+ "██████ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ ██ ",
+ "██ ██ ██ ██ ██",
+ "██ ██ ██ ██ ██",
+ "██ ",
+ "██ ██",
+ "██ ██",
+ "██ ",
+ "██ ██",
+ "██ ██"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.STOP) to Pattern(arrayOf(
+ " ████████ ",
+ " ██████████ ",
+ " ████████████ ",
+ " ██████████████ ",
+ "████████████████",
+ "█ █ █ █ ██",
+ "█ ███ ██ █ █ █ █",
+ "█ ██ ██ █ █ ██",
+ "██ ██ ██ █ █ ███",
+ "█ ██ ██ █ ███",
+ "████████████████",
+ " ██████████████ ",
+ " ████████████ ",
+ " ██████████ ",
+ " ████████ "
+ )),
+ Glyph.LargeSymbol(LargeSymbol.CALENDAR_AND_CLOCK) to Pattern(arrayOf(
+ " █████ ",
+ " █ █ ",
+ "██████ █ █ ",
+ "█ █ █ █ ",
+ "█████ █ █ ",
+ "█ █ █ ███ █ ",
+ "█████ ",
+ "█ █ █ ███████",
+ "██████ █ █",
+ "█ █ █ █ █ ██",
+ "█████████ █ █ █",
+ "█ █ █ █ █ ██ █ █",
+ "█████████ █ █ █",
+ " ████████ ███████"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.TBR) to Pattern(arrayOf(
+ " ███████ ██ ██",
+ " ███████ ████ ██ ",
+ " ██ ██ ████ ██ ",
+ " ██ ███████ ██ ██ ",
+ " ██ ███████ ██ ",
+ "███████ ██ ██ ██ ",
+ "███████ ██ ██ ██ ",
+ "██ ██ ██ ██ ██ ",
+ "██ ██ ██ ██ ██ ",
+ "██ ██ ██ ██ ██ ",
+ "██ ██ ██ ██ ██ ██ ",
+ "██ ██ ██ ██ ██ ████",
+ "██ ██ ██ ██ ██ ████",
+ "██ ██ ██ ██ ██ ██ "
+ )),
+ Glyph.LargeSymbol(LargeSymbol.BASAL) to Pattern(arrayOf(
+ " ",
+ " ███████ ",
+ " ███████ ",
+ " ██ ██ ",
+ " ██ ███████",
+ " ██ ███████",
+ "███████ ██ ██",
+ "███████ ██ ██",
+ "██ ██ ██ ██",
+ "██ ██ ██ ██",
+ "██ ██ ██ ██",
+ "██ ██ ██ ██",
+ "██ ██ ██ ██",
+ "██ ██ ██ ██",
+ "██ ██ ██ ██"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.PUMP_SETTINGS) to Pattern(arrayOf(
+ "███████████ ",
+ "███████████ ",
+ "████████████ ",
+ "██ ███ ",
+ "██ ████ ",
+ "█████████████ ",
+ "██ ██████ ",
+ "███████████████ ██",
+ "███████████████ █",
+ " ██",
+ " █ █ █",
+ " ██ █ █",
+ " █ █ █",
+ " ███████"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.PUMP_SETTINGS) to Pattern(arrayOf(
+ "███████████ ",
+ "███████████ ",
+ "████████████ ",
+ "██ ███ ",
+ "██ ████ ",
+ "█████████████ ",
+ "██ ██████ ",
+ "███████████████ ██",
+ "███████████████ █",
+ " ██",
+ " █ █ █",
+ " ██ █ █",
+ " █ █ █",
+ " ███████"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.THERAPY_SETTINGS) to Pattern(arrayOf(
+ " ████ ",
+ " █ █ ",
+ " █ █ ",
+ "████ ████ ",
+ "█ █ ",
+ "█ █ ",
+ "████ ████ ",
+ " █ █ ",
+ " █ █ ███████",
+ " ████ █ █",
+ " █ ██",
+ " █ █ █",
+ " ██ █ █",
+ " █ █ █",
+ " ███████"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.BLUETOOTH_SETTINGS) to Pattern(arrayOf(
+ " ██████ ",
+ " ███ ████ ",
+ " ███ ███ ",
+ "████ █ ███ ",
+ "████ ██ ██ ",
+ "██ █ █ ███ ",
+ "███ ████ ",
+ "████ ██ ",
+ "███ █ ███████",
+ "██ █ █ █ █",
+ "████ ██ █ ██",
+ "████ █ █ █ █",
+ " ███ █ ██ █ █",
+ " ███ ██ █ █ █",
+ " █████ ███████"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.MENU_SETTINGS) to Pattern(arrayOf(
+ " █████████ ",
+ " █ █ ",
+ " █ █ ",
+ "█████████ █ ",
+ "█████████ █ ",
+ "█████████ █ ",
+ "█████████ █ ",
+ "█████ ",
+ "█████ ███████",
+ "█████ █ █",
+ "█████ █ ██",
+ "█████ █ █ █",
+ "█████ ██ █ █",
+ "█████ █ █ █",
+ " ███████"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.MY_DATA) to Pattern(arrayOf(
+ " ████ ",
+ " ██████ ",
+ " ████████ ",
+ " ██ ██ ",
+ " █ ",
+ "███████ █ ",
+ "█ █ █ ",
+ "█ ███ █ ███ ",
+ "█ █ ",
+ "█ ███ █ ████ ",
+ "█ █ █████ ",
+ "█ █ ██████",
+ "███████ ██████"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.REMINDER_SETTINGS) to Pattern(arrayOf(
+ " █ ",
+ " █ █ ",
+ " ███ ",
+ " █ █ █ ",
+ " █ █ █ ",
+ " █ █ ██ ",
+ " █ █ █ ",
+ " █ █ █ ",
+ " █ █ ███████",
+ " █ █ █ █ █",
+ " █ █ █ ██",
+ "█████████ █ █ █",
+ " ███ ██ █ █",
+ " █ █ █ █",
+ " ███████"
+ )),
+ Glyph.LargeSymbol(LargeSymbol.CHECK) to Pattern(arrayOf(
+ " ███",
+ " ███ ",
+ " ███ ",
+ " ███ ",
+ "███ ███ ",
+ " ███ ███ ",
+ " ███ ███ ",
+ " █████ ",
+ " ███ ",
+ " █ "
+ )),
+ Glyph.LargeSymbol(LargeSymbol.ERROR) to Pattern(arrayOf(
+ " █████ ",
+ " █████████ ",
+ " ███████████ ",
+ " ███ █████ ███ ",
+ " ██ ███ ██ ",
+ "████ █ ████",
+ "█████ █████",
+ "██████ ██████",
+ "█████ █████",
+ "████ █ ████",
+ " ██ ███ ██ ",
+ " ███ █████ ███ ",
+ " ███████████ ",
+ " █████████ ",
+ " █████ "
+ )),
+
+ Glyph.LargeDigit(0) to Pattern(arrayOf(
+ " ████ ",
+ " ██ ██ ",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ " ██ ██ ",
+ " ████ "
+ )),
+ Glyph.LargeDigit(1) to Pattern(arrayOf(
+ " ██ ",
+ " ███ ",
+ " ████ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ "
+ )),
+ Glyph.LargeDigit(2) to Pattern(arrayOf(
+ " ████ ",
+ " ██ ██ ",
+ "██ ██",
+ "██ ██",
+ " ██",
+ " ██",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ "██ ",
+ "██ ",
+ "██ ",
+ "████████"
+ )),
+ Glyph.LargeDigit(3) to Pattern(arrayOf(
+ " █████ ",
+ "██ ██ ",
+ " ██",
+ " ██",
+ " ██",
+ " ██ ",
+ " ███ ",
+ " ██ ",
+ " ██",
+ " ██",
+ " ██",
+ " ██",
+ " ██",
+ "██ ██ ",
+ " █████ "
+
+ )),
+ Glyph.LargeDigit(4) to Pattern(arrayOf(
+ " ██ ",
+ " ███ ",
+ " ███ ",
+ " ████ ",
+ " █ ██ ",
+ " ██ ██ ",
+ " █ ██ ",
+ " ██ ██ ",
+ "██ ██ ",
+ "████████",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ "
+
+ )),
+ Glyph.LargeDigit(5) to Pattern(arrayOf(
+ "███████ ",
+ "██ ",
+ "██ ",
+ "██ ",
+ "██ ",
+ "██████ ",
+ " ██ ",
+ " ██",
+ " ██",
+ " ██",
+ " ██",
+ " ██",
+ " ██",
+ "██ ██ ",
+ " █████ "
+ )),
+ Glyph.LargeDigit(6) to Pattern(arrayOf(
+ " ███ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ "██ ",
+ "██████ ",
+ "███ ██ ",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ " ██ ██ ",
+ " ████ "
+ )),
+ Glyph.LargeDigit(7) to Pattern(arrayOf(
+ "████████",
+ " ██",
+ " ██",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ "
+ )),
+ Glyph.LargeDigit(8) to Pattern(arrayOf(
+ " ████ ",
+ " ██ ██ ",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ " ██ ██ ",
+ " ████ ",
+ " ██ ██ ",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ " ██ ██ ",
+ " ████ "
+ )),
+ Glyph.LargeDigit(9) to Pattern(arrayOf(
+ " ████ ",
+ " ██ ██ ",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ " ██ ███",
+ " ██████",
+ " ██",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ██ ",
+ " ███ "
+ )),
+
+ Glyph.LargeCharacter('E') to Pattern(arrayOf(
+ "████████",
+ "██ ",
+ "██ ",
+ "██ ",
+ "██ ",
+ "██ ",
+ "██ ",
+ "███████ ",
+ "██ ",
+ "██ ",
+ "██ ",
+ "██ ",
+ "██ ",
+ "██ ",
+ "████████"
+ )),
+ Glyph.LargeCharacter('W') to Pattern(arrayOf(
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██ ██",
+ "██ ██ ██",
+ "██ ██ ██",
+ "██ ██ ██",
+ "██ ██ ██",
+ "██ ██ ██",
+ "██ ██ ██",
+ "██ ████ ██",
+ "██████████",
+ " ███ ███ ",
+ " █ █ "
+ )),
+ Glyph.LargeCharacter('u') to Pattern(arrayOf(
+ " ",
+ " ",
+ " ",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ "██ ██",
+ " ████ "
+ )),
+
+ Glyph.SmallSymbol(SmallSymbol.CLOCK) to Pattern(arrayOf(
+ " ███ ",
+ " █ █ █ ",
+ "█ █ █",
+ "█ ██ █",
+ "█ █",
+ " █ █ ",
+ " ███ "
+ )),
+ Glyph.SmallSymbol(SmallSymbol.UNITS_PER_HOUR) to Pattern(arrayOf(
+ "█ █ █ █ ",
+ "█ █ █ █ ",
+ "█ █ █ █ █ ",
+ "█ █ █ ██ █",
+ "█ █ █ █ █",
+ "█ █ █ █ █",
+ " ██ █ █ █"
+ )),
+ Glyph.SmallSymbol(SmallSymbol.LOCK_CLOSED) to Pattern(arrayOf(
+ " ███ ",
+ "█ █",
+ "█ █",
+ "█████",
+ "██ ██",
+ "██ ██",
+ "█████"
+ )),
+ Glyph.SmallSymbol(SmallSymbol.LOCK_OPENED) to Pattern(arrayOf(
+ " ███ ",
+ "█ █ ",
+ "█ █ ",
+ " █████",
+ " ██ ██",
+ " ██ ██",
+ " █████"
+ )),
+ Glyph.SmallSymbol(SmallSymbol.CHECK) to Pattern(arrayOf(
+ " █",
+ " ██",
+ "█ ██ ",
+ "███ ",
+ " █ ",
+ " ",
+ " "
+ )),
+ Glyph.SmallSymbol(SmallSymbol.DIVIDE) to Pattern(arrayOf(
+ " ",
+ " █",
+ " █ ",
+ " █ ",
+ " █ ",
+ "█ ",
+ " "
+ )),
+ Glyph.SmallSymbol(SmallSymbol.LOW_BATTERY) to Pattern(arrayOf(
+ "██████████ ",
+ "█ █ ",
+ "███ ██",
+ "███ █",
+ "███ ██",
+ "█ █ ",
+ "██████████ "
+
+ )),
+ Glyph.SmallSymbol(SmallSymbol.NO_BATTERY) to Pattern(arrayOf(
+ "██████████ ",
+ "█ █ ",
+ "█ ██",
+ "█ █",
+ "█ ██",
+ "█ █ ",
+ "██████████ "
+
+ )),
+ Glyph.SmallSymbol(SmallSymbol.RESERVOIR_LOW) to Pattern(arrayOf(
+ "█████████████ ",
+ "█ █ █ █ ██ ███",
+ "█ █ █ █ ████ █",
+ "█ ████ █",
+ "█ ████ █",
+ "█ ██ ███",
+ "█████████████ "
+ )),
+ Glyph.SmallSymbol(SmallSymbol.RESERVOIR_EMPTY) to Pattern(arrayOf(
+ "█████████████ ",
+ "█ █ █ █ █ ███",
+ "█ █ █ █ ███ █",
+ "█ █ █",
+ "█ ███ █",
+ "█ █ ███",
+ "█████████████ "
+ )),
+ Glyph.SmallSymbol(SmallSymbol.CALENDAR) to Pattern(arrayOf(
+ "███████",
+ "█ █",
+ "███████",
+ "█ █ █ █",
+ "███████",
+ "█ █ ███",
+ "███████"
+ )),
+ Glyph.SmallSymbol(SmallSymbol.DOT) to Pattern(arrayOf(
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ██ ",
+ " ██ "
+ )),
+ Glyph.SmallSymbol(SmallSymbol.SEPARATOR) to Pattern(arrayOf(
+ " ",
+ " ██ ",
+ " ██ ",
+ " ",
+ " ██ ",
+ " ██ ",
+ " "
+ )),
+ Glyph.SmallSymbol(SmallSymbol.ARROW) to Pattern(arrayOf(
+ " █ ",
+ " ██ ",
+ "███████ ",
+ "████████",
+ "███████ ",
+ " ██ ",
+ " █ "
+ )),
+ Glyph.SmallSymbol(SmallSymbol.DOWN) to Pattern(arrayOf(
+ " ███ ",
+ " ███ ",
+ " ███ ",
+ "███████",
+ " █████ ",
+ " ███ ",
+ " █ "
+ )),
+ Glyph.SmallSymbol(SmallSymbol.UP) to Pattern(arrayOf(
+ " █ ",
+ " ███ ",
+ " █████ ",
+ "███████",
+ " ███ ",
+ " ███ ",
+ " ███ "
+
+ )),
+ Glyph.SmallSymbol(SmallSymbol.SUM) to Pattern(arrayOf(
+ "██████",
+ "█ █",
+ " █ ",
+ " █ ",
+ " █ ",
+ "█ █",
+ "██████"
+ )),
+ Glyph.SmallSymbol(SmallSymbol.BOLUS) to Pattern(arrayOf(
+ " ███ ",
+ " █ █ ",
+ " █ █ ",
+ " █ █ ",
+ " █ █ ",
+ " █ █ ",
+ "██ ████"
+ )),
+ Glyph.SmallSymbol(SmallSymbol.MULTIWAVE_BOLUS) to Pattern(arrayOf(
+ "███ ",
+ "█ █ ",
+ "█ █ ",
+ "█ ██████",
+ "█ █",
+ "█ █",
+ "█ █"
+ )),
+ Glyph.SmallSymbol(SmallSymbol.EXTENDED_BOLUS) to Pattern(arrayOf(
+ "███████ ",
+ "█ █ ",
+ "█ █ ",
+ "█ █ ",
+ "█ █ ",
+ "█ █ ",
+ "█ ██"
+ )),
+ Glyph.SmallSymbol(SmallSymbol.SPEAKER) to Pattern(arrayOf(
+ " ██ ",
+ " █ █ ",
+ "██ █ ",
+ "██ ██",
+ "██ █ ",
+ " █ █ ",
+ " ██ "
+ )),
+ Glyph.SmallSymbol(SmallSymbol.ERROR) to Pattern(arrayOf(
+ " ███ ",
+ " █████ ",
+ "██ █ ██",
+ "███ ███",
+ "██ █ ██",
+ " █████ ",
+ " ███ "
+ )),
+ Glyph.SmallSymbol(SmallSymbol.WARNING) to Pattern(arrayOf(
+ " █ ",
+ " ███ ",
+ " █ █ ",
+ " █ █ █ ",
+ " █ █ ",
+ "█ █ █",
+ "███████"
+ )),
+ Glyph.SmallSymbol(SmallSymbol.BRACKET_LEFT) to Pattern(arrayOf(
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " "
+ )),
+ Glyph.SmallSymbol(SmallSymbol.BRACKET_RIGHT) to Pattern(arrayOf(
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " "
+ )),
+ Glyph.SmallSymbol(SmallSymbol.PERCENT) to Pattern(arrayOf(
+ "██ ",
+ "██ █",
+ " █ ",
+ " █ ",
+ " █ ",
+ "█ ██",
+ " ██"
+ )),
+ Glyph.SmallSymbol(SmallSymbol.BASAL) to Pattern(arrayOf(
+ " ████ ",
+ " █ ███",
+ "███ █ █",
+ "█ █ █ █",
+ "█ █ █ █",
+ "█ █ █ █",
+ "█ █ █ █"
+ )),
+ Glyph.SmallSymbol(SmallSymbol.MINUS) to Pattern(arrayOf(
+ " ",
+ " ",
+ " ",
+ " █████ ",
+ " ",
+ " ",
+ " "
+ )),
+ Glyph.SmallSymbol(SmallSymbol.WARRANTY) to Pattern(arrayOf(
+ " ███ █ ",
+ " ██ █ ",
+ " █ █ █",
+ "█ █",
+ "█ █ █ ",
+ " █ ██ ",
+ " █ ███ "
+ )),
+
+ Glyph.SmallDigit(0) to Pattern(arrayOf(
+ " ███ ",
+ "█ █",
+ "█ ██",
+ "█ █ █",
+ "██ █",
+ "█ █",
+ " ███ "
+ )),
+ Glyph.SmallDigit(1) to Pattern(arrayOf(
+ " █ ",
+ " ██ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " ███ "
+ )),
+ Glyph.SmallDigit(2) to Pattern(arrayOf(
+ " ███ ",
+ "█ █",
+ " █",
+ " █ ",
+ " █ ",
+ " █ ",
+ "█████"
+ )),
+ Glyph.SmallDigit(3) to Pattern(arrayOf(
+ "█████",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █",
+ "█ █",
+ " ███ "
+ )),
+ Glyph.SmallDigit(4) to Pattern(arrayOf(
+ " █ ",
+ " ██ ",
+ " █ █ ",
+ "█ █ ",
+ "█████",
+ " █ ",
+ " █ "
+
+ )),
+ Glyph.SmallDigit(5) to Pattern(arrayOf(
+ "█████",
+ "█ ",
+ "████ ",
+ " █",
+ " █",
+ "█ █",
+ " ███ "
+ )),
+ Glyph.SmallDigit(6) to Pattern(arrayOf(
+ " ██ ",
+ " █ ",
+ "█ ",
+ "████ ",
+ "█ █",
+ "█ █",
+ " ███ "
+ )),
+ Glyph.SmallDigit(7) to Pattern(arrayOf(
+ "█████",
+ " █",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ "
+ )),
+ Glyph.SmallDigit(8) to Pattern(arrayOf(
+ " ███ ",
+ "█ █",
+ "█ █",
+ " ███ ",
+ "█ █",
+ "█ █",
+ " ███ "
+ )),
+ Glyph.SmallDigit(9) to Pattern(arrayOf(
+ " ███ ",
+ "█ █",
+ "█ █",
+ " ████",
+ " █",
+ " █ ",
+ " ██ "
+ )),
+
+ Glyph.SmallCharacter('A') to Pattern(arrayOf(
+ " █ ",
+ " █ █ ",
+ "█ █",
+ "█████",
+ "█ █",
+ "█ █",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('a') to Pattern(arrayOf(
+ " ███ ",
+ " █",
+ " ████",
+ "█ █",
+ " ████"
+ )),
+ Glyph.SmallCharacter('Ä') to Pattern(arrayOf(
+ "█ █",
+ " ███ ",
+ "█ █",
+ "█ █",
+ "█████",
+ "█ █",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('ă') to Pattern(arrayOf(
+ " █ █ ",
+ " █ ",
+ " █ ",
+ " █ █ ",
+ "█ █",
+ "█████",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('Á') to Pattern(arrayOf(
+ " █ ",
+ " █ ",
+ " ███ ",
+ "█ █",
+ "█████",
+ "█ █",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('á') to Pattern(arrayOf(
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ █ ",
+ "█ █",
+ "█████",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('ã') to Pattern(arrayOf(
+ " █ █",
+ "█ ██ ",
+ " █ ",
+ " █ █ ",
+ "█ █",
+ "█████",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('Ą') to Pattern(arrayOf(
+ " ███ ",
+ "█ █",
+ "█████",
+ "█ █",
+ "█ █",
+ " █ ",
+ " █"
+ )),
+ Glyph.SmallCharacter('Å') to Pattern(arrayOf(
+ " █ ",
+ " █ █ ",
+ " █ ",
+ " █ █ ",
+ "█ █",
+ "█████",
+ "█ █"
+ )),
+
+ Glyph.SmallCharacter('æ') to Pattern(arrayOf(
+ " ████",
+ "█ █ ",
+ "█ █ ",
+ "████ ",
+ "█ █ ",
+ "█ █ ",
+ "█ ███"
+ )),
+
+ Glyph.SmallCharacter('B') to Pattern(arrayOf(
+ "████ ",
+ "█ █",
+ "█ █",
+ "████ ",
+ "█ █",
+ "█ █",
+ "████ "
+ )),
+ Glyph.SmallCharacter('C') to Pattern(arrayOf(
+ " ███ ",
+ "█ █",
+ "█ ",
+ "█ ",
+ "█ ",
+ "█ █",
+ " ███ "
+ )),
+ Glyph.SmallCharacter('ć') to Pattern(arrayOf(
+ " █ ",
+ " █ ",
+ " ████",
+ "█ ",
+ "█ ",
+ "█ ",
+ " ████"
+ )),
+ Glyph.SmallCharacter('č') to Pattern(arrayOf(
+ " █ █ ",
+ " █ ",
+ " ████",
+ "█ ",
+ "█ ",
+ "█ ",
+ " ████"
+ )),
+ Glyph.SmallCharacter('Ç') to Pattern(arrayOf(
+ " ████",
+ "█ ",
+ "█ ",
+ "█ ",
+ " ████",
+ " █ ",
+ " ██ "
+ )),
+
+ Glyph.SmallCharacter('D') to Pattern(arrayOf(
+ "███ ",
+ "█ █ ",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █ ",
+ "███ "
+ )),
+ Glyph.SmallCharacter('E') to Pattern(arrayOf(
+ "█████",
+ "█ ",
+ "█ ",
+ "████ ",
+ "█ ",
+ "█ ",
+ "█████"
+ )),
+ Glyph.SmallCharacter('É') to Pattern(arrayOf(
+ " █ ",
+ " █ ",
+ "█████",
+ "█ ",
+ "████ ",
+ "█ ",
+ "█████"
+ )),
+ Glyph.SmallCharacter('Ê') to Pattern(arrayOf(
+ " █ ",
+ " █ █ ",
+ "█████",
+ "█ ",
+ "████ ",
+ "█ ",
+ "█████"
+ )),
+ Glyph.SmallCharacter('Ě') to Pattern(arrayOf(
+ " █ █ ",
+ " █ ",
+ "█████",
+ "█ ",
+ "████ ",
+ "█ ",
+ "█████"
+ )),
+ Glyph.SmallCharacter('ę') to Pattern(arrayOf(
+ "█████",
+ "█ ",
+ "████ ",
+ "█ ",
+ "█████",
+ " █ ",
+ " ██ "
+ )),
+ Glyph.SmallCharacter('F') to Pattern(arrayOf(
+ "█████",
+ "█ ",
+ "█ ",
+ "████ ",
+ "█ ",
+ "█ ",
+ "█ "
+ )),
+ Glyph.SmallCharacter('G') to Pattern(arrayOf(
+ " ███ ",
+ "█ █",
+ "█ ",
+ "█ ███",
+ "█ █",
+ "█ █",
+ " ████"
+ )),
+ Glyph.SmallCharacter('H') to Pattern(arrayOf(
+ "█ █",
+ "█ █",
+ "█ █",
+ "█████",
+ "█ █",
+ "█ █",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('I') to Pattern(arrayOf(
+ " ███ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " ███ "
+ )),
+ Glyph.SmallCharacter('i') to Pattern(arrayOf(
+ " █ ",
+ " ",
+ "██ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ "███"
+ )),
+ Glyph.SmallCharacter('í') to Pattern(arrayOf(
+ " █",
+ " █ ",
+ "███",
+ " █ ",
+ " █ ",
+ " █ ",
+ "███"
+ )),
+ Glyph.SmallCharacter('İ') to Pattern(arrayOf(
+ " █ ",
+ " ",
+ "███",
+ " █ ",
+ " █ ",
+ " █ ",
+ "███"
+ )),
+
+ Glyph.SmallCharacter('J') to Pattern(arrayOf(
+ " ███",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ "█ █ ",
+ " ██ "
+ )),
+ Glyph.SmallCharacter('K') to Pattern(arrayOf(
+ "█ █",
+ "█ █ ",
+ "█ █ ",
+ "██ ",
+ "█ █ ",
+ "█ █ ",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('L') to Pattern(arrayOf(
+ "█ ",
+ "█ ",
+ "█ ",
+ "█ ",
+ "█ ",
+ "█ ",
+ "█████"
+ )),
+ Glyph.SmallCharacter('ł') to Pattern(arrayOf(
+ " █ ",
+ " █ ",
+ " █ █ ",
+ " ██ ",
+ "██ ",
+ " █ ",
+ " ████"
+ )),
+ Glyph.SmallCharacter('M') to Pattern(arrayOf(
+ "█ █",
+ "██ ██",
+ "█ █ █",
+ "█ █ █",
+ "█ █",
+ "█ █",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('N') to Pattern(arrayOf(
+ "█ █",
+ "█ █",
+ "██ █",
+ "█ █ █",
+ "█ ██",
+ "█ █",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('Ñ') to Pattern(arrayOf(
+ " █ █",
+ "█ ██ ",
+ "█ █",
+ "██ █",
+ "█ █ █",
+ "█ ██",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('ň') to Pattern(arrayOf(
+ " █ █ ",
+ " █ ",
+ "█ █",
+ "██ █",
+ "█ █ █",
+ "█ ██",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('ń') to Pattern(arrayOf(
+ " █ ",
+ " █ ",
+ "█ █",
+ "██ █",
+ "█ █ █",
+ "█ ██",
+ "█ █"
+ )),
+
+ Glyph.SmallCharacter('O') to Pattern(arrayOf(
+ " ███ ",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ " ███ "
+ )),
+ Glyph.SmallCharacter('Ö') to Pattern(arrayOf(
+ "█ █",
+ " ███ ",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ " ███ "
+ )),
+ Glyph.SmallCharacter('ó') to Pattern(arrayOf(
+ " █ ",
+ " █ ",
+ " ███ ",
+ "█ █",
+ "█ █",
+ "█ █",
+ " ███ "
+ )),
+ Glyph.SmallCharacter('ø') to Pattern(arrayOf(
+ " █",
+ " ███ ",
+ " █ █ █",
+ " █ █ █",
+ " █ █ █",
+ " ███ ",
+ " █ "
+ )),
+ Glyph.SmallCharacter('ő') to Pattern(arrayOf(
+ " █ █",
+ "█ █ ",
+ " ███ ",
+ "█ █",
+ "█ █",
+ "█ █",
+ " ███ "
+ )),
+
+ Glyph.SmallCharacter('P') to Pattern(arrayOf(
+ "████ ",
+ "█ █",
+ "█ █",
+ "████ ",
+ "█ ",
+ "█ ",
+ "█ "
+ )),
+ Glyph.SmallCharacter('Q') to Pattern(arrayOf(
+ " ███ ",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █ █",
+ "█ █ ",
+ " ██ █"
+ )),
+ Glyph.SmallCharacter('R') to Pattern(arrayOf(
+ "████ ",
+ "█ █",
+ "█ █",
+ "████ ",
+ "█ █ ",
+ "█ █ ",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('S') to Pattern(arrayOf(
+ " ████",
+ "█ ",
+ "█ ",
+ " ███ ",
+ " █",
+ " █",
+ "████ "
+ )),
+ Glyph.SmallCharacter('ś') to Pattern(arrayOf(
+ " █ ",
+ " █ ",
+ " ████",
+ "█ ",
+ " ███ ",
+ " █",
+ "████ "
+ )),
+ Glyph.SmallCharacter('š') to Pattern(arrayOf(
+ " █ █ ",
+ " █ ",
+ " ████",
+ "█ ",
+ " ███ ",
+ " █",
+ "████ "
+ )),
+
+ Glyph.SmallCharacter('T') to Pattern(arrayOf(
+ "█████",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ " █ "
+ )),
+ Glyph.SmallCharacter('U') to Pattern(arrayOf(
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ " ███ "
+ )),
+ Glyph.SmallCharacter('u') to Pattern(arrayOf(
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ ██",
+ " ██ █",
+ " "
+ )),
+ Glyph.SmallCharacter('Ü') to Pattern(arrayOf(
+ "█ █",
+ " ",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ " ███ "
+ )),
+ Glyph.SmallCharacter('ú') to Pattern(arrayOf(
+ " █ ",
+ " █ ",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ " ███ "
+ )),
+ Glyph.SmallCharacter('ů') to Pattern(arrayOf(
+ " █ ",
+ " █ █ ",
+ "█ █ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ " ███ "
+ )),
+ Glyph.SmallCharacter('V') to Pattern(arrayOf(
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ " █ █ ",
+ " █ "
+ )),
+ Glyph.SmallCharacter('W') to Pattern(arrayOf(
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █ █",
+ "█ █ █",
+ "█ █ █",
+ " █ █ "
+ )),
+ Glyph.SmallCharacter('X') to Pattern(arrayOf(
+ "█ █",
+ "█ █",
+ " █ █ ",
+ " █ ",
+ " █ █ ",
+ "█ █",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('Y') to Pattern(arrayOf(
+ "█ █",
+ "█ █",
+ "█ █",
+ " █ █ ",
+ " █ ",
+ " █ ",
+ " █ "
+ )),
+ Glyph.SmallCharacter('ý') to Pattern(arrayOf(
+ " █ ",
+ "█ █ █",
+ "█ █",
+ " █ █ ",
+ " █ ",
+ " █ ",
+ " █ "
+ )),
+ Glyph.SmallCharacter('Z') to Pattern(arrayOf(
+ "█████",
+ " █",
+ " █ ",
+ " █ ",
+ " █ ",
+ "█ ",
+ "█████"
+ )),
+ Glyph.SmallCharacter('ź') to Pattern(arrayOf(
+ " █ ",
+ "█████",
+ " █",
+ " ██ ",
+ " █ ",
+ "█ ",
+ "█████"
+ )),
+ Glyph.SmallCharacter('ž') to Pattern(arrayOf(
+ " █ █ ",
+ " █ ",
+ "█████",
+ " █ ",
+ " █ ",
+ " █ ",
+ "█████"
+ )),
+
+ Glyph.SmallCharacter('б') to Pattern(arrayOf(
+ "█████",
+ "█ ",
+ "█ ",
+ "████ ",
+ "█ █",
+ "█ █",
+ "████ "
+ )),
+ Glyph.SmallCharacter('ъ') to Pattern(arrayOf(
+ "██ ",
+ " █ ",
+ " █ ",
+ " ██ ",
+ " █ █",
+ " █ █",
+ " ██ "
+ )),
+ Glyph.SmallCharacter('м') to Pattern(arrayOf(
+ "█ █",
+ "██ ██",
+ "█ █ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('л') to Pattern(arrayOf(
+ " ████",
+ " █ █",
+ " █ █",
+ " █ █",
+ " █ █",
+ " █ █",
+ "██ █"
+ )),
+ Glyph.SmallCharacter('ю') to Pattern(arrayOf(
+ "█ █ ",
+ "█ █ █",
+ "█ █ █",
+ "███ █",
+ "█ █ █",
+ "█ █ █",
+ "█ █ "
+ )),
+ Glyph.SmallCharacter('а') to Pattern(arrayOf(
+ " █ ",
+ " █ █ ",
+ "█ █",
+ "█ █",
+ "█████",
+ "█ █",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('п') to Pattern(arrayOf(
+ "█████",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('я') to Pattern(arrayOf(
+ " ████",
+ "█ █",
+ "█ █",
+ " ████",
+ " █ █",
+ " █ █",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('й') to Pattern(arrayOf(
+ " █ █ ",
+ " █ ",
+ "█ █",
+ "█ ██",
+ "█ █ █",
+ "██ █",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('Г') to Pattern(arrayOf(
+ "█████",
+ "█ ",
+ "█ ",
+ "█ ",
+ "█ ",
+ "█ ",
+ "█ "
+ )),
+ Glyph.SmallCharacter('д') to Pattern(arrayOf(
+ " ██ ",
+ " █ █ ",
+ " █ █ ",
+ "█ █ ",
+ "█ █ ",
+ "█████",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('ь') to Pattern(arrayOf(
+ "█ ",
+ "█ ",
+ "█ ",
+ "███ ",
+ "█ █",
+ "█ █",
+ "███ "
+ )),
+ Glyph.SmallCharacter('ж') to Pattern(arrayOf(
+ "█ █ █",
+ "█ █ █",
+ " ███ ",
+ " ███ ",
+ "█ █ █",
+ "█ █ █",
+ "█ █ █"
+ )),
+ Glyph.SmallCharacter('ы') to Pattern(arrayOf(
+ "█ █",
+ "█ █",
+ "█ █",
+ "██ █",
+ "█ █ █",
+ "█ █ █",
+ "██ █"
+ )),
+ Glyph.SmallCharacter('у') to Pattern(arrayOf(
+ "█ █",
+ "█ █",
+ "█ █",
+ " ███ ",
+ " █ ",
+ " █ ",
+ "█ "
+ )),
+ Glyph.SmallCharacter('ч') to Pattern(arrayOf(
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ ██",
+ " ██ █",
+ " █",
+ " █"
+ )),
+ Glyph.SmallCharacter('з') to Pattern(arrayOf(
+ " ███ ",
+ " █ █",
+ " █",
+ " ██ ",
+ " █",
+ " █ █",
+ " ███ "
+ )),
+ Glyph.SmallCharacter('ц') to Pattern(arrayOf(
+ "█ █ ",
+ "█ █ ",
+ "█ █ ",
+ "█ █ ",
+ "█ █ ",
+ "█████",
+ " █"
+ )),
+ Glyph.SmallCharacter('и') to Pattern(arrayOf(
+ "█ █",
+ "█ ██",
+ "█ █ █",
+ "█ █ █",
+ "█ █ █",
+ "██ █",
+ "█ █"
+ )),
+
+ Glyph.SmallCharacter('Σ') to Pattern(arrayOf(
+ "█████",
+ "█ ",
+ " █ ",
+ " █ ",
+ " █ ",
+ "█ ",
+ "█████"
+ )),
+ Glyph.SmallCharacter('Δ') to Pattern(arrayOf(
+ " █ ",
+ " █ ",
+ " █ █ ",
+ " █ █ ",
+ "█ █",
+ "█ █",
+ "█████"
+ )),
+ Glyph.SmallCharacter('Φ') to Pattern(arrayOf(
+ " █ ",
+ " ███ ",
+ "█ █ █",
+ "█ █ █",
+ "█ █ █",
+ " ███ ",
+ " █ "
+ )),
+ Glyph.SmallCharacter('Λ') to Pattern(arrayOf(
+ " █ ",
+ " █ █ ",
+ " █ █ ",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █"
+ )),
+ Glyph.SmallCharacter('Ω') to Pattern(arrayOf(
+ " ███ ",
+ "█ █",
+ "█ █",
+ "█ █",
+ "█ █",
+ " █ █ ",
+ "██ ██"
+ )),
+ Glyph.SmallCharacter('υ') to Pattern(arrayOf(
+ "█ █",
+ "█ █",
+ "█ █",
+ " ███ ",
+ " █ ",
+ " █ ",
+ " █ "
+ )),
+ Glyph.SmallCharacter('Θ') to Pattern(arrayOf(
+ " ███ ",
+ "█ █",
+ "█ █",
+ "█ █ █",
+ "█ █",
+ "█ █",
+ " ███ "
+ ))
+)
diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Quickinfo.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Quickinfo.kt
new file mode 100644
index 0000000000..fe9765785a
--- /dev/null
+++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Quickinfo.kt
@@ -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)
diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/TitleStrings.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/TitleStrings.kt
new file mode 100644
index 0000000000..8b13c08d8e
--- /dev/null
+++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/TitleStrings.kt
@@ -0,0 +1,319 @@
+package info.nightscout.comboctl.parser
+
+/**
+ * IDs of known titles.
+ *
+ * Used during parsing to identify the titles in a language-
+ * independent manner by using the parsed screen title as a
+ * key in the [knownScreenTitles] table below.
+ */
+enum class TitleID {
+ QUICK_INFO,
+ TBR_PERCENTAGE,
+ TBR_DURATION,
+ HOUR,
+ MINUTE,
+ YEAR,
+ MONTH,
+ DAY,
+ BOLUS_DATA,
+ ERROR_DATA,
+ DAILY_TOTALS,
+ TBR_DATA
+}
+
+/**
+ * Known screen titles in various languages, associated to corresponding IDs.
+ *
+ * This table is useful for converting parsed screen titles to
+ * IDs, which are language-independent and thus considerably
+ * more useful for identifying screens.
+ *
+ * The titles are written in uppercase, since this shows
+ * subtle nuances in characters better.
+ */
+val knownScreenTitles = mapOf(
+ // English
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "TBR PERCENTAGE" to TitleID.TBR_PERCENTAGE,
+ "TBR DURATION" to TitleID.TBR_DURATION,
+ "HOUR" to TitleID.HOUR,
+ "MINUTE" to TitleID.MINUTE,
+ "YEAR" to TitleID.YEAR,
+ "MONTH" to TitleID.MONTH,
+ "DAY" to TitleID.DAY,
+ "BOLUS DATA" to TitleID.BOLUS_DATA,
+ "ERROR DATA" to TitleID.ERROR_DATA,
+ "DAILY TOTALS" to TitleID.DAILY_TOTALS,
+ "TBR DATA" to TitleID.TBR_DATA,
+
+ // Spanish
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "PORCENTAJE DBT" to TitleID.TBR_PERCENTAGE,
+ "DURACIÓN DE DBT" to TitleID.TBR_DURATION,
+ "HORA" to TitleID.HOUR,
+ "MINUTO" to TitleID.MINUTE,
+ "AÑO" to TitleID.YEAR,
+ "MES" to TitleID.MONTH,
+ "DÍA" to TitleID.DAY,
+ "DATOS DE BOLO" to TitleID.BOLUS_DATA,
+ "DATOS DE ERROR" to TitleID.ERROR_DATA,
+ "TOTALES DIARIOS" to TitleID.DAILY_TOTALS,
+ "DATOS DE DBT" to TitleID.TBR_DATA,
+
+ // French
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "VALEUR DU DBT" to TitleID.TBR_PERCENTAGE,
+ "DURÉE DU DBT" to TitleID.TBR_DURATION,
+ "HEURE" to TitleID.HOUR,
+ "MINUTES" to TitleID.MINUTE,
+ "ANNÉE" to TitleID.YEAR,
+ "MOIS" to TitleID.MONTH,
+ "JOUR" to TitleID.DAY,
+ "BOLUS" to TitleID.BOLUS_DATA,
+ "ERREURS" to TitleID.ERROR_DATA,
+ "QUANTITÉS JOURN." to TitleID.DAILY_TOTALS,
+ "DBT" to TitleID.TBR_DATA,
+
+ // Italian
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "PERCENTUALE PBT" to TitleID.TBR_PERCENTAGE,
+ "DURATA PBT" to TitleID.TBR_DURATION,
+ "IMPOSTARE ORA" to TitleID.HOUR,
+ "IMPOSTARE MINUTI" to TitleID.MINUTE,
+ "IMPOSTARE ANNO" to TitleID.YEAR,
+ "IMPOSTARE MESE" to TitleID.MONTH,
+ "IMPOSTARE GIORNO" to TitleID.DAY,
+ "MEMORIA BOLI" to TitleID.BOLUS_DATA,
+ "MEMORIA ALLARMI" to TitleID.ERROR_DATA,
+ "TOTALI GIORNATA" to TitleID.DAILY_TOTALS,
+ "MEMORIA PBT" to TitleID.TBR_DATA,
+
+ // Russian
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "ПPOЦEHT BБC" to TitleID.TBR_PERCENTAGE,
+ "ПPOДOЛЖИT. BБC" to TitleID.TBR_DURATION,
+ "ЧАCЫ" to TitleID.HOUR,
+ "МИHУTЫ" to TitleID.MINUTE,
+ "ГOД" to TitleID.YEAR,
+ "МECЯЦ" to TitleID.MONTH,
+ "ДEHЬ" to TitleID.DAY,
+ "ДАHHЫE O БOЛЮCE" to TitleID.BOLUS_DATA,
+ "ДАHHЫE OБ O ИБ." to TitleID.ERROR_DATA,
+ "CУTOЧHЫE ДOЗЫ" to TitleID.DAILY_TOTALS,
+ "ДАHHЫE O BБC" to TitleID.TBR_DATA,
+
+ // Turkish
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "GBH YÜZDESİ" to TitleID.TBR_PERCENTAGE,
+ "GBH SÜRESİ" to TitleID.TBR_DURATION,
+ "SAAT" to TitleID.HOUR,
+ "DAKİKA" to TitleID.MINUTE,
+ "YIL" to TitleID.YEAR,
+ "AY" to TitleID.MONTH,
+ "GÜN" to TitleID.DAY,
+ "BOLUS VERİLERİ" to TitleID.BOLUS_DATA,
+ "HATA VERİLERİ" to TitleID.ERROR_DATA,
+ "GÜNLÜK TOPLAM" to TitleID.DAILY_TOTALS,
+ "GBH VERİLERİ" to TitleID.TBR_DATA,
+
+ // Polish
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "PROCENT TDP" to TitleID.TBR_PERCENTAGE,
+ "CZAS TRWANIA TDP" to TitleID.TBR_DURATION,
+ "GODZINA" to TitleID.HOUR,
+ "MINUTA" to TitleID.MINUTE,
+ "ROK" to TitleID.YEAR,
+ "MIESIĄC" to TitleID.MONTH,
+ "DZIEŃ" to TitleID.DAY,
+ "DANE BOLUSA" to TitleID.BOLUS_DATA,
+ "DANE BŁĘDU" to TitleID.ERROR_DATA,
+ "DZIEN. D. CAŁK." to TitleID.DAILY_TOTALS,
+ "DANE TDP" to TitleID.TBR_DATA,
+
+ // Czech
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "PROCENTO DBD" to TitleID.TBR_PERCENTAGE,
+ "TRVÁNÍ DBD" to TitleID.TBR_DURATION,
+ "HODINA" to TitleID.HOUR,
+ "MINUTA" to TitleID.MINUTE,
+ "ROK" to TitleID.YEAR,
+ "MĚSÍC" to TitleID.MONTH,
+ "DEN" to TitleID.DAY,
+ "ÚDAJE BOLUSŮ" to TitleID.BOLUS_DATA,
+ "ÚDAJE CHYB" to TitleID.ERROR_DATA,
+ "CELK. DEN. DÁVKY" to TitleID.DAILY_TOTALS,
+ "ÚDAJE DBD" to TitleID.TBR_DATA,
+
+ // Hungarian
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "TBR SZÁZALÉK" to TitleID.TBR_PERCENTAGE,
+ "TBR IDŐTARTAM" to TitleID.TBR_DURATION,
+ "ÓRA" to TitleID.HOUR,
+ "PERC" to TitleID.MINUTE,
+ "ÉV" to TitleID.YEAR,
+ "HÓNAP" to TitleID.MONTH,
+ "NAP" to TitleID.DAY,
+ "BÓLUSADATOK" to TitleID.BOLUS_DATA,
+ "HIBAADATOK" to TitleID.ERROR_DATA,
+ "NAPI TELJES" to TitleID.DAILY_TOTALS,
+ "TBR-ADATOK" to TitleID.TBR_DATA,
+
+ // Slovak
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "PERCENTO DBD" to TitleID.TBR_PERCENTAGE,
+ "TRVANIE DBD" to TitleID.TBR_DURATION,
+ "HODINA" to TitleID.HOUR,
+ "MINÚTA" to TitleID.MINUTE,
+ "ROK" to TitleID.YEAR,
+ "MESIAC" to TitleID.MONTH,
+ "DEŇ" to TitleID.DAY,
+ "BOLUSOVÉ DÁTA" to TitleID.BOLUS_DATA,
+ "DÁTA O CHYBÁCH" to TitleID.ERROR_DATA,
+ "SÚČTY DŇA" to TitleID.DAILY_TOTALS,
+ "DBD DÁTA" to TitleID.TBR_DATA,
+
+ // Romanian
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "PROCENT RBT" to TitleID.TBR_PERCENTAGE,
+ "DURATA RBT" to TitleID.TBR_DURATION,
+ "ORĂ" to TitleID.HOUR,
+ "MINUT" to TitleID.MINUTE,
+ "AN" to TitleID.YEAR,
+ "LUNĂ" to TitleID.MONTH,
+ "ZI" to TitleID.DAY,
+ "DATE BOLUS" to TitleID.BOLUS_DATA,
+ "DATE EROARE" to TitleID.ERROR_DATA,
+ "TOTALURI ZILNICE" to TitleID.DAILY_TOTALS,
+ "DATE RBT" to TitleID.TBR_DATA,
+
+ // Croatian
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "POSTOTAK PBD-A" to TitleID.TBR_PERCENTAGE,
+ "TRAJANJE PBD-A" to TitleID.TBR_DURATION,
+ "SAT" to TitleID.HOUR,
+ "MINUTE" to TitleID.MINUTE,
+ "GODINA" to TitleID.YEAR,
+ "MJESEC" to TitleID.MONTH,
+ "DAN" to TitleID.DAY,
+ "PODACI O BOLUSU" to TitleID.BOLUS_DATA,
+ "PODACI O GREŠK." to TitleID.ERROR_DATA,
+ "UKUPNE DNEV.DOZE" to TitleID.DAILY_TOTALS,
+ "PODACI O PBD-U" to TitleID.TBR_DATA,
+
+ // Dutch
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "TBD-PERCENTAGE" to TitleID.TBR_PERCENTAGE,
+ "TBD-DUUR" to TitleID.TBR_DURATION,
+ "UREN" to TitleID.HOUR,
+ "MINUTEN" to TitleID.MINUTE,
+ "JAAR" to TitleID.YEAR,
+ "MAAND" to TitleID.MONTH,
+ "DAG" to TitleID.DAY,
+ "BOLUSGEGEVENS" to TitleID.BOLUS_DATA,
+ "FOUTENGEGEVENS" to TitleID.ERROR_DATA,
+ "DAGTOTALEN" to TitleID.DAILY_TOTALS,
+ "TBD-GEGEVENS" to TitleID.TBR_DATA,
+
+ // Greek
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "ПOΣOΣTO П.B.P." to TitleID.TBR_PERCENTAGE,
+ "ΔIАPKEIА П.B.P." to TitleID.TBR_DURATION,
+ "ΩPА" to TitleID.HOUR,
+ "ΛEПTO" to TitleID.MINUTE,
+ "ETOΣ" to TitleID.YEAR,
+ "МHNАΣ" to TitleID.MONTH,
+ "HМEPА" to TitleID.DAY,
+ "ΔEΔOМENА ΔOΣEΩN" to TitleID.BOLUS_DATA,
+ "ΔEΔOМ. ΣΦАΛМАTΩN" to TitleID.ERROR_DATA,
+ "HМEPHΣIO ΣΥNOΛO" to TitleID.DAILY_TOTALS,
+ "ΔEΔOМENА П.B.P." to TitleID.TBR_DATA,
+
+ // Finnish
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "TBA - PROSENTTI" to TitleID.TBR_PERCENTAGE,
+ "TBA - KESTO" to TitleID.TBR_DURATION,
+ "TUNTI" to TitleID.HOUR,
+ "MINUUTTI" to TitleID.MINUTE,
+ "VUOSI" to TitleID.YEAR,
+ "KUUKAUSI" to TitleID.MONTH,
+ "PÄIVÄ" to TitleID.DAY,
+ "BOLUSTIEDOT" to TitleID.BOLUS_DATA,
+ "HÄLYTYSTIEDOT" to TitleID.ERROR_DATA,
+ "PÄIV. KOK.ANNOS" to TitleID.DAILY_TOTALS,
+ "TBA - TIEDOT" to TitleID.TBR_DATA,
+
+ // Norwegian
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "MBD-PROSENT" to TitleID.TBR_PERCENTAGE,
+ "MBD-VARIGHET" to TitleID.TBR_DURATION,
+ "TIME" to TitleID.HOUR,
+ "MINUTT" to TitleID.MINUTE,
+ "ÅR" to TitleID.YEAR,
+ "MÅNED" to TitleID.MONTH,
+ "DAG" to TitleID.DAY,
+ "BOLUSDATA" to TitleID.BOLUS_DATA,
+ "FEILDATA" to TitleID.ERROR_DATA,
+ "DØGNMENGDE" to TitleID.DAILY_TOTALS,
+ "MBD-DATA" to TitleID.TBR_DATA,
+
+ // Portuguese
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "DBT PERCENTAGEM" to TitleID.TBR_PERCENTAGE,
+ "DBT DURAÇÃO" to TitleID.TBR_DURATION,
+ "HORA" to TitleID.HOUR,
+ "MINUTO" to TitleID.MINUTE,
+ "ANO" to TitleID.YEAR,
+ "MÊS" to TitleID.MONTH,
+ "DIA" to TitleID.DAY,
+ "DADOS DE BOLUS" to TitleID.BOLUS_DATA,
+ // on some newer pumps translations have changed, so a menu can have multiple names
+ "DADOS DE ERROS" to TitleID.ERROR_DATA, "DADOS DE ALARMES" to TitleID.ERROR_DATA,
+ "TOTAIS DIÁRIOS" to TitleID.DAILY_TOTALS,
+ "DADOS DBT" to TitleID.TBR_DATA,
+
+ // Swedish
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "TBD PROCENT" to TitleID.TBR_PERCENTAGE,
+ "TBD DURATION" to TitleID.TBR_DURATION,
+ "TIMME" to TitleID.HOUR,
+ "MINUT" to TitleID.MINUTE,
+ "ÅR" to TitleID.YEAR,
+ "MÅNAD" to TitleID.MONTH,
+ "DAG" to TitleID.DAY,
+ "BOLUSDATA" to TitleID.BOLUS_DATA,
+ "FELDATA" to TitleID.ERROR_DATA,
+ "DYGNSHISTORIK" to TitleID.DAILY_TOTALS,
+ "TBD DATA" to TitleID.TBR_DATA,
+
+ // Danish
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "MBR-PROCENT" to TitleID.TBR_PERCENTAGE,
+ "MBR-VARIGHED" to TitleID.TBR_DURATION,
+ "TIME" to TitleID.HOUR,
+ "MINUT" to TitleID.MINUTE,
+ "ÅR" to TitleID.YEAR,
+ "MÅNED" to TitleID.MONTH,
+ "DAG" to TitleID.DAY,
+ "BOLUSDATA" to TitleID.BOLUS_DATA,
+ "FEJLDATA" to TitleID.ERROR_DATA,
+ "DAGLIG TOTAL" to TitleID.DAILY_TOTALS,
+ "MBR-DATA" to TitleID.TBR_DATA,
+
+ // German
+ "QUICK INFO" to TitleID.QUICK_INFO,
+ "TBR WERT" to TitleID.TBR_PERCENTAGE,
+ "TBR DAUER" to TitleID.TBR_DURATION,
+ "STUNDE" to TitleID.HOUR,
+ "MINUTE" to TitleID.MINUTE,
+ "JAHR" to TitleID.YEAR,
+ "MONAT" to TitleID.MONTH,
+ "TAG" to TitleID.DAY,
+ "BOLUSINFORMATION" to TitleID.BOLUS_DATA,
+ "FEHLERMELDUNGEN" to TitleID.ERROR_DATA,
+ "TAGESGESAMTMENGE" to TitleID.DAILY_TOTALS,
+ "TBR-INFORMATION" to TitleID.TBR_DATA,
+
+ // Some pumps came preconfigured with a different quick info name
+ "ACCU CHECK SPIRIT" to TitleID.QUICK_INFO
+)
diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Tokenization.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Tokenization.kt
new file mode 100644
index 0000000000..adf6828c91
--- /dev/null
+++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Tokenization.kt
@@ -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
+
+/**
+ * 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()
+
+ // 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()
+
+ // 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
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/CRCTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/CRCTest.kt
new file mode 100644
index 0000000000..dd2da00700
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/CRCTest.kt
@@ -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)
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/CipherTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/CipherTest.kt
new file mode 100644
index 0000000000..3b65ac5fc9
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/CipherTest.kt
@@ -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())
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/ComboFrameTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/ComboFrameTest.kt
new file mode 100644
index 0000000000..2b76314a01
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/ComboFrameTest.kt
@@ -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 0xCC 0xCC 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 { 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 { parser.parseFrame() }
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/DisplayFrameTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/DisplayFrameTest.kt
new file mode 100644
index 0000000000..e382abef7e
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/DisplayFrameTest.kt
@@ -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)
+ }
+ }
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/GraphTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/GraphTest.kt
new file mode 100644
index 0000000000..d721f05773
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/GraphTest.kt
@@ -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().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().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().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().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)
+ }
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/NonceTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/NonceTest.kt
new file mode 100644
index 0000000000..8d7cee1348
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/NonceTest.kt
@@ -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)
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/PairingSessionTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/PairingSessionTest.kt
new file mode 100644
index 0000000000..2fd989153b
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/PairingSessionTest.kt
@@ -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) : ComboIO {
+ private var curSequenceIndex = 0
+ private val barrier = Channel(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) {
+ 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 {
+ 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")
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/ProgressReporterTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/ProgressReporterTest.kt
new file mode 100644
index 0000000000..a1155d9bf4
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/ProgressReporterTest.kt
@@ -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(
+ 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(
+ 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(
+ 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(
+ 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
+ )
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/PumpIOTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/PumpIOTest.kt
new file mode 100644
index 0000000000..60ee33a5e4
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/PumpIOTest.kt
@@ -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,
+ 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) {
+ 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)
+ }
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/TransportLayerTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/TransportLayerTest.kt
new file mode 100644
index 0000000000..a48ff86aca
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/TransportLayerTest.kt
@@ -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(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 {
+ // 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(exceptionThrownBySendCall.cause)
+
+ val exceptionThrownByReceiveCall = assertFailsWith {
+ tpLayerIO.receive()
+ }
+ System.err.println(
+ "Exception thrown by receive() call (this exception was expected by the test): $exceptionThrownByReceiveCall"
+ )
+ assertIs(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 {
+ coroutineScopeWithWatchdog(1000) {
+ tpLayerIO.receive()
+ }
+ }
+
+ tpLayerIO.stop()
+
+ assertEquals(1, numReceivedDataPackets)
+ }
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/TwofishTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/TwofishTest.kt
new file mode 100644
index 0000000000..5b17fe8db8
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/TwofishTest.kt
@@ -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())
+ }
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestBluetoothDevice.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestBluetoothDevice.kt
new file mode 100644
index 0000000000..2f65a90411
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestBluetoothDevice.kt
@@ -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) {
+ frameParser.pushData(dataToSend)
+ frameParser.parseFrame()?.let {
+ runBlocking {
+ innerScope.async {
+ testComboIO.send(it)
+ }.await()
+ }
+ }
+ }
+
+ override fun blockingReceive(): List = runBlocking {
+ innerScope.async {
+ val retval = testComboIO.receive().toComboFrame()
+ retval
+ }.await()
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestComboIO.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestComboIO.kt
new file mode 100644
index 0000000000..7e5985a6ea
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestComboIO.kt
@@ -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(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)
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestPacketSequence.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestPacketSequence.kt
new file mode 100644
index 0000000000..3e0525494f
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestPacketSequence.kt
@@ -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
+
+fun newTestPacketSequence() = mutableListOf()
+
+sealed class TestRefPacketItem {
+ data class TransportLayerPacketItem(val packetInfo: TransportLayer.OutgoingPacketInfo) : TestRefPacketItem()
+ data class ApplicationLayerPacketItem(val packet: ApplicationLayer.Packet) : TestRefPacketItem()
+}
+
+fun checkTestPacketSequence(referenceSequence: List, testPacketSequence: List) {
+ 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)
+ }
+ }
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestPumpStateStore.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestPumpStateStore.kt
new file mode 100644
index 0000000000..141b6bc855
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestPumpStateStore.kt
@@ -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()
+ 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 = 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
+ }
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/UtilityFunctions.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/UtilityFunctions.kt
new file mode 100644
index 0000000000..c552f6edd5
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/UtilityFunctions.kt
@@ -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
+}
diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/main/ParsedDisplayFrameStreamTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/main/ParsedDisplayFrameStreamTest.kt
new file mode 100644
index 0000000000..839f3bc559
--- /dev/null
+++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/main/ParsedDisplayFrameStreamTest.kt
@@ -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()
+ 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()
+ 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()
+ 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()
+ 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