This commit is contained in:
Milos Kozak 2022-12-11 10:52:37 +01:00
commit 621d8b9614
5 changed files with 173 additions and 37 deletions

153
pump/combov2/README.md Normal file
View file

@ -0,0 +1,153 @@
# Overview over combov2's and ComboCtl's architecture
ComboCtl is the core driver. It uses Kotlin Multiplatform and is written in a platform agnostic
way. The code is located in `comboctl/`, and is also available in [its own separate repository]
(https://github.com/dv1/ComboCtl). That separate repository is kept in sync with the ComboCtl
copy in AndroidAPS as much as possible, with some notable changes (see below). "combov2" is the
name of the AndroidAPS driver. In short: combov2 = ComboCtl + extra AndroidAPS integration code.
## Directory structure
The directory structure of the local ComboCtl itself is:
* `comboctl/src/commonMain/` : The platform agnostic portion of ComboCtl. The vast majority of
ComboCtl's logic is contained there.
* `comboctl/src/androidMain/` : The Android specific code. This in particular contains
implementations of the Bluetooth interfaces that are defined in `commonMain/`.
* `comboctl/src/jvmTest/` : Unit tests. This subdirectory is called `jvmTest` because in the
ComboCtl repository, there is also a `jvmMain/` subdirectory, and the unit tests are run
with the JVM.
The AndroidAPS specific portion of the driver is located in `src/`. This connects ComboCtl with
AndroidAPS. In particular, this is where the `ComboV2Plugin` class is located. That's the main
entrypoint for the combov2 driver plugin.
## Basic description of how ComboCtl communicates with the pump
ComboCtl uses Kotlin coroutines. It uses [the Default dispatcher](https://kotlinlang.
org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-dispatchers/-default.html),
with [a limitedParallelism](https://kotlinlang.org/api/kotlinx.
coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-dispatcher/limited-parallelism.html)
constraint to prevent actual parallelism, that is, to not let coroutine jobs run on multiple
threads concurrently. Coroutines are used in ComboCtl to greatly simplify the communication steps,
which normally require a number of state machines to be implemented manually. Stackless coroutines
like Kotlin's essentially are automatically generated state machines under the hood, and this is
what they are used for here. Enabling parallelism is not part of such a state machine. Furthermore,
communication with the Combo does not benefit from parallelism.
The communication code in ComboCtl is split in higher level operations (in its `Pump` class) and
lower level ones (in its `PumpIO` class). `Pump` instantiates `PumpIO` internally, and focuses on
implementing functionality like reading basal profiles, setting TBRs etc. `PumpIO` implements the
building blocks for these higher level operations. In particular, `PumpIO` has an internal
coroutine scope that is used for sending data to the Combo and for running a "heartbeat" loop.
That "heartbeat" is a message that needs to be regularly sent to the Combo (unless other data is
sent to the Combo in time, like a command to press a button). If nothing is sent to a Combo for
some time, it will eventually disconnect. For this reason, that heartbeat loop is necessary.
PumpIO also contains the code for performing the pump pairing.
Going further down a level, `TransportLayer` implements the IO code to generate packets for the
Combo and parse packets coming from the Combo. This includes code for authenticating outgoing
packets and for checking incoming ones. `TransportLayer` also contains the `IO` subclass, which
actually transfers packets to and receives data from the Combo.
One important detail to keep in mind about the `IO` class is that it enforces a packet send
interval of 200 ms. That is: The time between packet transmission is never shorter than 200 ms
(it is OK to be longer). The interval is important, because the Combo has a ring buffer for the
packet it receives, and transmitting packets to the Combo too quickly causes an overflow and a
subsequent error in the Combo, which then terminates the connection.
The Combo can run in three modes. The first one is the "service" mode, which is only briefly
used for setting up the connection. Immediately after the connection is established, the pump
continues in the "command" or "remote terminal" (abbr. "RT") mode. The "command" mode is what the
remote control of the Combo uses for its direct commands (that is, delivering bolus and retrieving
the latest changes / activities from the history). The "remote terminal" mode replicates the LCD
on the pump itself along with the 4 Combo buttons.
Only a few operations are possible in the command mode. In particular, the driver uses the bolus
delivery command from the command mode, the command to retrieve a history delta, and the command
for getting the pump's current date and time. But everything else (getting basal profile, setting
TBR, getting pump status...) is done in the remote terminal mode, by emulating a user pressing
buttons. This unfortunately means that these operations are performed slowly, but there is no
other choice.
## Details about long-pressing RT buttons
As part of operations like reading the pump's profile, an emulated long RT button press is sometimes
used. Such long presses cause more rapid changes compared to multiple short button presses. A
button press is "long" when the emulated user "holds down" the button, while a short button press
equals pressing and immediately releasing the emulated button.
The greater speed of long button presses comes with a drawback though: "Overshoots" can happen. For
example, if long button pressing is used for adjusting a quantity on screen, then the quantity may
still get incremented/decremented after the emulated user "released" the button. It is therefore
necessary to check the quantity on screen, and finetune it with short button presses afterwards
if necessary.
## Idempotent and non-idempotent high level commands
A command is _idempotent_ if it can be repeated if the connection to the pump was lost. Most
commands are idempotent. For example, reading the basal profile can be repeated if during the
initial basal profile retrieval the connection was lost (for example because the user walked away
from the pump). After a few attempts to repeat the command, an error is produced (to avoid an
infinite loop).
Currently, there is only one non-idempotent command: Delivering a bolus. This one _cannot_ be
repeated, otherwise there is a high risk of infusing too much insulin. Instead, in case of a
connection failure, the delivering bolus command fails immediately and is not automatically
attempted again.
## Automatic datetime adjustments and timezone offset handling
ComboCtl automatically adjusts the date and time of the Combo. This is done through the RT mode,
since there is no command-mode command to _set_ the current datetime (but there is one for
_getting_ the current datetime). But since the Combo cannot store a timezone offset (it only stores
localtime), the timezone offset that has been used so far is stored in a dedicated field in the
pump state store that ComboCtl uses. DST changes and timezone changes can be tracked properly with
this logic.
The pump's current datetime is always retrieved (through the command mode) every time a connection
is established to it, and compared to the system's current datetime. If these two differ too much,
the pump's datetime is automatically adjusted. This keeps the pump's datetime in sync.
## Notes about how TBRs are set
TBRs are set through the remote terminal mode. The driver assumes that the Combo is configured
to use 15-minute TBR duration steps sizes and a TBR percentage maximum of 500%. There is code
in the driver to detect if the maximum is not set to 500%. If AndroidAPS tries to set a percentage
that is higher than the actually configured maximum, then eventually, an error is reported.
:warning: The duration step size cannot be detected by the driver. The user _must_ make sure that
the step size is configured to 15 minutes.
## Pairing with a Combo and the issue with pump queue connection timeouts
When pairing, the pump queue's internal timeout is likely to be reached. Essentially, the queue
tries to connect to the pump right after the driver was selected in the configuration. But
a connection cannot be established because the pump is not yet paired.
When the queue attempts to connect to the pump, it "thinks" that if the connect procedure does not
complete after 120 seconds, then the driver must be stuck somehow. The queue then hits a timeout.
The assumption about 120s is correct if the Combo is already paired (a connection should be set up
in far less time than 120s). But if it is currently being paired, the steps involved can take
about 2-3 minutes.
For this reason, the driver automatically requests a pump update - which connects to the pump -
once pairing is done.
## Changes to ComboCtl in the local copy
The code in `comboctl/` is ComboCtl minus the `jvmMain/` code, which contains code for the Linux
platform. This includes C++ glue code to the BlueZ stack. Since none of this is useful to
AndroidAPS, it is better left out, especially since it consists of almost 9000 lines of code.
Also, the original `comboctl/build.gradle.kts` files is replaced by `comboctl/build.gradle`, which
is much simpler, and builds ComboCtl as a kotlin-android project, not a Kotlin Multiplatform one.
This simplifies integration into AndroidAPS, and avoids multiplatform problems (after all,
Kotlin Multiplatform is still marked as an alpha version feature).
When updating ComboCtl, it is important to keep these differences in mind.
Differences between the copy in `comboctl/` and the original ComboCtl code must be kept as little
as possible, and preferably be transferred to the main ComboCtl project. This helps with keeping the
`comboctl/` copy and the main project in sync since transferring changes then is straightforward.

View file

@ -1,14 +0,0 @@
The code in comboctl/ is ComboCtl minus the jvmMain/ code, which contains code for the Linux platform.
This includes C++ glue code to the BlueZ stack. Since none of this is useful to AndroidAPS, it is better
left out, especially since it consists of almost 9000 lines of code.
Also, the original comboctl/build.gradle.kts files is replaced by comboctl/build.gradle, which is
much simpler, and builds ComboCtl as a kotlin-android project, not a kotlin-multiplatform one.
This simplifies integration into AndroidAPS, and avoids multiplatform problems (after all,
Kotlin Multiplatform is still marked as an alpha version feature).
When updating ComboCtl, it is important to keep these differences in mind.
Differences between the copy in comboctl/ and the original ComboCtl code must be kept as little as
possible, and preferably be transferred to the main ComboCtl project. This helps keep the comboctl/
copy and the main project in sync.

View file

@ -67,8 +67,9 @@ class AndroidBluetoothDevice(
// just yet (for example because the UI is still shown on the LCD), while
// the retryBlocking loop here is in place because the _Android device_
// may not be ready to connect right away.
// TODO: Test and define what happens when all attempts failed.
// The user needs to be informed and given the choice to try again.
// When all attempts fail, retryBlocking() lets the exception pass through.
// That exception is wrapped in BluetoothException, which then needs to be
// handled by the caller.
val totalNumAttempts = 5
retryBlocking(numberOfRetries = totalNumAttempts, delayBetweenRetries = 100) { attemptNumber, previousException ->
if (abortConnectAttempt)

View file

@ -381,13 +381,6 @@ class AndroidBluetoothInterface(private val androidContext: Context) : Bluetooth
// instance was already processed. This check here instead
// verifies if we have seen the same Bluetooth address on
// *different* Android Bluetooth device instances.
// TODO: Test how AndroidBluetoothInterface behaves if the
// device is unpaired while discovery is ongoing (manually by
// the user for example). In theory, this should be handled
// properly by the onBondStateChanged function below.
// TODO: This check may not be necessary on all Android
// devices. On some, it seems to also work if we use the
// first offered BluetoothDevice.
if (comboctlBtAddress !in previouslyDiscoveredDevices) {
previouslyDiscoveredDevices[comboctlBtAddress] = androidBtDevice
logger(LogLevel.DEBUG) {

View file

@ -2743,8 +2743,6 @@ class Pump(
)
}
numObservedScreens++
val factorIndexOnScreen = parsedScreen.beginTime.hour
// numUnits null means the basal profile factor
@ -2752,6 +2750,11 @@ class Pump(
if (parsedScreen.numUnits == null)
return@longPressRTButtonUntil LongPressRTButtonsCommand.ContinuePressingButton
// Increase this _after_ checking for a blinking screen
// to not accidentally count the blinking and non-blinking
// screens as two separate ones.
numObservedScreens++
// If the factor in the profile is >= 0,
// it means it was already read earlier.
if (basalProfileFactors[factorIndexOnScreen] >= 0)
@ -2759,14 +2762,16 @@ class Pump(
val factor = parsedScreen.numUnits
basalProfileFactors[factorIndexOnScreen] = factor
logger(LogLevel.DEBUG) { "Got basal profile factor #$factorIndexOnScreen : $factor" }
numRetrievedFactors++
logger(LogLevel.DEBUG) {
"Got basal profile factor #$factorIndexOnScreen : $factor; $numRetrievedFactors factor(s) read and $numObservedScreens screen(s) observed thus far"
}
getBasalProfileReporter.setCurrentProgressStage(
RTCommandProgressStage.GettingBasalProfile(numRetrievedFactors)
)
numRetrievedFactors++
return@longPressRTButtonUntil if (numObservedScreens >= NUM_COMBO_BASAL_PROFILE_FACTORS)
LongPressRTButtonsCommand.ReleaseButton
else
@ -2819,21 +2824,19 @@ class Pump(
}
}
numRetrievedFactors++
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)
// All factors retrieved. Press BACK repeatedly until we are back at the main menu.
cycleToRTScreen(
rtNavigationContext,
RTNavigationButton.BACK,
ParsedScreen.MainScreen::class
)
getBasalProfileReporter.setCurrentProgressStage(BasicProgressStage.Finished)