diff --git a/.claude/agent-memory/code-reviewer/MEMORY.md b/.claude/agent-memory/code-reviewer/MEMORY.md
index 1c627f918911..e01f7fc122fe 100644
--- a/.claude/agent-memory/code-reviewer/MEMORY.md
+++ b/.claude/agent-memory/code-reviewer/MEMORY.md
@@ -69,6 +69,8 @@
## See Also
-- `equil-migration.md` — detailed Equil Compose migration review (2026-03-09)
+- `carelevo-ble-transport-migration.md` — Carelevo BLE stack unification onto shared `BleTransport`
+ (Dana/Equil/Medtrum abstraction), Phase 1 adapter (`BleTransportGattConnection`) review (2026-07-08).
+ Confirms double-`@Singleton` DI pattern is intentional fleet convention (see Equil precedent), not a bug.
- Earlier migration reviews (NSClient, Tidepool, Wear, SMS, Preferences, EOPatch2): see conversation
history from 2026-03-01 and 2026-03-02.
diff --git a/.claude/agent-memory/code-reviewer/carelevo-ble-transport-migration.md b/.claude/agent-memory/code-reviewer/carelevo-ble-transport-migration.md
new file mode 100644
index 000000000000..8d43b8e25874
--- /dev/null
+++ b/.claude/agent-memory/code-reviewer/carelevo-ble-transport-migration.md
@@ -0,0 +1,81 @@
+# Carelevo BLE Transport Migration (Phase 1) — review notes (2026-07-08)
+
+## Architecture
+- Shared fleet abstraction: `core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/ble/BleTransport.kt`
+ (`BleTransport`/`BleAdapter`/`BleScanner`/`BleGatt`/`BleTransportListener`). Used by Dana/Equil/Medtrum/Carelevo.
+ `BleGatt.writeCharacteristic(data)` and `.enableNotifications()` have NO uuid param — the transport
+ hardcodes a single write char + single notify char internally (one-service/one-write/one-notify patch
+ pump shape). `BleTransportListener.onCharacteristicWritten()` carries no status (unlike
+ `BluetoothGattCallback.onCharacteristicWrite`'s status int) — silently-dropped writes degrade to the
+ caller's own `withTimeout`, they don't fast-fail. Single listener via `setListener()` (last write wins,
+ no ownership enforcement).
+- `pump/carelevo/.../ble/gatt/BleTransportGattConnection.kt` is a NEW adapter bridging `BleTransport` to
+ carelevo's own pre-existing `GattConnection` contract, so `BleClientImpl` (coroutine correlation layer)
+ runs unchanged over either the old `AndroidGattConnection` (direct `BluetoothGatt` wrapper) or the new
+ shared-transport adapter. Mirrors `AndroidGattConnection`'s internals almost line for line (gattMutex +
+ 3x `@Volatile CompletableDeferred` for write/discovery/descriptor acks).
+- `pump/equil/.../ble/EquilBleTransportImpl.kt` is the template `CarelevoBleTransportImpl.kt` was cloned
+ from — near-identical structure (inner Adapter/Scanner/Gatt classes, `@Synchronized` gattCallback for
+ conn-state/svc-discovery/descriptor-write, unsynchronized for char write/read/changed).
+
+## Confirmed non-issues (checked against Equil precedent, don't re-flag)
+- **Double `@Singleton`** (class-level `@Singleton class XxxBleTransportImpl @Inject constructor` PLUS
+ `@Provides @Singleton fun provideXxxBleTransport(impl: XxxBleTransportImpl): XxxBleTransport = impl`)
+ is the established fleet pattern, not a bug. See `app/src/withPumps/kotlin/app/aaps/di/EquilModules.kt`
+ — this indirection exists so the app module can swap in an emulator transport
+ (`EquilEmulatorBleTransport`) at runtime while the impl module's own `@Module class` (non-abstract, so
+ no `@Binds`) still exposes the concrete singleton. Carelevo's `CarelevoBleModule.kt` binds it locally
+ instead (no emulator swap yet — KDoc says "a future emulator impl can be swapped in here"). Functionally
+ identical either way (Dagger caches per scoped binding, exactly one instance).
+- Synchronous listener callback firing INSIDE `gatt.writeCharacteristic()` on the not-connected path
+ (`listener?.onConnectionStateChanged(false)`) correctly aborts the just-registered `writeAck` deferred
+ with no deadlock risk — `completeExceptionally` never tries to reacquire `gattMutex`, and it all runs on
+ the same coroutine/call-stack that already holds the mutex from `writeCharacteristic`'s `withLock` block.
+ Verified via `BleTransportGattConnectionTest` `writeCharacteristic on a not-connected transport fast-
+ fails...` test — legit coverage, not trivially green.
+- `CarelevoBleTransportImpl.onServicesDiscovered` tightening the success condition to
+ `service != null && notifyChara != null && writeChara != null` (vs Equil's bare `service != null`) is a
+ **safety improvement**, not a regression — Equil's version can report false "success" if the service is
+ found but a characteristic UUID lookup inside it fails (firmware/UUID mismatch), only surfacing the real
+ failure later on the first write/enableNotifications call.
+- UUID role wiring verified correct: `BleEnvConfig`/`CarelevoConfig.kt` — service `e1b40001`, TX(notify)
+ `e1b40003` → `notifyChara`, RX(write) `e1b40002` → `writeChara`, CCCD `2902`. Matches Equil's analogous
+ NRF_UART_NOTIFY/NRF_UART_WRITE mapping.
+
+## Real findings (see full review text in conversation 2026-07-08 for details — recap here)
+- **High**: `BleTransportGattConnection` has no self-tracked "closed" flag. `close()` (lines ~122-130)
+ nulls the transport's listener slot (`transport.setListener(null)`) but a *subsequent* call to
+ `writeCharacteristic`/`discoverServices`/`enableNotifications` on the same (now-closed) adapter
+ re-acquires `gattMutex` uncontended, registers a fresh deferred, and calls into the transport — whose
+ not-connected fast-fail path (`listener?.onConnectionStateChanged(false)`) is now a silent no-op because
+ the listener is null. The deferred is never completed → hangs until the caller's own `withTimeout`
+ instead of failing fast. `AndroidGattConnection` avoids this by self-guarding with
+ `val g = gatt ?: throw GattWriteException(...)` at the top of every suspend op (checks its own nulled
+ `gatt` field, doesn't depend on a callback round-trip). Not yet reachable in production (adapter isn't
+ wired into any real call site yet — grep confirms only the test file constructs it), but must be fixed
+ before Phase 2 wiring.
+- **Medium-High (latent/Phase-2)**: Listener-slot ownership has zero enforcement. `CarelevoBleTransportImpl`
+ is `@Singleton`; `BleTransportGattConnection` is meant to be "one adapter instance owns the transport's
+ single listener slot for one connection lifecycle" (its own KDoc) but nothing asserts this. If a future
+ reconnect path ever constructs a new adapter without `close()`-ing the previous one first, the new
+ adapter's `init { transport.setListener(this) }` silently steals the slot; the old adapter's in-flight
+ deferred(s) hang until timeout with no error surfaced.
+- **Medium**: `writeCharacteristic(uuid, payload, withResponse)`'s `uuid` and `withResponse` params, and
+ `enableNotifications(uuid)`'s `uuid` param, are silently ignored by `BleTransportGattConnection` (the
+ underlying `BleGatt` interface has no per-uuid/write-type parameters — single hardcoded write/notify
+ char). No `require()` guard, no doc comment (contrast with the write-ack-status KDoc it does have).
+ A caller passing the wrong UUID (e.g. copy-paste bug) would silently "succeed" against the transport's
+ one real characteristic instead of erroring.
+- **Medium (pre-existing, not a regression)**: `writeAck`/`discoveryAck`/`descriptorAck` are bare mutable
+ fields, not per-op correlation tokens. A late/stale native ack for an aborted operation could in theory
+ complete a *different*, newer operation's deferred after `gattMutex` released and a new op started. This
+ risk is identical in `AndroidGattConnection` (same design) — not introduced by the new adapter, just
+ worth a shared follow-up someday.
+- **Test gaps**: no test for close()-then-reuse (would have caught the High finding above); no test for
+ double-close() idempotency (interface explicitly promises it); no test characterizing two adapters over
+ one transport clobbering the listener slot. Existing tests (register-before-write, synchronous not-
+ connected fast-fail, disconnect aborting in-flight write, full BleClient-over-adapter correlation
+ end-to-end via nested `scope.launch` + `runTest` virtual time) are legitimate, not trivially green.
+
+## See also
+- [MEMORY.md](MEMORY.md) main index
diff --git a/.idea/dictionaries/project_dictionary.xml b/.idea/dictionaries/project_dictionary.xml
index 5ab9ab769492..66d04ee06ef4 100644
--- a/.idea/dictionaries/project_dictionary.xml
+++ b/.idea/dictionaries/project_dictionary.xml
@@ -34,6 +34,7 @@
carbratio
carbs
carbsreq
+ carelevo
careportal
cctl
cellnovo
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 8325c3941f62..e7fdb42b2df8 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -234,6 +234,7 @@ dependencies {
// - :pump:combov2:comboctl is a support lib pulled in transitively by :pump:combov2
// buildFile.exists() skips the phantom :pump:omnipod container Gradle auto-creates from the
// nested :pump:omnipod:* includes (it has no build script / no consumable variant).
+ // :pump:carelevo is included automatically via this filter (present in settings.gradle).
val pumpExclusions = setOf(":pump:virtual", ":pump:combov2:comboctl")
rootProject.subprojects
.filter { it.path.startsWith(":pump:") && it.path !in pumpExclusions && it.buildFile.exists() }
@@ -301,4 +302,3 @@ if (!gitAvailable()) {
if (isMaster() && !allCommitted()) {
throw GradleException("There are uncommitted changes. Clone sources again as described in wiki and do not allow gradle update")
}
-
diff --git a/app/src/withPumps/kotlin/app/aaps/di/CarelevoModules.kt b/app/src/withPumps/kotlin/app/aaps/di/CarelevoModules.kt
new file mode 100644
index 000000000000..33a470ece046
--- /dev/null
+++ b/app/src/withPumps/kotlin/app/aaps/di/CarelevoModules.kt
@@ -0,0 +1,41 @@
+package app.aaps.di
+
+import app.aaps.core.interfaces.configuration.Config
+import app.aaps.core.interfaces.configuration.ExternalOptions
+import app.aaps.core.interfaces.logging.AAPSLogger
+import app.aaps.core.interfaces.logging.LTag
+import app.aaps.pump.carelevo.ble.CarelevoBleTransport
+import app.aaps.pump.carelevo.ble.CarelevoBleTransportImpl
+import app.aaps.pump.carelevo.emulator.CarelevoEmulatorBleTransport
+import app.aaps.pump.carelevo.emulator.CarelevoPumpEmulator
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+class CarelevoModules {
+
+ /**
+ * Selects the emulated patch over the real Bluetooth stack when the `emulate_carelevo` marker
+ * file is present, so the driver can be exercised end-to-end without patch hardware.
+ */
+ @Provides
+ @Singleton
+ fun provideCarelevoBleTransport(
+ config: Config,
+ carelevoBleTransportImpl: CarelevoBleTransportImpl,
+ aapsLogger: AAPSLogger
+ ): CarelevoBleTransport =
+ if (config.isEnabled(ExternalOptions.EMULATE_CARELEVO)) {
+ aapsLogger.debug(LTag.PUMPEMULATOR, "CareLevo emulator active — real Bluetooth is not used")
+ CarelevoEmulatorBleTransport(
+ emulator = CarelevoPumpEmulator(aapsLogger = aapsLogger),
+ aapsLogger = aapsLogger
+ )
+ } else {
+ carelevoBleTransportImpl
+ }
+}
diff --git a/app/src/withPumps/kotlin/app/aaps/di/PumpDriversModule.kt b/app/src/withPumps/kotlin/app/aaps/di/PumpDriversModule.kt
index b73ec1e8f5c8..61f8f46ed1a8 100644
--- a/app/src/withPumps/kotlin/app/aaps/di/PumpDriversModule.kt
+++ b/app/src/withPumps/kotlin/app/aaps/di/PumpDriversModule.kt
@@ -1,5 +1,6 @@
package app.aaps.di
+import app.aaps.pump.carelevo.di.CarelevoModule
import app.aaps.pump.common.di.PumpCommonModule
import app.aaps.pump.common.di.RileyLinkModule
import app.aaps.pump.diaconn.di.DiaconnG8Module
@@ -30,6 +31,8 @@ import info.nightscout.pump.combov2.di.ComboV2Module
MedtrumModule::class,
EquilModule::class,
EquilModules::class,
+ CarelevoModule::class,
+ CarelevoModules::class
]
)
@InstallIn(SingletonComponent::class)
diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt
index 51daa400c22b..dee7202f0ecc 100644
--- a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt
+++ b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt
@@ -13,6 +13,7 @@ enum class ManufacturerType(val description: String) {
Ypsomed("Ypsomed"),
G2e("G2e"),
Eoflow("Eoflow"),
+ CareMedi("CareMedi"),
Equil("Equil");
}
\ No newline at end of file
diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt
index 36dc916fa9a6..594fcebdb045 100644
--- a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt
+++ b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt
@@ -20,7 +20,7 @@ enum class PumpCapability {
DiaconnCapabilities(arrayOf(Capability.Bolus, Capability.ExtendedBolus, Capability.TempBasal, Capability.BasalProfileSet, Capability.Refill, Capability.ReplaceBattery, Capability.TDD, Capability.ManualTDDLoad)), //
EopatchCapabilities(arrayOf(Capability.Bolus, Capability.ExtendedBolus, Capability.TempBasal, Capability.BasalProfileSet, Capability.BasalRate30min)),
MedtrumCapabilities(arrayOf(Capability.Bolus, Capability.TempBasal, Capability.BasalProfileSet, Capability.BasalRate30min, Capability.TDD)), // Technically the pump supports ExtendedBolus, but not implemented (yet)
- ;
+ CarelevoCapabilities(arrayOf(Capability.Bolus, Capability.ExtendedBolus, Capability.TempBasal, Capability.BasalProfileSet, Capability.BasalRate30min));
var children: ArrayList = ArrayList()
diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt
index 81d1d68283c2..9ff2c49e8d70 100644
--- a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt
+++ b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt
@@ -457,6 +457,26 @@ enum class PumpType(
model = "untested",
parent = MEDTRUM_NANO
),
+
+ CAREMEDI_CARELEVO(
+ description = "Carelevo",
+ manufacturer = ManufacturerType.CareMedi,
+ model = "Carelevo",
+ bolusSize = 0.05,
+ specialBolusSize = null,
+ extendedBolusSettings = DoseSettings(0.05, 30, 8 * 60, 0.05, 25.0),
+ pumpTempBasalType = PumpTempBasalType.Absolute,
+ tbrSettings = DoseSettings(0.05, 30, 12 * 60, 0.0, 15.0),
+ specialBasalDurations = arrayOf(Capability.BasalRate_Duration30minAllowed),
+ baseBasalMinValue = 0.05,
+ baseBasalMaxValue = 15.0,
+ baseBasalStep = 0.05,
+ baseBasalSpecialSteps = null,
+ pumpCapability = PumpCapability.CarelevoCapabilities,
+ isPatchPump = true,
+ maxReservoirReading = 300,
+ source = Source.Carelevo
+ ),
EQUIL(
description = "Equil",
manufacturer = ManufacturerType.Equil,
@@ -511,7 +531,8 @@ enum class PumpType(
MDI,
VirtualPump,
Unknown,
- EQuil
+ EQuil,
+ Carelevo
}
companion object {
diff --git a/core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt b/core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt
index e0e79baf5805..398b54c5cc1c 100644
--- a/core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt
+++ b/core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt
@@ -83,6 +83,8 @@ enum class Sources {
Garmin,
Scene, //From Scene activation
Database, // for PersistenceLayer
+
+ Carelevo,
Unknown //if necessary
;
}
\ No newline at end of file
diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/configuration/Config.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/configuration/Config.kt
index 78f4c3b10045..8bf82189a43c 100644
--- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/configuration/Config.kt
+++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/configuration/Config.kt
@@ -35,6 +35,7 @@ enum class ExternalOptions(val filename: String) {
EMULATE_DANA_R("emulate_dana_r"),
EMULATE_DANA_R_KOREAN("emulate_dana_r_korean"),
EMULATE_DANA_R_V2("emulate_dana_r_v2"),
+ EMULATE_CARELEVO("emulate_carelevo"),
ENABLE_OMNIPOD_DRIFT_COMPENSATION("omnipod_drift_compensation"),
}
diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationId.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationId.kt
index ff68f58543c3..797fb19b6c56 100644
--- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationId.kt
+++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationId.kt
@@ -117,6 +117,9 @@ enum class NotificationId(
// Pump — Dana emulator
PUMP_EMULATOR_DISPLAY(INFO, PUMP),
+ // Pump — Carelevo
+ CARELEVO_PATCH_ALERT(URGENT, PUMP, allowMultiple = true),
+
// CGM
BG_READINGS_MISSED(URGENT, CGM),
SENSOR_CHANGE_DETECTED(NORMAL, CGM),
diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/ble/BleTransport.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/ble/BleTransport.kt
index cf3aaec4ac8d..6415ceeadeab 100644
--- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/ble/BleTransport.kt
+++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/ble/BleTransport.kt
@@ -41,7 +41,8 @@ interface BleAdapter {
data class ScannedDevice(
val name: String,
val address: String,
- val scanRecordBytes: ByteArray? = null
+ val scanRecordBytes: ByteArray? = null,
+ val rssi: Int = Int.MIN_VALUE
)
interface BleScanner {
diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginCarelevo.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginCarelevo.kt
new file mode 100644
index 000000000000..ea066e5108f9
--- /dev/null
+++ b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginCarelevo.kt
@@ -0,0 +1,143 @@
+package app.aaps.core.ui.compose.icons
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathFillType
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+/**
+ * Icon for CareLevo Pump Plugin.
+ *
+ * Single-color design using NonZero winding to create holes:
+ * CW outer body → filled border frame
+ * CCW inner panel → transparent window
+ * CCW connector dots (in border) → transparent holes
+ * CW lens circle → ring fill
+ * CCW lens center → transparent center of ring
+ * CW text bars → filled bars inside window
+ *
+ * Works correctly with any tint color (no multi-color tricks).
+ */
+val IcPluginCarelevo: ImageVector by lazy {
+ ImageVector.Builder(
+ name = "IcPluginCarelevo",
+ defaultWidth = 48.dp,
+ defaultHeight = 48.dp,
+ viewportWidth = 24f,
+ viewportHeight = 24f
+ ).apply {
+
+ // ── Combined body + all winding-based cutouts/fills ───────────────────
+ path(
+ fill = SolidColor(Color.Black),
+ fillAlpha = 1.0f,
+ stroke = null,
+ strokeAlpha = 1.0f,
+ strokeLineWidth = 1.0f,
+ strokeLineCap = StrokeCap.Butt,
+ strokeLineJoin = StrokeJoin.Miter,
+ strokeLineMiter = 1.0f,
+ pathFillType = PathFillType.NonZero
+ ) {
+ // ── 1. Outer device body (CW) ─ winding = +1 ──────────────────────
+ moveTo(5.9f, 4.5f)
+ lineTo(18.1f, 4.5f)
+ curveTo(20.5f, 4.5f, 22.5f, 6.5f, 22.5f, 8.9f)
+ lineTo(22.5f, 15.1f)
+ curveTo(22.5f, 17.5f, 20.5f, 19.5f, 18.1f, 19.5f)
+ lineTo(5.9f, 19.5f)
+ curveTo(3.5f, 19.5f, 1.5f, 17.5f, 1.5f, 15.1f)
+ lineTo(1.5f, 8.9f)
+ curveTo(1.5f, 6.5f, 3.5f, 4.5f, 5.9f, 4.5f)
+ close()
+
+ // ── 2. Inner panel (CCW) ─ cancels to 0 → transparent window ──────
+ // Start TL, go DOWN first (CCW in screen-Y-down coords)
+ moveTo(5.8f, 6.5f)
+ curveTo(4.58f, 6.5f, 3.6f, 7.48f, 3.6f, 8.7f)
+ lineTo(3.6f, 15.3f)
+ curveTo(3.6f, 16.52f, 4.58f, 17.5f, 5.8f, 17.5f)
+ lineTo(18.2f, 17.5f)
+ curveTo(19.42f, 17.5f, 20.4f, 16.52f, 20.4f, 15.3f)
+ lineTo(20.4f, 8.7f)
+ curveTo(20.4f, 7.48f, 19.42f, 6.5f, 18.2f, 6.5f)
+ close()
+
+ // ── 3. Connector dot holes (CCW, r=0.4) ─ transparent holes ───────
+ // isPositiveArc=false → CCW arc → creates hole
+ // Top connector (center 6.9, 5.8)
+ moveTo(7.3f, 5.8f)
+ arcToRelative(0.4f, 0.4f, 0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = -0.8f, dy1 = 0f)
+ arcToRelative(0.4f, 0.4f, 0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = 0.8f, dy1 = 0f)
+ // Bottom connector (center 6.9, 18.3)
+ moveTo(7.3f, 18.3f)
+ arcToRelative(0.4f, 0.4f, 0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = -0.8f, dy1 = 0f)
+ arcToRelative(0.4f, 0.4f, 0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = 0.8f, dy1 = 0f)
+ // Right connector (center 21.5, 12.0)
+ moveTo(21.9f, 12.0f)
+ arcToRelative(0.4f, 0.4f, 0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = -0.8f, dy1 = 0f)
+ arcToRelative(0.4f, 0.4f, 0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = 0.8f, dy1 = 0f)
+
+ // ── 4. CARELEVO text bars (CW rects) ─ +1 → fills inside window ───
+ // Go RIGHT first from TL (CW in screen-Y-down coords)
+ moveTo(13.2f, 10.7f)
+ lineTo(19.5f, 10.7f)
+ lineTo(19.5f, 11.2f)
+ lineTo(13.2f, 11.2f)
+ close()
+ moveTo(13.2f, 11.75f)
+ lineTo(18.3f, 11.75f)
+ lineTo(18.3f, 12.25f)
+ lineTo(13.2f, 12.25f)
+ close()
+ moveTo(13.2f, 12.8f)
+ lineTo(19.5f, 12.8f)
+ lineTo(19.5f, 13.3f)
+ lineTo(13.2f, 13.3f)
+ close()
+ }
+
+ // ── Lens arc – 300° open arc, gap on right (C보다 조금 더 길게) ──────────
+ // Center (10.1, 12.0), r = 1.75
+ // Start: 30° above right → (11.62, 11.13)
+ // End : 30° below right → (11.62, 12.88)
+ // Long CCW arc (300°) going up → left → down
+ path(
+ fill = SolidColor(Color.Transparent),
+ fillAlpha = 0f,
+ stroke = SolidColor(Color.Black),
+ strokeAlpha = 1.0f,
+ strokeLineWidth = 0.85f,
+ strokeLineCap = StrokeCap.Round,
+ strokeLineJoin = StrokeJoin.Round,
+ strokeLineMiter = 1.0f
+ ) {
+ moveTo(11.62f, 11.13f)
+ arcToRelative(1.75f, 1.75f, 0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = 0f, dy1 = 1.75f)
+ }
+
+ }.build()
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
+@Composable
+private fun IcPluginCarelevoPreview() {
+ Icon(
+ imageVector = IcPluginCarelevo,
+ contentDescription = null,
+ modifier = Modifier
+ .padding(8.dp)
+ .size(48.dp),
+ tint = Color.Unspecified
+ )
+}
diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/WizardScreen.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/WizardScreen.kt
index 4a1c5a55c7c2..aeba41047641 100644
--- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/WizardScreen.kt
+++ b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/WizardScreen.kt
@@ -38,7 +38,9 @@ import app.aaps.core.ui.compose.dialogs.OkCancelDialog
* @param currentStep Current wizard step (null hides content)
* @param totalSteps Total number of steps for progress indicator
* @param currentStepIndex Zero-based current step position
- * @param canGoBack Whether back navigation is allowed on current step
+ * @param canGoBack Whether leaving the wizard is allowed on the current step. When false, the
+ * toolbar back arrow is hidden and the system back button is swallowed without showing the
+ * cancel dialog — used by steps where aborting is unsafe (e.g. mid-priming).
* @param onBack Called when user confirms exit from the wizard
* @param cancelDialogTitle Title for the cancel confirmation dialog
* @param cancelDialogText Body text for the cancel confirmation dialog
@@ -61,17 +63,21 @@ fun WizardScreen(
) {
var showCancelDialog by remember { mutableStateOf(false) }
- // Take over toolbar: back arrow shows confirmation dialog
+ // Take over toolbar: back arrow shows confirmation dialog (hidden while canGoBack is false —
+ // ViewModels compute per-step canGoBack precisely to lock the user in during irreversible
+ // steps; this parameter used to be accepted but never wired, silently disabling that gating).
if (setToolbarConfig != null) {
// Use a stable callback ref so toolbar icon can trigger dialog
val onRequestCancel = remember { { showCancelDialog = true } }
- DisposableEffect(title, onRequestCancel) {
+ DisposableEffect(title, onRequestCancel, canGoBack) {
setToolbarConfig(
ToolbarConfig(
title = title,
navigationIcon = {
- IconButton(onClick = onRequestCancel) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+ if (canGoBack) {
+ IconButton(onClick = onRequestCancel) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+ }
}
},
actions = {}
@@ -84,9 +90,10 @@ fun WizardScreen(
}
}
- // System back button: always confirm before leaving the wizard
+ // System back button: confirm before leaving the wizard; swallow entirely on steps that
+ // must not be left.
BackHandler(enabled = true) {
- showCancelDialog = true
+ if (canGoBack) showCancelDialog = true
}
if (showCancelDialog) {
diff --git a/database/impl/src/main/kotlin/app/aaps/database/entities/UserEntry.kt b/database/impl/src/main/kotlin/app/aaps/database/entities/UserEntry.kt
index 8916473378c2..23a9712d6d38 100644
--- a/database/impl/src/main/kotlin/app/aaps/database/entities/UserEntry.kt
+++ b/database/impl/src/main/kotlin/app/aaps/database/entities/UserEntry.kt
@@ -215,6 +215,8 @@ data class UserEntry(
BgFragment,
Garmin,
Database, //for PersistenceLayer
+
+ Carelevo,
Unknown, //if necessary
;
diff --git a/database/impl/src/main/kotlin/app/aaps/database/entities/embedments/InterfaceIDs.kt b/database/impl/src/main/kotlin/app/aaps/database/entities/embedments/InterfaceIDs.kt
index 078c6cb40ca6..9ff1fca081e7 100644
--- a/database/impl/src/main/kotlin/app/aaps/database/entities/embedments/InterfaceIDs.kt
+++ b/database/impl/src/main/kotlin/app/aaps/database/entities/embedments/InterfaceIDs.kt
@@ -53,6 +53,7 @@ data class InterfaceIDs @Ignore constructor(
MEDTRUM_UNTESTED,
USER,
CACHE,
+ CARELEVO,
EQUIL;
companion object {
diff --git a/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/PumpTypeExtension.kt b/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/PumpTypeExtension.kt
index e17b554e168d..41be4641bfd4 100644
--- a/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/PumpTypeExtension.kt
+++ b/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/PumpTypeExtension.kt
@@ -42,6 +42,7 @@ fun InterfaceIDs.PumpType.fromDb(): PumpType =
InterfaceIDs.PumpType.MEDTRUM_UNTESTED -> PumpType.MEDTRUM_UNTESTED
InterfaceIDs.PumpType.CACHE -> PumpType.CACHE
InterfaceIDs.PumpType.EQUIL -> PumpType.EQUIL
+ InterfaceIDs.PumpType.CARELEVO -> PumpType.CAREMEDI_CARELEVO
}
fun PumpType.toDb(): InterfaceIDs.PumpType =
@@ -83,5 +84,6 @@ fun PumpType.toDb(): InterfaceIDs.PumpType =
PumpType.MEDTRUM_UNTESTED -> InterfaceIDs.PumpType.MEDTRUM_UNTESTED
PumpType.CACHE -> InterfaceIDs.PumpType.CACHE
PumpType.EQUIL -> InterfaceIDs.PumpType.EQUIL
+ PumpType.CAREMEDI_CARELEVO -> InterfaceIDs.PumpType.CARELEVO
}
diff --git a/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/SourcesExtension.kt b/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/SourcesExtension.kt
index c84dedfd19e7..5e544f1ec2f9 100644
--- a/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/SourcesExtension.kt
+++ b/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/SourcesExtension.kt
@@ -87,6 +87,7 @@ fun UserEntry.Sources.fromDb(): Sources =
UserEntry.Sources.Garmin -> Sources.Garmin
UserEntry.Sources.Scene -> Sources.Scene
UserEntry.Sources.Database -> Sources.Database
+ UserEntry.Sources.Carelevo -> Sources.Carelevo
UserEntry.Sources.Unknown -> Sources.Unknown
}
@@ -174,6 +175,7 @@ fun Sources.toDb(): UserEntry.Sources =
Sources.Scene -> UserEntry.Sources.Scene
Sources.Database -> UserEntry.Sources.Database
Sources.Insulin -> UserEntry.Sources.Insulin
+ Sources.Carelevo -> UserEntry.Sources.Carelevo
Sources.Unknown -> UserEntry.Sources.Unknown
}
diff --git a/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt b/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt
index faeaa91d4f94..cdeff6678232 100644
--- a/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt
+++ b/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt
@@ -47,6 +47,7 @@ import app.aaps.core.ui.compose.icons.IcNote
import app.aaps.core.ui.compose.icons.IcPatchPump
import app.aaps.core.ui.compose.icons.IcPluginAutomation
import app.aaps.core.ui.compose.icons.IcPluginAutotune
+import app.aaps.core.ui.compose.icons.IcPluginCarelevo
import app.aaps.core.ui.compose.icons.IcPluginCombo
import app.aaps.core.ui.compose.icons.IcPluginConfigBuilder
import app.aaps.core.ui.compose.icons.IcPluginDanaI
@@ -109,6 +110,7 @@ class UserEntryPresentationHelperImpl @Inject constructor(
Sources.BgFragment -> IcAaps
Sources.CalibrationDialog -> IcCalibration
Sources.CarbDialog -> IcCarbs
+ Sources.Carelevo -> IcPluginCarelevo
Sources.Combo -> IcPluginCombo
Sources.ConcentrationDialog -> IcPluginInsulin
Sources.ConfigBuilder -> IcPluginConfigBuilder
@@ -267,6 +269,7 @@ class UserEntryPresentationHelperImpl @Inject constructor(
Sources.Wear -> ElementType.AAPS.color()
Sources.WizardDialog -> ElementType.BOLUS_WIZARD.color()
Sources.Xdrip -> ElementType.CGM_XDRIP.color()
+ Sources.Carelevo -> ElementType.PUMP.color()
}
override fun listToPresentationString(list: List) =
diff --git a/pump/carelevo-emulator/build.gradle.kts b/pump/carelevo-emulator/build.gradle.kts
new file mode 100644
index 000000000000..2c8b69a9ea56
--- /dev/null
+++ b/pump/carelevo-emulator/build.gradle.kts
@@ -0,0 +1,22 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.ksp)
+ id("android-module-dependencies")
+ id("test-module-dependencies")
+ id("jacoco-module-dependencies")
+}
+
+android {
+ namespace = "app.aaps.pump.carelevo.emulator"
+}
+
+dependencies {
+ implementation(project(":core:interfaces"))
+ implementation(project(":pump:carelevo"))
+
+ testImplementation(project(":shared:tests"))
+
+ ksp(libs.com.google.dagger.compiler)
+ ksp(libs.com.google.dagger.hilt.compiler)
+ ksp(libs.com.google.dagger.android.processor)
+}
diff --git a/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoEmulatorBleTransport.kt b/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoEmulatorBleTransport.kt
new file mode 100644
index 000000000000..b30b6259f45b
--- /dev/null
+++ b/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoEmulatorBleTransport.kt
@@ -0,0 +1,163 @@
+package app.aaps.pump.carelevo.emulator
+
+import app.aaps.core.interfaces.logging.AAPSLogger
+import app.aaps.core.interfaces.logging.LTag
+import app.aaps.core.interfaces.pump.ble.BleAdapter
+import app.aaps.core.interfaces.pump.ble.BleGatt
+import app.aaps.core.interfaces.pump.ble.BleScanner
+import app.aaps.core.interfaces.pump.ble.BleTransportListener
+import app.aaps.core.interfaces.pump.ble.PairingState
+import app.aaps.core.interfaces.pump.ble.ScannedDevice
+import app.aaps.pump.carelevo.ble.CarelevoBleTransport
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+
+/**
+ * [CarelevoBleTransport] that talks to a [CarelevoPumpEmulator] instead of real Bluetooth, so the
+ * whole CareLevo driver — sessions, pairing, commands, coordinators — runs without patch hardware.
+ *
+ * The seam is narrow: the driver never touches this directly. `BleTransportGattConnection` adapts it
+ * to a `GattConnection` and `BleClientImpl` does opcode correlation above that, so everything the
+ * protocol actually cares about still runs for real. Only the radio is fake.
+ *
+ * Each write is answered synchronously on the calling thread, which keeps tests deterministic: by the
+ * time `writeCharacteristic` returns, the notifications have already been delivered. That is safe
+ * because `BleClientImpl` registers its waiter *before* writing, so an instantaneous reply cannot race.
+ *
+ * The driver opens a fresh session per operation, so `connect`/`close` and `setListener` are exercised
+ * repeatedly; this tolerates the register → close → register cycle rather than warning about it.
+ */
+class CarelevoEmulatorBleTransport(
+ val emulator: CarelevoPumpEmulator = CarelevoPumpEmulator(),
+ private val aapsLogger: AAPSLogger? = null,
+ /** Serial reported by a discovery scan. Defaults to whatever the emulated patch already carries. */
+ private val serialNumberProvider: (() -> String)? = null
+) : CarelevoBleTransport {
+
+ private var listener: BleTransportListener? = null
+
+ @Volatile private var connected = false
+
+ val pumpState: CarelevoPumpState get() = emulator.state
+
+ /** Every frame the driver has written, in order — the assertion surface for tests. */
+ val writes: MutableList = mutableListOf()
+
+ /** Bond state of the emulated patch; flip to drive the driver's bonding paths. */
+ var bonded: Boolean = true
+ var bondCalls: Int = 0
+
+ /** `connect()` returns false, as if the BLE stack refused to start the connection. */
+ var connectRefused: Boolean = false
+
+ /** `connect()` succeeds but the patch never reports CONNECTED — drives the connect timeout. */
+ var reportConnected: Boolean = true
+
+ override var scanAddress: String? = null
+ override var onGattError133: (() -> Unit)? = null
+
+ override val adapter: BleAdapter = EmulatorAdapter()
+ override val scanner: BleScanner = EmulatorScanner()
+ override val gatt: BleGatt = EmulatorGatt()
+
+ private val _pairingState = MutableStateFlow(PairingState())
+ override val pairingState: StateFlow = _pairingState
+
+ override fun updatePairingState(state: PairingState) {
+ _pairingState.value = state
+ }
+
+ override fun setListener(listener: BleTransportListener?) {
+ this.listener = listener
+ }
+
+ // ---- BleAdapter -------------------------------------------------------------------------
+
+ private inner class EmulatorAdapter : BleAdapter {
+
+ override fun enable() {}
+ override fun getDeviceName(address: String): String = deviceName()
+ override fun isDeviceBonded(address: String): Boolean = bonded
+ override fun createBond(address: String): Boolean {
+ bondCalls++
+ bonded = true
+ return true
+ }
+
+ override fun removeBond(address: String) {
+ bonded = false
+ }
+ }
+
+ // ---- BleScanner -------------------------------------------------------------------------
+
+ private inner class EmulatorScanner : BleScanner {
+
+ private val _scannedDevices = MutableSharedFlow(replay = 1, extraBufferCapacity = 10)
+ override val scannedDevices: SharedFlow = _scannedDevices
+
+ override fun startScan() {
+ // Discovery vs reconnect is decided by isNullOrEmpty(), matching CarelevoBleTransportImpl —
+ // a null-only check would treat an empty filter as a reconnect and report a blank address.
+ val filter = scanAddress?.ifEmpty { null }
+ // A discovery scan (no address filter) is where a fresh patch gets its serial.
+ if (filter == null) serialNumberProvider?.invoke()?.let { pumpState.serialNumber = it }
+ val address = filter ?: macAddressString()
+ aapsLogger?.debug(LTag.PUMPEMULATOR, "emulator: scan reporting $address")
+ _scannedDevices.tryEmit(ScannedDevice(name = deviceName(), address = address))
+ }
+
+ override fun stopScan() {}
+ }
+
+ // ---- BleGatt ----------------------------------------------------------------------------
+
+ private inner class EmulatorGatt : BleGatt {
+
+ override fun connect(address: String): Boolean {
+ if (connectRefused) return false
+ connected = true
+ if (reportConnected) listener?.onConnectionStateChanged(true)
+ return true
+ }
+
+ override fun disconnect() {
+ if (connected) {
+ connected = false
+ listener?.onConnectionStateChanged(false)
+ }
+ }
+
+ override fun close() {
+ connected = false
+ }
+
+ override fun discoverServices() {
+ listener?.onServicesDiscovered(true)
+ }
+
+ override fun findCharacteristics(): Boolean = true
+
+ override fun enableNotifications() {
+ listener?.onDescriptorWritten()
+ }
+
+ override fun writeCharacteristic(data: ByteArray) {
+ writes += data
+ listener?.onCharacteristicWritten()
+ emulator.handle(data).forEach { listener?.onCharacteristicChanged(it) }
+ }
+ }
+
+ private fun deviceName(): String = "$DEVICE_NAME_PREFIX${pumpState.serialNumber}"
+
+ private fun macAddressString(): String =
+ pumpState.macAddress.joinToString(separator = ":") { "%02X".format(it) }
+
+ private companion object {
+
+ const val DEVICE_NAME_PREFIX = "CareLevo-"
+ }
+}
diff --git a/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpEmulator.kt b/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpEmulator.kt
new file mode 100644
index 000000000000..a1299443cc80
--- /dev/null
+++ b/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpEmulator.kt
@@ -0,0 +1,544 @@
+package app.aaps.pump.carelevo.emulator
+
+import app.aaps.core.interfaces.logging.AAPSLogger
+import app.aaps.core.interfaces.logging.LTag
+import kotlin.experimental.xor
+import kotlin.math.roundToInt
+
+/**
+ * Answers CareLevo BLE commands the way a real patch does, against a live [CarelevoPumpState].
+ *
+ * The CareLevo wire has no framing, length prefix, CRC or encryption: a request is
+ * `[opcode][payload]` written verbatim, and a response is a raw notification whose byte 0 is the
+ * opcode the client correlates on. So [handle] is the whole protocol — it takes one written frame
+ * and returns the notification frames the patch would send back, in order.
+ *
+ * Returning a *list* is what makes the two irregular shapes fall out naturally: the 0x33 patch-info
+ * report is two frames (0x93 + 0x94), and the 0x12 safety check streams N progress frames before its
+ * terminal one. An empty list means the patch stays silent.
+ *
+ * Response opcodes are a hardcoded table, never computed. The rule is *mostly* `request + 0x60`
+ * (arithmetic — `or 0x60` breaks every opcode from 0x21 up), but there are real exceptions: 0x19→0x7A
+ * and 0x1A→0x79 are swapped, and 0x4B answers on 0xBB.
+ */
+class CarelevoPumpEmulator(
+ val state: CarelevoPumpState = CarelevoPumpState(),
+ private val aapsLogger: AAPSLogger? = null
+) {
+
+ /** Frames the patch sends back for [request]; empty when it stays silent. */
+ fun handle(request: ByteArray): List {
+ if (request.isEmpty()) return emptyList()
+ val opcode = request[0]
+ if (opcode in state.silentOpcodes) {
+ aapsLogger?.debug(LTag.PUMPEMULATOR, "emulator: silently dropping 0x${hex(opcode)}")
+ return emptyList()
+ }
+ val responses = dispatch(opcode, request)
+ aapsLogger?.debug(
+ LTag.PUMPEMULATOR,
+ "emulator: 0x${hex(opcode)} -> ${responses.joinToString(", ") { "0x${hex(it[0])}" }.ifEmpty { "(silence)" }}"
+ )
+ return responses
+ }
+
+ private fun dispatch(opcode: Byte, request: ByteArray): List = when (opcode) {
+ OP_SET_TIME -> setTime(request)
+ OP_SAFETY_CHECK -> safetyCheck()
+ OP_BASAL_SET -> basalProgram(request, RESP_BASAL_SET)
+ OP_BASAL_UPDATE -> basalProgram(request, RESP_BASAL_UPDATE)
+ OP_NOTICE_THRESHOLD -> noticeThreshold(request)
+ OP_INFUSION_THRESHOLD -> infusionThreshold(request)
+ OP_BUZZ_MODE -> buzzMode(request)
+ OP_NEEDLE_ACK -> needleAck(request)
+ OP_NEEDLE_STATUS -> needleStatus()
+ OP_THRESHOLD_SETUP -> thresholdSetup(request)
+ OP_ADDITIONAL_PRIMING -> additionalPriming()
+ OP_TEMP_BASAL -> tempBasal()
+ OP_IMMEDIATE_BOLUS -> immediateBolus(request)
+ OP_EXTEND_BOLUS -> extendBolus(request)
+ OP_PUMP_STOP -> pumpStop(request)
+ OP_PUMP_RESUME -> pumpResume(request)
+ OP_EXTEND_BOLUS_CANCEL -> extendBolusCancel()
+ OP_BOLUS_CANCEL -> bolusCancel()
+ OP_TEMP_BASAL_CANCEL -> tempBasalCancel()
+ OP_INFUSION_INFO -> infusionInfo(request)
+ OP_PATCH_INFO -> patchInfoFrames()
+ OP_PATCH_DISCARD -> patchDiscard()
+ OP_MAC_ADDRESS -> macAddress(request)
+ OP_ALARM_CLEAR -> alarmClear(request)
+ OP_ALERT_ALARM_SET -> alertAlarmSet(request)
+ OP_APP_AUTH -> appAuth(request)
+ else -> {
+ aapsLogger?.debug(LTag.PUMPEMULATOR, "emulator: unknown opcode 0x${hex(opcode)}")
+ emptyList()
+ }
+ }
+
+ // ---- Pairing ----------------------------------------------------------------------------
+
+ /** `[0x9B][mac 6][checksum ..]`. The key is retained so [appAuth] can verify the app's fold. */
+ private fun macAddress(request: ByteArray): List {
+ state.lastMacKey = if (request.size > 1) request.u(1) else null
+ return listOf(byteArrayOf(RESP_MAC_ADDRESS) + state.macAddress + state.macCheckSum)
+ }
+
+ /**
+ * Verifies the app's handshake rather than rubber-stamping it: the app XOR-folds
+ * `(mac || checksum)` starting from the key it sent on 0x3B, so the same fold here must match.
+ * A real patch rejects a wrong fold, and so does this — that is the point of emulating it.
+ */
+ private fun appAuth(request: ByteArray): List {
+ val key = state.lastMacKey
+ val expected = key?.let { (state.macAddress + state.macCheckSum).fold(it.toByte()) { acc, b -> acc xor b } }
+ val offered = if (request.size > 1) request[1] else null
+ val result = when {
+ state.resultCodeOverrides.containsKey(OP_APP_AUTH) -> state.resultFor(OP_APP_AUTH)
+ expected == null || offered == null -> AUTH_FAILED
+ offered != expected -> {
+ aapsLogger?.debug(
+ LTag.PUMPEMULATOR,
+ "emulator: auth rejected, expected 0x${hex(expected)} got 0x${hex(offered)}"
+ )
+ AUTH_FAILED
+ }
+
+ else -> CarelevoPumpState.RESULT_SUCCESS
+ }
+ return listOf(byteArrayOf(RESP_APP_AUTH, result.toByte()))
+ }
+
+ // ---- Time and patch info ----------------------------------------------------------------
+
+ /**
+ * 0x11 carries a byte-identical frame for two different commands; only `subId` (byte 1) tells
+ * them apart. `subId == 0` is the activation path, which waits for the patch-info pair and would
+ * hang on a 0x71; every other subId is a plain set-time that wants the 0x71 ack.
+ */
+ private fun setTime(request: ByteArray): List {
+ val subId = if (request.size > 1) request.u(1) else 0
+ val result = state.resultFor(OP_SET_TIME)
+ if (request.size >= SET_TIME_LENGTH && result == CarelevoPumpState.RESULT_SUCCESS) {
+ state.subId = subId
+ state.runningMinutes = 0
+ }
+ return if (subId == 0) patchInfoFrames()
+ else listOf(byteArrayOf(RESP_SET_TIME, result.toByte()))
+ }
+
+ /**
+ * RPT1 `[0x93][result][serial 13 ASCII]` and RPT2 `[0x94][result][fw 4 ASCII][unused 5][model 5]`.
+ * Order is irrelevant — the client keys them by opcode — but both must arrive or the request hangs.
+ */
+ private fun patchInfoFrames(): List {
+ val result = state.resultFor(OP_PATCH_INFO).toByte()
+ val rpt1 = byteArrayOf(RESP_PATCH_INFO_RPT1, result) + state.serialNumber.ascii(SERIAL_LENGTH)
+ val rpt2 = byteArrayOf(RESP_PATCH_INFO_RPT2, result) +
+ state.firmwareVersion.ascii(FIRMWARE_LENGTH) +
+ ByteArray(RPT2_UNUSED_LENGTH) +
+ state.modelBytes.copyOf(MODEL_LENGTH)
+ return listOf(rpt1, rpt2)
+ }
+
+ // ---- Safety check -----------------------------------------------------------------------
+
+ /**
+ * Streams progress frames then a terminal one, all on 0x72. A frame is progress purely because
+ * its result byte is 4 or 18; anything else — including 0 for success — ends the stream.
+ *
+ * Frames are 6 bytes. Never emit 5: `SafetyCheckCommand.decode` reads index 5 whenever
+ * `size > 4`, so a 5-byte frame throws out of bounds instead of failing cleanly.
+ */
+ private fun safetyCheck(): List {
+ val frames = mutableListOf()
+ repeat(state.safetyCheckProgressFrames) { frames += safetyFrame(SAFETY_PROGRESS_RESULT) }
+ frames += safetyFrame(state.safetyCheckResult)
+ if (state.safetyCheckResult == CarelevoPumpState.RESULT_SUCCESS) state.pumpStateRaw = PUMP_STATE_RUNNING
+ return frames
+ }
+
+ private fun safetyFrame(result: Int): ByteArray = byteArrayOf(
+ RESP_SAFETY_CHECK,
+ result.toByte(),
+ (state.safetyCheckVolume / HUNDRED).toByte(),
+ (state.safetyCheckVolume % HUNDRED).toByte(),
+ (state.safetyCheckDurationSeconds / SECONDS_PER_MINUTE).toByte(),
+ (state.safetyCheckDurationSeconds % SECONDS_PER_MINUTE).toByte()
+ )
+
+ // ---- Basal ------------------------------------------------------------------------------
+
+ /**
+ * Each sequence is its own round-trip: the ack carries no seqNo, so the pump simply banks the
+ * segments and answers `[resp][result]`. The program only counts as set once seq 2 lands.
+ */
+ private fun basalProgram(request: ByteArray, responseOpcode: Byte): List {
+ val seqNo = if (request.size > 1) request.u(1) else 0
+ val speeds = (BASAL_SEGMENT_START until request.size step 2)
+ .filter { it + 1 < request.size }
+ .map { request.u(it) + request.u(it + 1) / CENTI }
+ val requestOpcode = request[0]
+ val result = state.resultFor(requestOpcode)
+ if (result == CarelevoPumpState.RESULT_SUCCESS) {
+ state.basalSegmentsBySeq[seqNo] = speeds
+ if (seqNo == BASAL_FINAL_SEQ) state.basalProgramCommitted = true
+ }
+ return listOf(byteArrayOf(responseOpcode, result.toByte()))
+ }
+
+ // ---- Settings ---------------------------------------------------------------------------
+
+ /** `[0x75][type]` — byte 1 echoes the type; there is no result code, arrival means success. */
+ private fun noticeThreshold(request: ByteArray): List {
+ val type = if (request.size > 1) request.u(1) else 0
+ val value = if (request.size > 2) request.u(2) else 0
+ when (type) {
+ NOTICE_TYPE_LOW_INSULIN -> state.lowInsulinNotice = value
+ NOTICE_TYPE_EXPIRY -> state.expiryNotice = value
+ }
+ return listOf(byteArrayOf(RESP_NOTICE_THRESHOLD, type.toByte()))
+ }
+
+ /** `[0x77][type][result]` — result sits at index 2 here, because byte 1 echoes the type. */
+ private fun infusionThreshold(request: ByteArray): List {
+ val flag = if (request.size > 1) request[1] else 0
+ val result = state.resultFor(OP_INFUSION_THRESHOLD)
+ if (request.size >= 4 && result == CarelevoPumpState.RESULT_SUCCESS) {
+ val value = request.u(2) + request.u(3) / CENTI
+ if (flag == FLAG_MAX_VOLUME) state.maxBolusDose = value else state.maxBasalSpeed = value
+ }
+ return listOf(byteArrayOf(RESP_INFUSION_THRESHOLD, flag, result.toByte()))
+ }
+
+ private fun buzzMode(request: ByteArray): List {
+ val result = state.resultFor(OP_BUZZ_MODE)
+ if (request.size > 1 && result == CarelevoPumpState.RESULT_SUCCESS) state.buzzUse = request[1] == FLAG_ON
+ return listOf(byteArrayOf(RESP_BUZZ_MODE, result.toByte()))
+ }
+
+ private fun thresholdSetup(request: ByteArray): List {
+ val result = state.resultFor(OP_THRESHOLD_SETUP)
+ if (request.size >= THRESHOLD_SETUP_LENGTH && result == CarelevoPumpState.RESULT_SUCCESS) {
+ state.insulinRemainsThreshold = request.u(1)
+ state.expiryThreshold = request.u(2)
+ state.maxBasalSpeed = request.u(3) + request.u(4) / CENTI
+ state.maxBolusDose = request.u(5) + request.u(6) / CENTI
+ state.buzzUse = request[7] == FLAG_ON
+ }
+ return listOf(byteArrayOf(RESP_THRESHOLD_SETUP, result.toByte()))
+ }
+
+ private fun alertAlarmSet(request: ByteArray): List {
+ val result = state.resultFor(OP_ALERT_ALARM_SET)
+ if (request.size > 1 && result == CarelevoPumpState.RESULT_SUCCESS) state.alertAlarmMode = request.u(1)
+ return listOf(byteArrayOf(RESP_ALERT_ALARM_SET, result.toByte()))
+ }
+
+ /** `[0xA7][subId][cause][result]` — result at index 3. */
+ private fun alarmClear(request: ByteArray): List {
+ val alarmType = if (request.size > 1) request[1] else 0
+ val cause = if (request.size > 2) request[2] else 0
+ return listOf(byteArrayOf(RESP_ALARM_CLEAR, alarmType, cause, state.resultFor(OP_ALARM_CLEAR).toByte()))
+ }
+
+ // ---- Needle and priming -----------------------------------------------------------------
+
+ /** 0x19 answers on 0x7A — swapped with [needleStatus]. Request byte 1: 0x00 success, 0x01 failure. */
+ private fun needleAck(request: ByteArray): List {
+ val result = state.resultFor(OP_NEEDLE_ACK)
+ if (request.size > 1 && request[1] == NEEDLE_FLAG_SUCCESS && result == CarelevoPumpState.RESULT_SUCCESS) {
+ state.needleInserted = true
+ }
+ return listOf(byteArrayOf(RESP_NEEDLE_ACK, result.toByte()))
+ }
+
+ /** 0x1A answers on 0x79 — swapped with [needleAck]. */
+ private fun needleStatus(): List =
+ listOf(byteArrayOf(RESP_NEEDLE_STATUS, state.resultFor(OP_NEEDLE_STATUS).toByte()))
+
+ private fun additionalPriming(): List {
+ val result = state.resultFor(OP_ADDITIONAL_PRIMING)
+ if (result == CarelevoPumpState.RESULT_SUCCESS) state.primingCount++
+ return listOf(byteArrayOf(RESP_ADDITIONAL_PRIMING, result.toByte()))
+ }
+
+ // ---- Delivery ---------------------------------------------------------------------------
+
+ /**
+ * The ack is result-only, so the emulator does not need to distinguish the two modes — worth
+ * knowing that they are told apart by frame length alone (6 bytes BY_UNIT with a trailing 0x00,
+ * 5 bytes BY_PERCENT), since the opcode and layout are otherwise identical.
+ */
+ private fun tempBasal(): List {
+ val result = state.resultFor(OP_TEMP_BASAL)
+ if (result == CarelevoPumpState.RESULT_SUCCESS) {
+ state.tempBasalRunning = true
+ state.modeRaw = MODE_TEMP_BASAL
+ }
+ return listOf(byteArrayOf(RESP_TEMP_BASAL, result.toByte()))
+ }
+
+ private fun tempBasalCancel(): List {
+ val result = state.resultFor(OP_TEMP_BASAL_CANCEL)
+ if (result == CarelevoPumpState.RESULT_SUCCESS) {
+ state.tempBasalRunning = false
+ state.modeRaw = MODE_BASAL
+ }
+ return listOf(byteArrayOf(RESP_TEMP_BASAL_CANCEL, result.toByte()))
+ }
+
+ /**
+ * `[0x84][actionId][result][mm][ss][remain 3]`. Byte 1 **must** echo the request's actionId —
+ * the client correlates on it and silently discards a frame that does not match, hanging the call.
+ */
+ private fun immediateBolus(request: ByteArray): List {
+ val actionId = if (request.size > 1) request[1] else 0
+ val volume = if (request.size > 3) request.u(2) + request.u(3) / CENTI else 0.0
+ val result = state.resultFor(OP_IMMEDIATE_BOLUS)
+ if (result == CarelevoPumpState.RESULT_SUCCESS) {
+ state.activeBolusActionId = actionId.toUByte().toInt()
+ state.modeRaw = MODE_IMME_BOLUS
+ state.insulinRemaining = (state.insulinRemaining - volume).coerceAtLeast(0.0)
+ state.infusedTotalBolus += volume
+ // The emulated bolus lands immediately rather than ramping, so the whole dose counts as
+ // infused — a later cancel must report that, not 0 U for insulin the totals already hold.
+ state.bolusInfusedAmount = volume
+ }
+ val seconds = (volume * SECONDS_PER_UNIT).roundToInt()
+ return listOf(
+ byteArrayOf(
+ RESP_IMMEDIATE_BOLUS,
+ actionId,
+ result.toByte(),
+ (seconds / SECONDS_PER_MINUTE).toByte(),
+ (seconds % SECONDS_PER_MINUTE).toByte()
+ ) + encodeHundredsUnitsCenti(state.insulinRemaining)
+ )
+ }
+
+ /** `[0x8C][result][units][centi]` — the infused amount is 2 bytes here, with no hundreds byte. */
+ private fun bolusCancel(): List {
+ val result = state.resultFor(OP_BOLUS_CANCEL)
+ val infused = state.bolusInfusedAmount
+ if (result == CarelevoPumpState.RESULT_SUCCESS) {
+ state.activeBolusActionId = null
+ state.modeRaw = MODE_BASAL
+ }
+ return listOf(byteArrayOf(RESP_BOLUS_CANCEL, result.toByte()) + encodeUnitsCenti(infused))
+ }
+
+ /**
+ * `[0x85][result][mm][ss]`. The decoder recombines bytes 2..3 as `[2] * 60 + [3]` **seconds**, so
+ * the requested duration goes into byte 2 as whole minutes — halving it again by 60 would report
+ * a 90-minute bolus as 90 seconds.
+ *
+ * Byte 2 caps the reportable duration at 255 minutes; a longer request is clamped rather than
+ * silently wrapping, which is the protocol's limit and not something the emulator can widen.
+ */
+ private fun extendBolus(request: ByteArray): List {
+ val result = state.resultFor(OP_EXTEND_BOLUS)
+ val hour = if (request.size > 5) request.u(5) else 0
+ val min = if (request.size > 6) request.u(6) else 0
+ if (result == CarelevoPumpState.RESULT_SUCCESS) {
+ state.extendedBolusRunning = true
+ state.extendedInfusedAmount = 0.0
+ state.modeRaw = MODE_EXTEND_BOLUS
+ }
+ val totalMinutes = (hour * MINUTES_PER_HOUR + min).coerceAtMost(MAX_REPORTABLE_MINUTES)
+ return listOf(byteArrayOf(RESP_EXTEND_BOLUS, result.toByte(), totalMinutes.toByte(), 0))
+ }
+
+ private fun extendBolusCancel(): List {
+ val result = state.resultFor(OP_EXTEND_BOLUS_CANCEL)
+ val infused = state.extendedInfusedAmount
+ if (result == CarelevoPumpState.RESULT_SUCCESS) {
+ state.extendedBolusRunning = false
+ state.modeRaw = MODE_BASAL
+ }
+ return listOf(byteArrayOf(RESP_EXTEND_BOLUS_CANCEL, result.toByte()) + encodeUnitsCenti(infused))
+ }
+
+ // ---- Stop / resume / discard ------------------------------------------------------------
+
+ private fun pumpStop(request: ByteArray): List {
+ val result = state.resultFor(OP_PUMP_STOP)
+ if (result == CarelevoPumpState.RESULT_SUCCESS) {
+ state.stopped = true
+ if (request.size > 3) state.subId = request.u(3)
+ }
+ return listOf(byteArrayOf(RESP_PUMP_STOP, result.toByte()))
+ }
+
+ /** `[0x87][result][mode]` — the optional 4th subId byte is left off; the decoder defaults it to 0. */
+ private fun pumpResume(request: ByteArray): List {
+ val mode = if (request.size > 1) request[1] else 0
+ val result = state.resultFor(OP_PUMP_RESUME)
+ if (result == CarelevoPumpState.RESULT_SUCCESS) {
+ state.stopped = false
+ state.modeRaw = MODE_BASAL
+ }
+ return listOf(byteArrayOf(RESP_PUMP_RESUME, result.toByte(), mode))
+ }
+
+ private fun patchDiscard(): List {
+ val result = state.resultFor(OP_PATCH_DISCARD)
+ if (result == CarelevoPumpState.RESULT_SUCCESS) {
+ state.discarded = true
+ state.pumpStateRaw = PUMP_STATE_READY
+ }
+ return listOf(byteArrayOf(RESP_PATCH_DISCARD, result.toByte()))
+ }
+
+ // ---- Status -----------------------------------------------------------------------------
+
+ /**
+ * The 20-byte status frame. Bytes 13..14 are documented but never decoded — they still have to be
+ * present, because the decoder reads index 19 and requires the full length.
+ */
+ private fun infusionInfo(request: ByteArray): List {
+ val inquiryType = if (request.size > 1) request.u(1) else 0
+ val frame = ByteArray(INFUSION_INFO_LENGTH)
+ frame[0] = RESP_INFUSION_INFO
+ frame[1] = inquiryType.toByte()
+ frame[2] = (state.runningMinutes / MINUTES_PER_HOUR).toByte()
+ frame[3] = (state.runningMinutes % MINUTES_PER_HOUR).toByte()
+ encodeHundredsUnitsCenti(state.insulinRemaining).copyInto(frame, 4)
+ encodeUnitsCenti(state.infusedTotalBasal).copyInto(frame, 7)
+ encodeUnitsCenti(state.infusedTotalBolus).copyInto(frame, 9)
+ frame[11] = state.pumpStateRaw.toByte()
+ frame[12] = state.modeRaw.toByte()
+ // 13..14 (infuseSetMinutes) intentionally left zero — present but never read.
+ encodeUnitsCenti(state.infusedTotalBolus).copyInto(frame, 15)
+ frame[17] = (state.runningMinutes / MINUTES_PER_HOUR).toByte()
+ frame[18] = (state.runningMinutes % MINUTES_PER_HOUR).toByte()
+ frame[19] = 0
+ return listOf(frame)
+ }
+
+ // ---- Encoding helpers -------------------------------------------------------------------
+
+ /** `[hundreds, units, centi]` — the 3-byte reservoir encoding of 0x91[4..6] and 0x84[5..7]. */
+ private fun encodeHundredsUnitsCenti(value: Double): ByteArray {
+ val centiTotal = (value * HUNDRED).roundToInt().coerceAtLeast(0)
+ return byteArrayOf(
+ (centiTotal / TEN_THOUSAND).toByte(),
+ ((centiTotal / HUNDRED) % HUNDRED).toByte(),
+ (centiTotal % HUNDRED).toByte()
+ )
+ }
+
+ /** `[units, centi]` — the 2-byte amount encoding of the 0x91 totals and the 0x89/0x8C acks. */
+ private fun encodeUnitsCenti(value: Double): ByteArray {
+ val centiTotal = (value * HUNDRED).roundToInt().coerceAtLeast(0)
+ return byteArrayOf((centiTotal / HUNDRED).toByte(), (centiTotal % HUNDRED).toByte())
+ }
+
+ /** Fixed-width ASCII, space-padded or truncated — the frame layout is positional. */
+ private fun String.ascii(length: Int): ByteArray =
+ padEnd(length).take(length).toByteArray(Charsets.US_ASCII)
+
+ private fun ByteArray.u(index: Int): Int = this[index].toUByte().toInt()
+
+ private fun hex(value: Byte): String = "%02X".format(value)
+
+ @Suppress("unused")
+ companion object {
+
+ // Requests
+ const val OP_SET_TIME: Byte = 0x11
+ const val OP_SAFETY_CHECK: Byte = 0x12
+ const val OP_BASAL_SET: Byte = 0x13
+ const val OP_NOTICE_THRESHOLD: Byte = 0x15
+ const val OP_INFUSION_THRESHOLD: Byte = 0x17
+ const val OP_BUZZ_MODE: Byte = 0x18
+ const val OP_NEEDLE_ACK: Byte = 0x19
+ const val OP_NEEDLE_STATUS: Byte = 0x1A
+ const val OP_THRESHOLD_SETUP: Byte = 0x1B
+ const val OP_ADDITIONAL_PRIMING: Byte = 0x1D
+ const val OP_BASAL_UPDATE: Byte = 0x21
+ const val OP_TEMP_BASAL: Byte = 0x23
+ const val OP_IMMEDIATE_BOLUS: Byte = 0x24
+ const val OP_EXTEND_BOLUS: Byte = 0x25
+ const val OP_PUMP_STOP: Byte = 0x26
+ const val OP_PUMP_RESUME: Byte = 0x27
+ const val OP_EXTEND_BOLUS_CANCEL: Byte = 0x29
+ const val OP_BOLUS_CANCEL: Byte = 0x2C
+ const val OP_TEMP_BASAL_CANCEL: Byte = 0x2D
+ const val OP_INFUSION_INFO: Byte = 0x31
+ const val OP_PATCH_INFO: Byte = 0x33
+ const val OP_PATCH_DISCARD: Byte = 0x36
+ const val OP_MAC_ADDRESS: Byte = 0x3B
+ const val OP_ALARM_CLEAR: Byte = 0x47
+ const val OP_ALERT_ALARM_SET: Byte = 0x48
+ const val OP_APP_AUTH: Byte = 0x4B
+
+ // Responses — hardcoded, never computed from the request.
+ const val RESP_SET_TIME: Byte = 0x71
+ const val RESP_SAFETY_CHECK: Byte = 0x72
+ const val RESP_BASAL_SET: Byte = 0x73
+ const val RESP_NOTICE_THRESHOLD: Byte = 0x75
+ const val RESP_INFUSION_THRESHOLD: Byte = 0x77
+ const val RESP_BUZZ_MODE: Byte = 0x78
+ const val RESP_NEEDLE_STATUS: Byte = 0x79 // 0x1A -> 0x79, swapped with the ack below
+ const val RESP_NEEDLE_ACK: Byte = 0x7A // 0x19 -> 0x7A
+ const val RESP_THRESHOLD_SETUP: Byte = 0x7B
+ const val RESP_ADDITIONAL_PRIMING: Byte = 0x7D
+ val RESP_BASAL_UPDATE: Byte = 0x81.toByte()
+ val RESP_TEMP_BASAL: Byte = 0x83.toByte()
+ val RESP_IMMEDIATE_BOLUS: Byte = 0x84.toByte()
+ val RESP_EXTEND_BOLUS: Byte = 0x85.toByte()
+ val RESP_PUMP_STOP: Byte = 0x86.toByte()
+ val RESP_PUMP_RESUME: Byte = 0x87.toByte()
+ val RESP_EXTEND_BOLUS_CANCEL: Byte = 0x89.toByte()
+ val RESP_BOLUS_CANCEL: Byte = 0x8C.toByte()
+ val RESP_TEMP_BASAL_CANCEL: Byte = 0x8D.toByte()
+ val RESP_INFUSION_INFO: Byte = 0x91.toByte()
+ val RESP_PATCH_INFO_RPT1: Byte = 0x93.toByte()
+ val RESP_PATCH_INFO_RPT2: Byte = 0x94.toByte()
+ val RESP_PATCH_DISCARD: Byte = 0x96.toByte()
+ val RESP_MAC_ADDRESS: Byte = 0x9B.toByte()
+ val RESP_ALARM_CLEAR: Byte = 0xA7.toByte()
+ val RESP_ALERT_ALARM_SET: Byte = 0xA8.toByte()
+ val RESP_APP_AUTH: Byte = 0xBB.toByte() // 0x4B -> 0xBB, not 0xAB and not 0xBA
+
+ /** Result byte of a 0x72 frame that means "still working" — see `SafetyCheckCommand`. */
+ const val SAFETY_PROGRESS_RESULT = 4
+ const val AUTH_FAILED = 1
+
+ const val PUMP_STATE_READY = 0
+ const val PUMP_STATE_RUNNING = 2
+
+ const val MODE_BASAL = 1
+ const val MODE_TEMP_BASAL = 2
+ const val MODE_IMME_BOLUS = 3
+ const val MODE_EXTEND_BOLUS = 5
+
+ const val FLAG_ON: Byte = 0x01
+ const val FLAG_MAX_VOLUME: Byte = 0x01
+ const val NEEDLE_FLAG_SUCCESS: Byte = 0x00
+ const val NOTICE_TYPE_LOW_INSULIN = 0
+ const val NOTICE_TYPE_EXPIRY = 1
+
+ const val SET_TIME_LENGTH = 11
+ const val THRESHOLD_SETUP_LENGTH = 8
+ const val INFUSION_INFO_LENGTH = 20
+ const val SERIAL_LENGTH = 13
+ const val FIRMWARE_LENGTH = 4
+ const val MODEL_LENGTH = 5
+ const val RPT2_UNUSED_LENGTH = 5
+ const val BASAL_SEGMENT_START = 2
+ const val BASAL_FINAL_SEQ = 2
+
+ const val HUNDRED = 100
+ const val TEN_THOUSAND = 10_000
+ const val CENTI = 100.0
+ const val SECONDS_PER_MINUTE = 60
+ const val MINUTES_PER_HOUR = 60
+
+ /** Rough delivery rate used to fabricate the 0x84 expected-duration bytes. */
+ const val SECONDS_PER_UNIT = 20
+
+ /** Byte 2 of the 0x85 ack carries whole minutes, so the wire cannot report beyond this. */
+ const val MAX_REPORTABLE_MINUTES = 255
+ }
+}
diff --git a/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpState.kt b/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpState.kt
new file mode 100644
index 000000000000..6fb84af37232
--- /dev/null
+++ b/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpState.kt
@@ -0,0 +1,123 @@
+package app.aaps.pump.carelevo.emulator
+
+/**
+ * Mutable state of the emulated CareLevo patch.
+ *
+ * The emulator answers status queries from here rather than from canned frames, so the driver sees a
+ * patch that remembers what it was told: a basal program written over three sequences, a bolus that
+ * draws the reservoir down, a stop that reports itself as stopped.
+ *
+ * Raw codes mirror `CarelevoBtEnums` and are deliberately raw (not enums) because they go on the wire:
+ * - [pumpStateRaw]: 0 READY, 1 PRIMING, 2 RUNNING — anything else decodes to ERROR
+ * - [modeRaw]: 1 BASAL, 2 TEMP_BASAL, 3 IMME_BOLUS, 4 EXTEND_IMME_BOLUS, 5 EXTEND_BOLUS.
+ * **0 decodes to ERROR**, so it is never a resting value.
+ */
+class CarelevoPumpState {
+
+ // ---- Identity ---------------------------------------------------------------------------
+
+ /** Exactly 13 chars — occupies bytes 2..14 of the 0x93 report, one ASCII byte per char. */
+ var serialNumber: String = "CL24000000001"
+
+ /** Exactly 4 chars — bytes 2..5 of the 0x94 report, ASCII. */
+ var firmwareVersion: String = "1.10"
+
+ /**
+ * Five raw bytes at 0x94[11..15]. The decoder concatenates each byte's DECIMAL value, so
+ * `[1, 2, 0, 0, 0]` reads back as the model name "12000" — it is not ASCII.
+ */
+ var modelBytes: ByteArray = byteArrayOf(1, 2, 0, 0, 0)
+
+ /** Six raw bytes, MSB first; surfaces to the driver as the uppercase hex "94B2161D2F6D". */
+ var macAddress: ByteArray = byteArrayOf(0x94.toByte(), 0xB2.toByte(), 0x16, 0x1D, 0x2F, 0x6D)
+
+ /** Trailing checksum bytes of the 0x9B frame. Content is arbitrary — see [lastMacKey]. */
+ var macCheckSum: ByteArray = byteArrayOf(0x5A)
+
+ /** Key from the last 0x3B request, retained so 0x4B can verify the app's XOR fold. */
+ var lastMacKey: Int? = null
+
+ // ---- Reservoir and totals ---------------------------------------------------------------
+
+ /** Units remaining. Reported by 0x91 and by the 0x84 bolus ack. */
+ var insulinRemaining: Double = 200.0
+ var infusedTotalBasal: Double = 0.0
+ var infusedTotalBolus: Double = 0.0
+
+ // ---- Lifecycle --------------------------------------------------------------------------
+
+ var pumpStateRaw: Int = 2
+ var modeRaw: Int = 1
+ var subId: Int = 0
+ var runningMinutes: Int = 0
+ var stopped: Boolean = false
+ var discarded: Boolean = false
+ var needleInserted: Boolean = false
+ var primingCount: Int = 0
+
+ // ---- Basal ------------------------------------------------------------------------------
+
+ /**
+ * Segment speeds accumulated per sequence number. A full program is three independent writes
+ * (seq 0, 1, 2), each separately acked; [basalProgramCommitted] flips only once seq 2 lands.
+ */
+ val basalSegmentsBySeq: MutableMap> = mutableMapOf()
+ var basalProgramCommitted: Boolean = false
+
+ /** Set of the whole program, in segment order, once all three sequences have arrived. */
+ val basalProgram: List
+ get() = (0..2).flatMap { basalSegmentsBySeq[it].orEmpty() }
+
+ // ---- Temp basal / bolus -----------------------------------------------------------------
+
+ var tempBasalRunning: Boolean = false
+ var extendedBolusRunning: Boolean = false
+
+ /** actionId of the in-flight immediate bolus; echoed back on 0x84 for correlation. */
+ var activeBolusActionId: Int? = null
+
+ /** Units already delivered by the in-flight bolus — reported by the 0x8C cancel ack. */
+ var bolusInfusedAmount: Double = 0.0
+ var extendedInfusedAmount: Double = 0.0
+
+ // ---- Thresholds and settings ------------------------------------------------------------
+
+ var insulinRemainsThreshold: Int = 20
+ var expiryThreshold: Int = 72
+ var maxBasalSpeed: Double = 15.0
+ var maxBolusDose: Double = 25.0
+ var buzzUse: Boolean = false
+ var lowInsulinNotice: Int = 20
+ var expiryNotice: Int = 72
+ var alertAlarmMode: Int = 0
+
+ // ---- Safety check -----------------------------------------------------------------------
+
+ /** Progress frames the 0x12 stream emits before its terminal frame. */
+ var safetyCheckProgressFrames: Int = 2
+
+ /** Priming volume reported by the safety-check stream, in whole units. */
+ var safetyCheckVolume: Int = 210
+
+ /** Priming duration reported by the safety-check stream, in seconds. */
+ var safetyCheckDurationSeconds: Int = 210
+
+ /** Terminal result of the safety-check stream. 0 = success; see `SafetyCheckCommand`. */
+ var safetyCheckResult: Int = 0
+
+ // ---- Fault injection --------------------------------------------------------------------
+
+ /** Opcodes the patch silently ignores, so a caller's timeout is the only way out. */
+ val silentOpcodes: MutableSet = mutableSetOf()
+
+ /** Per-opcode result-code override, for driving the driver's error paths. */
+ val resultCodeOverrides: MutableMap = mutableMapOf()
+
+ /** Result code this opcode should answer with — 0 (success) unless overridden. */
+ fun resultFor(requestOpcode: Byte): Int = resultCodeOverrides[requestOpcode] ?: RESULT_SUCCESS
+
+ companion object {
+
+ const val RESULT_SUCCESS = 0
+ }
+}
diff --git a/pump/carelevo-emulator/src/test/kotlin/app/aaps/pump/carelevo/emulator/CarelevoEmulatorTransportTest.kt b/pump/carelevo-emulator/src/test/kotlin/app/aaps/pump/carelevo/emulator/CarelevoEmulatorTransportTest.kt
new file mode 100644
index 000000000000..ff54c8b90e22
--- /dev/null
+++ b/pump/carelevo-emulator/src/test/kotlin/app/aaps/pump/carelevo/emulator/CarelevoEmulatorTransportTest.kt
@@ -0,0 +1,370 @@
+package app.aaps.pump.carelevo.emulator
+
+import app.aaps.core.interfaces.pump.ble.BleTransportListener
+import app.aaps.core.interfaces.pump.ble.PairingState
+import app.aaps.core.interfaces.pump.ble.PairingStep
+import app.aaps.pump.carelevo.ble.commands.InfusionInfoCommand
+import app.aaps.pump.carelevo.ble.commands.PatchDiscardCommand
+import app.aaps.pump.carelevo.ble.commands.PatchInfoCommand
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+/**
+ * Transport tests for [CarelevoEmulatorBleTransport] — the seam between the driver's `BleTransport`
+ * contract and [CarelevoPumpEmulator].
+ *
+ * Scope is deliberately the *transport*, not the protocol: [CarelevoPumpEmulatorTest] already proves
+ * every opcode round-trips through the production codec, so nothing here re-asserts frame contents
+ * beyond the response opcode needed to tell one notification from the next. What this suite pins down
+ * is the wiring the emulator adds on top:
+ * - which listener callback each `gatt` call fans out to, and **in what order** — `onCharacteristicWritten`
+ * must precede the `onCharacteristicChanged` frames, because [app.aaps.core.interfaces.pump.ble.BleGatt]
+ * answers writes synchronously and a client that saw the reply before the ack would be reading a
+ * reply to a write it does not yet believe it made;
+ * - the fault-injection switches (`connectRefused`, `reportConnected`, `silentOpcodes`) that exist
+ * purely to drive the driver's failure paths;
+ * - the register → close → register cycle the driver performs once per operation.
+ *
+ * Pure JVM: the transport takes a nullable logger and touches no Android API, so no Robolectric.
+ */
+class CarelevoEmulatorTransportTest {
+
+ private lateinit var state: CarelevoPumpState
+ private lateinit var emulator: CarelevoPumpEmulator
+ private lateinit var transport: CarelevoEmulatorBleTransport
+ private lateinit var listener: RecordingListener
+
+ @BeforeEach
+ fun setUp() {
+ state = CarelevoPumpState()
+ emulator = CarelevoPumpEmulator(state)
+ transport = CarelevoEmulatorBleTransport(emulator)
+ listener = RecordingListener()
+ transport.setListener(listener)
+ }
+
+ // ---- Listener fan-out --------------------------------------------------------------------
+
+ @Test
+ fun `the connect handshake fans out to each listener callback in order`() {
+ transport.gatt.connect(ADDRESS)
+ transport.gatt.discoverServices()
+ val found = transport.gatt.findCharacteristics()
+ transport.gatt.enableNotifications()
+
+ // The driver's session drives exactly this sequence; each step must land on its own callback.
+ assertThat(listener.events).containsExactly(CONNECTED, SERVICES_OK, DESCRIPTOR).inOrder()
+ // findCharacteristics is synchronous — it answers by return value, not by a callback.
+ assertThat(found).isTrue()
+ }
+
+ @Test
+ fun `a write acks before it delivers the notification`() {
+ transport.gatt.connect(ADDRESS)
+ listener.clear()
+
+ transport.gatt.writeCharacteristic(PatchDiscardCommand().encode())
+
+ // Order is the point: the ack must come first, or a client would see a reply to a write it
+ // does not yet believe completed.
+ assertThat(listener.events).containsExactly(WRITTEN, CHANGED).inOrder()
+ assertThat(listener.notifications.single()[0]).isEqualTo(PatchDiscardCommand.RESPONSE_OPCODE)
+ }
+
+ @Test
+ fun `a multi-frame command delivers one notification per emulator frame in order`() {
+ transport.gatt.connect(ADDRESS)
+ listener.clear()
+
+ transport.gatt.writeCharacteristic(PatchInfoCommand().encode())
+
+ // 0x33 is answered by two report frames; both must arrive as separate notifications, in order,
+ // or the client's multi-frame correlation never completes.
+ assertThat(listener.events).containsExactly(WRITTEN, CHANGED, CHANGED).inOrder()
+ assertThat(listener.notifications.map { it[0] })
+ .containsExactly(PatchInfoCommand.RPT1_OPCODE, PatchInfoCommand.RPT2_OPCODE)
+ .inOrder()
+ }
+
+ @Test
+ fun `a silent opcode still acks the write but delivers no notification`() {
+ state.silentOpcodes += InfusionInfoCommand.REQUEST_OPCODE
+ transport.gatt.connect(ADDRESS)
+ listener.clear()
+
+ transport.gatt.writeCharacteristic(InfusionInfoCommand().encode())
+
+ // The write itself still completes at the GATT layer — only the patch goes quiet, which is
+ // what leaves the caller waiting on its timeout.
+ assertThat(listener.events).containsExactly(WRITTEN)
+ assertThat(listener.notifications).isEmpty()
+ // ...and the frame is still recorded, so a test can assert what was sent into the silence.
+ assertThat(transport.writes).hasSize(1)
+ }
+
+ @Test
+ fun `writes records every frame in order`() {
+ val patchInfo = PatchInfoCommand().encode()
+ val infusionInfo = InfusionInfoCommand().encode()
+ val discard = PatchDiscardCommand().encode()
+
+ transport.gatt.connect(ADDRESS)
+ transport.gatt.writeCharacteristic(patchInfo)
+ transport.gatt.writeCharacteristic(infusionInfo)
+ transport.gatt.writeCharacteristic(discard)
+
+ assertThat(transport.writes.map { it.toList() })
+ .containsExactly(patchInfo.toList(), infusionInfo.toList(), discard.toList())
+ .inOrder()
+ }
+
+ // ---- Connect / disconnect ----------------------------------------------------------------
+
+ @Test
+ fun `connect refused returns false and fires no connection callback`() {
+ transport.connectRefused = true
+
+ assertThat(transport.gatt.connect(ADDRESS)).isFalse()
+
+ // The BLE stack refused to even start — the driver must not be told it connected.
+ assertThat(listener.events).isEmpty()
+ }
+
+ @Test
+ fun `connect without a connected report returns true but fires no callback`() {
+ transport.reportConnected = false
+
+ // The stack accepted the request; the patch simply never answers — this is the connect-timeout
+ // path, and it is the absence of the callback that drives it.
+ assertThat(transport.gatt.connect(ADDRESS)).isTrue()
+ assertThat(listener.events).isEmpty()
+ }
+
+ @Test
+ fun `disconnect before connect fires nothing`() {
+ transport.gatt.disconnect()
+
+ assertThat(listener.events).isEmpty()
+ }
+
+ @Test
+ fun `disconnect fires a single disconnected callback and is idempotent`() {
+ transport.gatt.connect(ADDRESS)
+ listener.clear()
+
+ transport.gatt.disconnect()
+ transport.gatt.disconnect()
+
+ // A second disconnect must stay silent — a duplicate lost-connection event would tear the
+ // session down twice.
+ assertThat(listener.connectionStates).containsExactly(false)
+ }
+
+ // ---- Bonding -----------------------------------------------------------------------------
+
+ @Test
+ fun `adapter reports the emulated bond state`() {
+ assertThat(transport.adapter.isDeviceBonded(ADDRESS)).isTrue()
+
+ transport.bonded = false
+
+ assertThat(transport.adapter.isDeviceBonded(ADDRESS)).isFalse()
+ }
+
+ @Test
+ fun `createBond counts the call and bonds the patch`() {
+ transport.bonded = false
+
+ assertThat(transport.adapter.createBond(ADDRESS)).isTrue()
+
+ assertThat(transport.bonded).isTrue()
+ assertThat(transport.adapter.isDeviceBonded(ADDRESS)).isTrue()
+ assertThat(transport.bondCalls).isEqualTo(1)
+
+ transport.adapter.createBond(ADDRESS)
+
+ assertThat(transport.bondCalls).isEqualTo(2)
+ }
+
+ @Test
+ fun `removeBond unbonds the patch`() {
+ transport.bonded = true
+
+ transport.adapter.removeBond(ADDRESS)
+
+ assertThat(transport.bonded).isFalse()
+ assertThat(transport.adapter.isDeviceBonded(ADDRESS)).isFalse()
+ }
+
+ // ---- Scanner -----------------------------------------------------------------------------
+
+ @Test
+ fun `a discovery scan asks the serial provider and reports the patch mac`() {
+ val discovering = CarelevoEmulatorBleTransport(emulator, serialNumberProvider = { PROVIDED_SERIAL })
+
+ discovering.scanner.startScan()
+ val device = runBlocking { discovering.scanner.scannedDevices.first() }
+
+ // No address filter means the pairing wizard is hunting for a fresh patch — that is where the
+ // serial comes from, so the provider decides what the patch now calls itself.
+ assertThat(discovering.pumpState.serialNumber).isEqualTo(PROVIDED_SERIAL)
+ assertThat(device.address).isEqualTo(DEFAULT_MAC)
+ assertThat(device.name).isEqualTo("CareLevo-$PROVIDED_SERIAL")
+ }
+
+ @Test
+ fun `a filtered scan reports the scan address and leaves the serial alone`() {
+ var providerCalls = 0
+ val reconnecting = CarelevoEmulatorBleTransport(
+ emulator,
+ serialNumberProvider = {
+ providerCalls++
+ PROVIDED_SERIAL
+ }
+ )
+ reconnecting.scanAddress = FILTER_ADDRESS
+
+ reconnecting.scanner.startScan()
+ val device = runBlocking { reconnecting.scanner.scannedDevices.first() }
+
+ // A filtered scan is a reconnect to a patch already known — re-deriving its serial would
+ // overwrite the identity the driver is reconnecting to.
+ assertThat(providerCalls).isEqualTo(0)
+ assertThat(reconnecting.pumpState.serialNumber).isEqualTo(DEFAULT_SERIAL)
+ assertThat(device.address).isEqualTo(FILTER_ADDRESS)
+ }
+
+ // ---- Adapter identity --------------------------------------------------------------------
+
+ @Test
+ fun `getDeviceName follows the patch serial`() {
+ assertThat(transport.adapter.getDeviceName(ADDRESS)).isEqualTo("CareLevo-$DEFAULT_SERIAL")
+
+ state.serialNumber = PROVIDED_SERIAL
+
+ // The name is derived, not cached — a re-serialled patch renames itself.
+ assertThat(transport.adapter.getDeviceName(ADDRESS)).isEqualTo("CareLevo-$PROVIDED_SERIAL")
+ }
+
+ // ---- Listener lifecycle ------------------------------------------------------------------
+
+ @Test
+ fun `setListener null detaches and a later write delivers nothing without throwing`() {
+ transport.gatt.connect(ADDRESS)
+ listener.clear()
+
+ transport.setListener(null)
+ transport.gatt.writeCharacteristic(PatchInfoCommand().encode())
+
+ assertThat(listener.events).isEmpty()
+ // The frame still reached the emulator — only the delivery path is detached.
+ assertThat(transport.writes).hasSize(1)
+ }
+
+ @Test
+ fun `re-registering a listener routes callbacks to the new listener only`() {
+ val firstSession = RecordingListener()
+ val secondSession = RecordingListener()
+
+ transport.setListener(firstSession)
+ transport.gatt.connect(ADDRESS)
+ transport.gatt.close()
+ firstSession.clear()
+
+ transport.setListener(secondSession)
+ transport.gatt.connect(ADDRESS)
+ transport.gatt.writeCharacteristic(PatchInfoCommand().encode())
+
+ // The driver opens a session per operation, so this cycle is the norm, not an edge case: a
+ // frame leaking to the closed session's listener would resolve a waiter that is already gone.
+ assertThat(secondSession.connectionStates).containsExactly(true)
+ assertThat(secondSession.notifications.map { it[0] })
+ .containsExactly(PatchInfoCommand.RPT1_OPCODE, PatchInfoCommand.RPT2_OPCODE)
+ .inOrder()
+ assertThat(firstSession.events).isEmpty()
+ }
+
+ // ---- Pairing state -----------------------------------------------------------------------
+
+ @Test
+ fun `pairing state round-trips through updatePairingState`() {
+ assertThat(transport.pairingState.value).isEqualTo(PairingState())
+ assertThat(transport.pairingState.value.step).isEqualTo(PairingStep.IDLE)
+
+ val pairing = PairingState(step = PairingStep.WAITING_FOR_PAIRING_CONFIRM, errorMessage = "confirm on patch")
+ transport.updatePairingState(pairing)
+
+ assertThat(transport.pairingState.value).isEqualTo(pairing)
+ }
+
+ // ---- State delegation --------------------------------------------------------------------
+
+ @Test
+ fun `pumpState is the emulator state instance`() {
+ // Not a copy: a test mutates `transport.pumpState` and the emulator must answer from it.
+ assertThat(transport.pumpState).isSameInstanceAs(emulator.state)
+ assertThat(transport.pumpState).isSameInstanceAs(state)
+ }
+
+ // ---- Helpers -----------------------------------------------------------------------------
+
+ /** Records every callback as a flat, ordered event log — the assertion surface for fan-out order. */
+ private class RecordingListener : BleTransportListener {
+
+ val events: MutableList = mutableListOf()
+ val connectionStates: MutableList = mutableListOf()
+ val notifications: MutableList = mutableListOf()
+
+ override fun onConnectionStateChanged(connected: Boolean) {
+ connectionStates += connected
+ events += if (connected) CONNECTED else DISCONNECTED
+ }
+
+ override fun onServicesDiscovered(success: Boolean) {
+ events += if (success) SERVICES_OK else SERVICES_FAILED
+ }
+
+ override fun onDescriptorWritten() {
+ events += DESCRIPTOR
+ }
+
+ override fun onCharacteristicChanged(data: ByteArray) {
+ notifications += data.copyOf()
+ events += CHANGED
+ }
+
+ override fun onCharacteristicWritten() {
+ events += WRITTEN
+ }
+
+ fun clear() {
+ events.clear()
+ connectionStates.clear()
+ notifications.clear()
+ }
+ }
+
+ private companion object {
+
+ /** [CarelevoPumpState.macAddress] rendered the way the scanner reports it. */
+ const val DEFAULT_MAC = "94:B2:16:1D:2F:6D"
+
+ /** The address the driver hands to `gatt`/`adapter` calls — the emulator ignores it. */
+ const val ADDRESS = DEFAULT_MAC
+ const val FILTER_ADDRESS = "AA:BB:CC:DD:EE:FF"
+
+ const val DEFAULT_SERIAL = "CL24000000001"
+ const val PROVIDED_SERIAL = "CL24000000042"
+
+ const val CONNECTED = "connected"
+ const val DISCONNECTED = "disconnected"
+ const val SERVICES_OK = "services:true"
+ const val SERVICES_FAILED = "services:false"
+ const val DESCRIPTOR = "descriptor"
+ const val WRITTEN = "written"
+ const val CHANGED = "changed"
+ }
+}
diff --git a/pump/carelevo-emulator/src/test/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpEmulatorTest.kt b/pump/carelevo-emulator/src/test/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpEmulatorTest.kt
new file mode 100644
index 000000000000..71ad03d61b83
--- /dev/null
+++ b/pump/carelevo-emulator/src/test/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpEmulatorTest.kt
@@ -0,0 +1,829 @@
+package app.aaps.pump.carelevo.emulator
+
+import app.aaps.pump.carelevo.ble.commands.AdditionalPrimingCommand
+import app.aaps.pump.carelevo.ble.commands.AlarmClearCommand
+import app.aaps.pump.carelevo.ble.commands.AlertAlarmSetCommand
+import app.aaps.pump.carelevo.ble.commands.AppAuthCommand
+import app.aaps.pump.carelevo.ble.commands.BasalProgramCommand
+import app.aaps.pump.carelevo.ble.commands.BolusCancelCommand
+import app.aaps.pump.carelevo.ble.commands.BuzzModeCommand
+import app.aaps.pump.carelevo.ble.commands.ExtendBolusCancelCommand
+import app.aaps.pump.carelevo.ble.commands.ExtendBolusCommand
+import app.aaps.pump.carelevo.ble.commands.ImmediateBolusCommand
+import app.aaps.pump.carelevo.ble.commands.InfusionInfoCommand
+import app.aaps.pump.carelevo.ble.commands.InfusionThresholdCommand
+import app.aaps.pump.carelevo.ble.commands.MacAddressCommand
+import app.aaps.pump.carelevo.ble.commands.MacAddressResponse
+import app.aaps.pump.carelevo.ble.commands.NeedleAckCommand
+import app.aaps.pump.carelevo.ble.commands.NeedleStatusCommand
+import app.aaps.pump.carelevo.ble.commands.NoticeThresholdCommand
+import app.aaps.pump.carelevo.ble.commands.PatchDiscardCommand
+import app.aaps.pump.carelevo.ble.commands.PatchInfoCommand
+import app.aaps.pump.carelevo.ble.commands.PumpResumeCommand
+import app.aaps.pump.carelevo.ble.commands.PumpStopCommand
+import app.aaps.pump.carelevo.ble.commands.SafetyCheckCommand
+import app.aaps.pump.carelevo.ble.commands.SetTimeCommand
+import app.aaps.pump.carelevo.ble.commands.SetTimeForPatchInfoCommand
+import app.aaps.pump.carelevo.ble.commands.TempBasalCancelCommand
+import app.aaps.pump.carelevo.ble.commands.TempBasalCommand
+import app.aaps.pump.carelevo.ble.commands.ThresholdSetupCommand
+import com.google.common.truth.Truth.assertThat
+import org.joda.time.DateTime
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import kotlin.experimental.xor
+
+/**
+ * Wire-compatibility tests for [CarelevoPumpEmulator], driven end-to-end through the **production**
+ * codec: every request is built with the real command's `encode()` and every response is parsed with
+ * the real command's `decode(...)`. Nothing here hand-asserts an invented byte array, so a passing
+ * test means the emulator is byte-for-byte compatible with what `BleClientImpl` actually writes and
+ * reads — not merely self-consistent.
+ *
+ * The three [app.aaps.pump.carelevo.ble.BleClient] shapes are exercised the way the client drives them:
+ * - [app.aaps.pump.carelevo.ble.BleCommand] → `decode(frames.single())`
+ * - [app.aaps.pump.carelevo.ble.BleMultiCommand] → `decode(frames.associateBy { it[0] })` (keyed by opcode)
+ * - [app.aaps.pump.carelevo.ble.BleStreamCommand]→ decode each frame, terminate on `isTerminal(decoded)`
+ *
+ * Pure JVM: the emulator takes a nullable logger and touches no Android API, so no Robolectric.
+ */
+class CarelevoPumpEmulatorTest {
+
+ private lateinit var state: CarelevoPumpState
+ private lateinit var emulator: CarelevoPumpEmulator
+
+ @BeforeEach
+ fun setUp() {
+ state = CarelevoPumpState()
+ emulator = CarelevoPumpEmulator(state)
+ }
+
+ // ---- Pairing ---------------------------------------------------------------------------------
+
+ @Test
+ fun `mac address round-trip decodes the patch address and checksum`() {
+ val cmd = MacAddressCommand(MAC_KEY.toByte())
+
+ val frames = emulator.handle(cmd.encode())
+ val decoded = cmd.decode(frames.single())
+
+ assertThat(frames.single()[0]).isEqualTo(MacAddressCommand.RESPONSE_OPCODE)
+ assertThat(decoded.macAddress).isEqualTo("94B2161D2F6D")
+ assertThat(decoded.checkSum).isEqualTo("5A")
+ // The key must be retained — 0x4B verifies the app's fold against it.
+ assertThat(state.lastMacKey).isEqualTo(MAC_KEY)
+ }
+
+ @Test
+ fun `app auth accepts the checksum fold the session computes`() {
+ val macResponse = readMac(MAC_KEY)
+ val cmd = AppAuthCommand(sessionFold(macResponse, MAC_KEY))
+
+ val frames = emulator.handle(cmd.encode())
+ val decoded = cmd.decode(frames.single())
+
+ assertThat(decoded.resultCode).isEqualTo(0)
+ }
+
+ @Test
+ fun `app auth rejects a wrong checksum`() {
+ val macResponse = readMac(MAC_KEY)
+ // One bit off the fold the patch expects — a real patch rejects this, so the emulator must too.
+ val cmd = AppAuthCommand((sessionFold(macResponse, MAC_KEY) + 1) and 0xFF)
+
+ val frames = emulator.handle(cmd.encode())
+ val decoded = cmd.decode(frames.single())
+
+ assertThat(decoded.resultCode).isNotEqualTo(0)
+ }
+
+ @Test
+ fun `app auth before the mac read fails`() {
+ // No 0x3B first, so the patch has no key to verify against.
+ val cmd = AppAuthCommand(0x42)
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(decoded.resultCode).isNotEqualTo(0)
+ }
+
+ @Test
+ fun `app auth answers on 0xBB not 0x4B 0xBA or 0xAB`() {
+ val macResponse = readMac(MAC_KEY)
+ val cmd = AppAuthCommand(sessionFold(macResponse, MAC_KEY))
+
+ val opcode = emulator.handle(cmd.encode()).single()[0]
+
+ assertThat(opcode).isEqualTo(0xBB.toByte())
+ assertThat(opcode).isNotEqualTo(0x4B.toByte())
+ assertThat(opcode).isNotEqualTo(0xBA.toByte())
+ assertThat(opcode).isNotEqualTo(0xAB.toByte())
+ // ...and the production decoder agrees, since it requires 0xBB at byte 0.
+ assertThat(cmd.decode(emulator.handle(cmd.encode()).single()).resultCode).isEqualTo(0)
+ }
+
+ @Test
+ fun `app auth result code override wins over a correct fold`() {
+ val macResponse = readMac(MAC_KEY)
+ state.resultCodeOverrides[CarelevoPumpEmulator.OP_APP_AUTH] = 2
+ val cmd = AppAuthCommand(sessionFold(macResponse, MAC_KEY))
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(decoded.resultCode).isEqualTo(2)
+ }
+
+ // ---- The swapped needle pair -----------------------------------------------------------------
+
+ @Test
+ fun `needle ack 0x19 answers on 0x7A not 0x79`() {
+ val cmd = NeedleAckCommand(isSuccess = true)
+
+ val frames = emulator.handle(cmd.encode())
+
+ // A "+0x60" emulator would answer 0x79 here and deadlock the client.
+ assertThat(frames.single()[0]).isEqualTo(0x7A.toByte())
+ assertThat(frames.single()[0]).isNotEqualTo(0x79.toByte())
+ assertThat(cmd.decode(frames.single()).resultCode).isEqualTo(0)
+ assertThat(state.needleInserted).isTrue()
+ }
+
+ @Test
+ fun `needle status 0x1A answers on 0x79 not 0x7A`() {
+ val cmd = NeedleStatusCommand()
+
+ val frames = emulator.handle(cmd.encode())
+
+ assertThat(frames.single()[0]).isEqualTo(0x79.toByte())
+ assertThat(frames.single()[0]).isNotEqualTo(0x7A.toByte())
+ assertThat(cmd.decode(frames.single()).resultCode).isEqualTo(0)
+ }
+
+ @Test
+ fun `needle ack with the failure flag leaves the needle uninserted`() {
+ val cmd = NeedleAckCommand(isSuccess = false)
+
+ cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(state.needleInserted).isFalse()
+ }
+
+ // ---- Patch info ------------------------------------------------------------------------------
+
+ @Test
+ fun `patch info answers with exactly the two report frames 0x93 and 0x94`() {
+ val cmd = PatchInfoCommand()
+
+ val frames = emulator.handle(cmd.encode())
+
+ assertThat(frames.map { it[0] }).containsExactly(0x93.toByte(), 0x94.toByte())
+ assertThat(frames.map { it[0] }.toSet()).isEqualTo(cmd.expectedResponseOpcodes)
+ }
+
+ @Test
+ fun `patch info round-trip decodes serial firmware and the decimal-concat model name`() {
+ val cmd = PatchInfoCommand()
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).associateBy { it[0] })
+
+ assertThat(decoded.serialResultCode).isEqualTo(0)
+ assertThat(decoded.serialNumber).isEqualTo("CL24000000001")
+ assertThat(decoded.detailResultCode).isEqualTo(0)
+ assertThat(decoded.firmwareVersion).isEqualTo("1.10")
+ // The quirk: model bytes are concatenated DECIMAL values, not ASCII — [1,2,0,0,0] -> "12000".
+ assertThat(decoded.modelName).isEqualTo("12000")
+ }
+
+ @Test
+ fun `patch info model name follows the state model bytes`() {
+ state.modelBytes = byteArrayOf(2, 15, 0, 3, 0)
+ val cmd = PatchInfoCommand()
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).associateBy { it[0] })
+
+ assertThat(decoded.modelName).isEqualTo("215030")
+ }
+
+ // ---- SetTime split ---------------------------------------------------------------------------
+
+ @Test
+ fun `set time with subId 0 answers with the patch info pair`() {
+ val cmd = SetTimeForPatchInfoCommand(subId = 0, volume = 200, aidMode = 0, dateTime = DATE_TIME)
+
+ val frames = emulator.handle(cmd.encode())
+ val decoded = cmd.decode(frames.associateBy { it[0] })
+
+ // The activation path waits for 0x93+0x94; a 0x71 here would hang it.
+ assertThat(frames.map { it[0] }).containsExactly(0x93.toByte(), 0x94.toByte())
+ assertThat(decoded.serialNumber).isEqualTo("CL24000000001")
+ }
+
+ @Test
+ fun `set time with a non-zero subId answers on 0x71`() {
+ val cmd = SetTimeCommand(subId = 1, volume = 200, aidMode = 0, dateTime = DATE_TIME)
+
+ val frames = emulator.handle(cmd.encode())
+ val decoded = cmd.decode(frames.single())
+
+ assertThat(frames.single()[0]).isEqualTo(SetTimeCommand.RESPONSE_OPCODE)
+ assertThat(decoded.resultCode).isEqualTo(0)
+ }
+
+ @Test
+ fun `set time frames differ only in the subId byte`() {
+ val forPatchInfo = SetTimeForPatchInfoCommand(subId = 0, volume = 200, aidMode = 0, dateTime = DATE_TIME).encode()
+ val plain = SetTimeCommand(subId = 1, volume = 200, aidMode = 0, dateTime = DATE_TIME).encode()
+
+ // Byte-identical 11-byte frames on one opcode — only byte 1 tells the two commands apart,
+ // which is exactly why the emulator has to branch on it.
+ assertThat(forPatchInfo).hasLength(CarelevoPumpEmulator.SET_TIME_LENGTH)
+ assertThat(plain).hasLength(CarelevoPumpEmulator.SET_TIME_LENGTH)
+ assertThat(forPatchInfo[0]).isEqualTo(plain[0])
+ assertThat(forPatchInfo[1]).isNotEqualTo(plain[1])
+ assertThat(forPatchInfo.copyOfRange(2, forPatchInfo.size))
+ .isEqualTo(plain.copyOfRange(2, plain.size))
+ }
+
+ @Test
+ fun `set time records the subId and resets the running minutes`() {
+ state.runningMinutes = 500
+ val cmd = SetTimeCommand(subId = 1, volume = 150, aidMode = 0, dateTime = DATE_TIME)
+
+ cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(state.subId).isEqualTo(1)
+ assertThat(state.runningMinutes).isEqualTo(0)
+ }
+
+ // ---- Safety check stream ---------------------------------------------------------------------
+
+ @Test
+ fun `safety check streams progress frames then a terminal frame`() {
+ state.safetyCheckProgressFrames = 3
+ val cmd = SafetyCheckCommand()
+
+ val decodedFrames = emulator.handle(cmd.encode()).map { cmd.decode(it) }
+
+ assertThat(decodedFrames).hasSize(4)
+ assertThat(decodedFrames.dropLast(1).map { cmd.isTerminal(it) }).containsExactly(false, false, false)
+ assertThat(decodedFrames.last().resultCode).isEqualTo(SafetyCheckCommand.RESULT_SUCCESS)
+ assertThat(cmd.isTerminal(decodedFrames.last())).isTrue()
+ }
+
+ @Test
+ fun `safety check progress frames carry a progress result code`() {
+ val cmd = SafetyCheckCommand()
+
+ val decodedFrames = emulator.handle(cmd.encode()).map { cmd.decode(it) }
+
+ // Progress is signalled purely by the result byte being REP_REQUEST/REP_REQUEST1.
+ assertThat(decodedFrames.dropLast(1).map { it.resultCode })
+ .containsExactly(SafetyCheckCommand.REP_REQUEST, SafetyCheckCommand.REP_REQUEST)
+ }
+
+ @Test
+ fun `safety check never emits a 5-byte frame`() {
+ state.safetyCheckProgressFrames = 4
+ val cmd = SafetyCheckCommand()
+
+ val frames = emulator.handle(cmd.encode())
+
+ // SafetyCheckCommand.decode reads index 5 whenever size > 4, so a 5-byte frame would throw
+ // ArrayIndexOutOfBounds in the real driver instead of failing cleanly.
+ assertThat(frames.map { it.size }).doesNotContain(5)
+ assertThat(frames.map { it.size }.all { it == 4 || it >= 6 }).isTrue()
+ assertThat(frames.map { it[0] }.toSet()).containsExactly(SafetyCheckCommand.RESPONSE_OPCODE)
+ }
+
+ @Test
+ fun `safety check frames decode the priming volume and duration`() {
+ state.safetyCheckVolume = 250
+ state.safetyCheckDurationSeconds = 185
+ val cmd = SafetyCheckCommand()
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).last())
+
+ assertThat(decoded.insulinVolume).isEqualTo(250)
+ assertThat(decoded.durationSeconds).isEqualTo(185)
+ }
+
+ @Test
+ fun `safety check success moves the patch to RUNNING`() {
+ state.pumpStateRaw = CarelevoPumpEmulator.PUMP_STATE_READY
+ val cmd = SafetyCheckCommand()
+
+ cmd.decode(emulator.handle(cmd.encode()).last())
+
+ assertThat(state.pumpStateRaw).isEqualTo(CarelevoPumpEmulator.PUMP_STATE_RUNNING)
+ }
+
+ @Test
+ fun `safety check error terminates the stream and leaves the pump state alone`() {
+ state.pumpStateRaw = CarelevoPumpEmulator.PUMP_STATE_READY
+ state.safetyCheckResult = 11
+ val cmd = SafetyCheckCommand()
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).last())
+
+ // An error code is terminal too — the stream must complete rather than time out.
+ assertThat(cmd.isTerminal(decoded)).isTrue()
+ assertThat(decoded.resultCode).isEqualTo(11)
+ assertThat(state.pumpStateRaw).isEqualTo(CarelevoPumpEmulator.PUMP_STATE_READY)
+ }
+
+ @Test
+ fun `safety check with no progress frames emits only the terminal frame`() {
+ state.safetyCheckProgressFrames = 0
+ val cmd = SafetyCheckCommand()
+
+ val frames = emulator.handle(cmd.encode())
+
+ assertThat(frames).hasSize(1)
+ assertThat(cmd.isTerminal(cmd.decode(frames.single()))).isTrue()
+ }
+
+ // ---- Immediate bolus -------------------------------------------------------------------------
+
+ @Test
+ fun `immediate bolus round-trip echoes the action id and decodes the remaining reservoir`() {
+ val cmd = ImmediateBolusCommand(actionId = 42, volume = 2.5)
+
+ val frames = emulator.handle(cmd.encode())
+ val decoded = cmd.decode(frames.single())
+
+ assertThat(frames.single()[0]).isEqualTo(ImmediateBolusCommand.RESPONSE_OPCODE)
+ // Byte 1 is the correlation byte — the client silently drops a frame that does not match.
+ assertThat(frames.single()[1]).isEqualTo(cmd.correlationByte)
+ assertThat(decoded.actionId).isEqualTo(42)
+ assertThat(decoded.resultCode).isEqualTo(0)
+ assertThat(decoded.expectedCompletionSeconds).isEqualTo(50)
+ assertThat(decoded.remainingReservoirUnits).isWithin(TOLERANCE).of(197.5)
+ }
+
+ @Test
+ fun `immediate bolus echoes an action id above 127 unsigned`() {
+ val cmd = ImmediateBolusCommand(actionId = 200, volume = 1.0)
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(decoded.actionId).isEqualTo(200)
+ assertThat(state.activeBolusActionId).isEqualTo(200)
+ }
+
+ @Test
+ fun `immediate bolus decrements the reservoir and adds to the bolus total`() {
+ state.insulinRemaining = 100.0
+ state.infusedTotalBolus = 3.0
+ val cmd = ImmediateBolusCommand(actionId = 7, volume = 4.25)
+
+ cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(state.insulinRemaining).isWithin(TOLERANCE).of(95.75)
+ assertThat(state.infusedTotalBolus).isWithin(TOLERANCE).of(7.25)
+ assertThat(state.modeRaw).isEqualTo(CarelevoPumpEmulator.MODE_IMME_BOLUS)
+ // The emulated bolus lands immediately, so the whole dose is already infused — a later cancel
+ // reports this, rather than claiming 0 U for insulin the totals above have already counted.
+ assertThat(state.bolusInfusedAmount).isWithin(TOLERANCE).of(4.25)
+ }
+
+ @Test
+ fun `immediate bolus failure keeps the reservoir and still echoes the action id`() {
+ state.resultCodeOverrides[CarelevoPumpEmulator.OP_IMMEDIATE_BOLUS] = 6
+ val cmd = ImmediateBolusCommand(actionId = 99, volume = 3.0)
+
+ val frames = emulator.handle(cmd.encode())
+ val decoded = cmd.decode(frames.single())
+
+ // Result code lives at index 2 — index 1 is the echoed actionId, not the result.
+ assertThat(frames.single()[2]).isEqualTo(6.toByte())
+ assertThat(decoded.resultCode).isEqualTo(6)
+ assertThat(decoded.actionId).isEqualTo(99)
+ assertThat(state.insulinRemaining).isWithin(TOLERANCE).of(200.0)
+ assertThat(state.activeBolusActionId).isNull()
+ }
+
+ // ---- Infusion info ---------------------------------------------------------------------------
+
+ @Test
+ fun `infusion info frame is 20 bytes and echoes the inquiry type`() {
+ val cmd = InfusionInfoCommand(inquiryType = 1)
+
+ val frames = emulator.handle(cmd.encode())
+ val decoded = cmd.decode(frames.single())
+
+ // The decoder reads index 19, so anything shorter than 20 fails the require().
+ assertThat(frames.single().size).isAtLeast(20)
+ assertThat(frames.single()[0]).isEqualTo(InfusionInfoCommand.RESPONSE_OPCODE)
+ assertThat(decoded.subId).isEqualTo(1)
+ }
+
+ @Test
+ fun `infusion info round-trip decodes every field`() {
+ state.runningMinutes = 125
+ state.insulinRemaining = 187.65
+ state.infusedTotalBasal = 12.34
+ state.infusedTotalBolus = 5.5
+ state.pumpStateRaw = CarelevoPumpEmulator.PUMP_STATE_RUNNING
+ state.modeRaw = CarelevoPumpEmulator.MODE_BASAL
+ val cmd = InfusionInfoCommand()
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(decoded.subId).isEqualTo(0)
+ assertThat(decoded.runningMinutes).isEqualTo(125)
+ assertThat(decoded.insulinRemaining).isWithin(TOLERANCE).of(187.65)
+ assertThat(decoded.infusedTotalBasalAmount).isWithin(TOLERANCE).of(12.34)
+ assertThat(decoded.infusedTotalBolusAmount).isWithin(TOLERANCE).of(5.5)
+ assertThat(decoded.pumpStateRaw).isEqualTo(2)
+ assertThat(decoded.modeRaw).isEqualTo(1)
+ assertThat(decoded.currentInfusedProgramVolume).isWithin(TOLERANCE).of(5.5)
+ // realInfusedTime is seconds: 125 min -> 7500 s.
+ assertThat(decoded.realInfusedTime).isEqualTo(7500)
+ }
+
+ // ---- Basal program ---------------------------------------------------------------------------
+
+ @Test
+ fun `basal program acks each sequence individually and commits only on seq 2`() {
+ val seq0 = BasalProgramCommand(isUpdate = false, seqNo = 0, segmentSpeeds = listOf(0.5, 0.75))
+ val seq1 = BasalProgramCommand(isUpdate = false, seqNo = 1, segmentSpeeds = listOf(1.25, 1.5))
+ val seq2 = BasalProgramCommand(isUpdate = false, seqNo = 2, segmentSpeeds = listOf(2.0, 2.5))
+
+ assertThat(seq0.decode(emulator.handle(seq0.encode()).single()).resultCode).isEqualTo(0)
+ assertThat(state.basalProgramCommitted).isFalse()
+
+ assertThat(seq1.decode(emulator.handle(seq1.encode()).single()).resultCode).isEqualTo(0)
+ assertThat(state.basalProgramCommitted).isFalse()
+
+ assertThat(seq2.decode(emulator.handle(seq2.encode()).single()).resultCode).isEqualTo(0)
+ assertThat(state.basalProgramCommitted).isTrue()
+ }
+
+ @Test
+ fun `basal program returns the segments in sequence order`() {
+ listOf(
+ BasalProgramCommand(isUpdate = false, seqNo = 2, segmentSpeeds = listOf(2.0, 2.5)),
+ BasalProgramCommand(isUpdate = false, seqNo = 0, segmentSpeeds = listOf(0.5, 0.75)),
+ BasalProgramCommand(isUpdate = false, seqNo = 1, segmentSpeeds = listOf(1.25, 1.5))
+ ).forEach { cmd -> cmd.decode(emulator.handle(cmd.encode()).single()) }
+
+ // Written out of order; the program still reads back in segment order.
+ assertThat(state.basalProgram).containsExactly(0.5, 0.75, 1.25, 1.5, 2.0, 2.5).inOrder()
+ }
+
+ @Test
+ fun `basal program set uses 0x13 to 0x73 and update uses 0x21 to 0x81`() {
+ val set = BasalProgramCommand(isUpdate = false, seqNo = 0, segmentSpeeds = listOf(1.0))
+ val update = BasalProgramCommand(isUpdate = true, seqNo = 0, segmentSpeeds = listOf(1.0))
+
+ val setFrame = emulator.handle(set.encode()).single()
+ val updateFrame = emulator.handle(update.encode()).single()
+
+ assertThat(set.encode()[0]).isEqualTo(BasalProgramCommand.SET_REQUEST_OPCODE)
+ assertThat(setFrame[0]).isEqualTo(BasalProgramCommand.SET_RESPONSE_OPCODE)
+ assertThat(update.encode()[0]).isEqualTo(BasalProgramCommand.UPDATE_REQUEST_OPCODE)
+ assertThat(updateFrame[0]).isEqualTo(BasalProgramCommand.UPDATE_RESPONSE_OPCODE)
+ assertThat(set.decode(setFrame).resultCode).isEqualTo(0)
+ assertThat(update.decode(updateFrame).resultCode).isEqualTo(0)
+ }
+
+ @Test
+ fun `basal program failure does not bank the segments`() {
+ state.resultCodeOverrides[BasalProgramCommand.SET_REQUEST_OPCODE] = 9
+ val cmd = BasalProgramCommand(isUpdate = false, seqNo = 2, segmentSpeeds = listOf(1.0, 2.0))
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(decoded.resultCode).isEqualTo(9)
+ assertThat(state.basalProgramCommitted).isFalse()
+ assertThat(state.basalProgram).isEmpty()
+ }
+
+ // ---- Index-shifted result codes ---------------------------------------------------------------
+
+ @Test
+ fun `infusion threshold carries its result at index 2 behind the echoed type`() {
+ state.resultCodeOverrides[CarelevoPumpEmulator.OP_INFUSION_THRESHOLD] = 3
+ val cmd = InfusionThresholdCommand(isMaxVolume = true, value = 12.5)
+
+ val frames = emulator.handle(cmd.encode())
+ val decoded = cmd.decode(frames.single())
+
+ // Byte 1 echoes the type flag, so the result sits at index 2 — not the usual index 1.
+ assertThat(frames.single()[1]).isEqualTo(0x01.toByte())
+ assertThat(frames.single()[2]).isEqualTo(3.toByte())
+ assertThat(decoded.type).isEqualTo(1)
+ assertThat(decoded.resultCode).isEqualTo(3)
+ }
+
+ @Test
+ fun `infusion threshold max volume round-trip stores the max bolus dose`() {
+ val cmd = InfusionThresholdCommand(isMaxVolume = true, value = 12.5)
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(decoded.type).isEqualTo(1)
+ assertThat(decoded.resultCode).isEqualTo(0)
+ assertThat(state.maxBolusDose).isWithin(TOLERANCE).of(12.5)
+ }
+
+ @Test
+ fun `infusion threshold max speed round-trip stores the max basal speed`() {
+ val cmd = InfusionThresholdCommand(isMaxVolume = false, value = 3.75)
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(decoded.type).isEqualTo(0)
+ assertThat(state.maxBasalSpeed).isWithin(TOLERANCE).of(3.75)
+ }
+
+ @Test
+ fun `alarm clear carries its result at index 3 behind the echoed type and cause`() {
+ state.resultCodeOverrides[CarelevoPumpEmulator.OP_ALARM_CLEAR] = 4
+ val cmd = AlarmClearCommand(alarmType = 5, cause = 3)
+
+ val frames = emulator.handle(cmd.encode())
+ val decoded = cmd.decode(frames.single())
+
+ assertThat(frames.single()[0]).isEqualTo(AlarmClearCommand.RESPONSE_OPCODE)
+ assertThat(frames.single()[3]).isEqualTo(4.toByte())
+ assertThat(decoded.subId).isEqualTo(5)
+ assertThat(decoded.cause).isEqualTo(3)
+ assertThat(decoded.resultCode).isEqualTo(4)
+ }
+
+ @Test
+ fun `notice threshold echoes the type with no result code on the wire`() {
+ val lowInsulin = NoticeThresholdCommand(thresholdType = NoticeThresholdCommand.TYPE_LOW_INSULIN, value = 35)
+ val expiry = NoticeThresholdCommand(thresholdType = NoticeThresholdCommand.TYPE_EXPIRY, value = 48)
+
+ val lowFrame = emulator.handle(lowInsulin.encode()).single()
+ val expiryFrame = emulator.handle(expiry.encode()).single()
+
+ // The 0x75 frame is [opcode][type] — arrival IS the success signal; resultCode is fabricated.
+ assertThat(lowFrame).hasLength(2)
+ assertThat(lowFrame[1]).isEqualTo(0x00.toByte())
+ assertThat(expiryFrame[1]).isEqualTo(0x01.toByte())
+ assertThat(lowInsulin.decode(lowFrame).thresholdType).isEqualTo(NoticeThresholdCommand.TYPE_LOW_INSULIN)
+ assertThat(lowInsulin.decode(lowFrame).resultCode).isEqualTo(0)
+ assertThat(expiry.decode(expiryFrame).thresholdType).isEqualTo(NoticeThresholdCommand.TYPE_EXPIRY)
+ assertThat(state.lowInsulinNotice).isEqualTo(35)
+ assertThat(state.expiryNotice).isEqualTo(48)
+ }
+
+ // ---- Remaining opcodes ------------------------------------------------------------------------
+
+ @Test
+ fun `buzz mode round-trip stores the buzzer flag`() {
+ val on = BuzzModeCommand(use = true)
+ assertThat(on.decode(emulator.handle(on.encode()).single()).resultCode).isEqualTo(0)
+ assertThat(state.buzzUse).isTrue()
+
+ val off = BuzzModeCommand(use = false)
+ assertThat(off.decode(emulator.handle(off.encode()).single()).resultCode).isEqualTo(0)
+ assertThat(state.buzzUse).isFalse()
+ }
+
+ @Test
+ fun `threshold setup round-trip stores the whole activation bundle`() {
+ val cmd = ThresholdSetupCommand(
+ insulinRemainsThreshold = 30,
+ expiryThreshold = 48,
+ maxBasalSpeed = 10.5,
+ maxBolusDose = 20.25,
+ buzzUse = true
+ )
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(decoded.resultCode).isEqualTo(0)
+ assertThat(state.insulinRemainsThreshold).isEqualTo(30)
+ assertThat(state.expiryThreshold).isEqualTo(48)
+ assertThat(state.maxBasalSpeed).isWithin(TOLERANCE).of(10.5)
+ assertThat(state.maxBolusDose).isWithin(TOLERANCE).of(20.25)
+ assertThat(state.buzzUse).isTrue()
+ }
+
+ @Test
+ fun `additional priming round-trip counts the priming pulse`() {
+ val cmd = AdditionalPrimingCommand()
+
+ assertThat(cmd.decode(emulator.handle(cmd.encode()).single()).resultCode).isEqualTo(0)
+ cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(state.primingCount).isEqualTo(2)
+ }
+
+ @Test
+ fun `temp basal by unit round-trip starts the temp basal`() {
+ val cmd = TempBasalCommand.byUnit(infusionUnit = 1.5, infusionHour = 2, infusionMin = 30)
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(cmd.encode()).hasLength(6) // BY_UNIT carries the trailing 0x00
+ assertThat(decoded.resultCode).isEqualTo(0)
+ assertThat(state.tempBasalRunning).isTrue()
+ assertThat(state.modeRaw).isEqualTo(CarelevoPumpEmulator.MODE_TEMP_BASAL)
+ }
+
+ @Test
+ fun `temp basal by percent round-trip uses the same opcode pair`() {
+ val cmd = TempBasalCommand.byPercent(infusionPercent = 150, infusionHour = 1, infusionMin = 0)
+
+ val frames = emulator.handle(cmd.encode())
+ val decoded = cmd.decode(frames.single())
+
+ assertThat(cmd.encode()).hasLength(5) // BY_PERCENT has no trailing byte — the asymmetry
+ assertThat(frames.single()[0]).isEqualTo(TempBasalCommand.RESPONSE_OPCODE)
+ assertThat(decoded.resultCode).isEqualTo(0)
+ assertThat(state.tempBasalRunning).isTrue()
+ }
+
+ @Test
+ fun `temp basal cancel round-trip stops the temp basal`() {
+ state.tempBasalRunning = true
+ state.modeRaw = CarelevoPumpEmulator.MODE_TEMP_BASAL
+ val cmd = TempBasalCancelCommand()
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(decoded.resultCode).isEqualTo(0)
+ assertThat(state.tempBasalRunning).isFalse()
+ assertThat(state.modeRaw).isEqualTo(CarelevoPumpEmulator.MODE_BASAL)
+ }
+
+ @Test
+ fun `extend bolus round-trip starts the extended bolus`() {
+ val cmd = ExtendBolusCommand(immediateDose = 1.0, extendedSpeed = 0.5, hour = 1, min = 30)
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(decoded.resultCode).isEqualTo(0)
+ // The decoder recombines bytes 2..3 as `[2] * 60 + [3]` seconds, so a 1h30m request must come
+ // back as 5400 s — encoded [90, 0], with the minutes in byte 2 rather than split again by 60.
+ assertThat(decoded.expectedTimeSeconds).isEqualTo(5400)
+ assertThat(state.extendedBolusRunning).isTrue()
+ assertThat(state.modeRaw).isEqualTo(CarelevoPumpEmulator.MODE_EXTEND_BOLUS)
+ }
+
+ @Test
+ fun `extend bolus cancel round-trip reports the infused amount`() {
+ state.extendedBolusRunning = true
+ state.extendedInfusedAmount = 3.25
+ val cmd = ExtendBolusCancelCommand()
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(decoded.resultCode).isEqualTo(0)
+ assertThat(decoded.infusedAmount).isWithin(TOLERANCE).of(3.25)
+ assertThat(state.extendedBolusRunning).isFalse()
+ assertThat(state.modeRaw).isEqualTo(CarelevoPumpEmulator.MODE_BASAL)
+ }
+
+ @Test
+ fun `bolus cancel round-trip reports the infused amount and clears the action id`() {
+ state.activeBolusActionId = 42
+ state.bolusInfusedAmount = 1.75
+ val cmd = BolusCancelCommand()
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(decoded.resultCode).isEqualTo(0)
+ assertThat(decoded.infusedAmount).isWithin(TOLERANCE).of(1.75)
+ assertThat(state.activeBolusActionId).isNull()
+ assertThat(state.modeRaw).isEqualTo(CarelevoPumpEmulator.MODE_BASAL)
+ }
+
+ @Test
+ fun `pump stop round-trip stops the patch and records the subId`() {
+ val cmd = PumpStopCommand(durationMinutes = 90, subId = 1)
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(decoded.resultCode).isEqualTo(0)
+ assertThat(state.stopped).isTrue()
+ assertThat(state.subId).isEqualTo(1)
+ }
+
+ @Test
+ fun `pump resume round-trip echoes the mode and defaults the optional subId`() {
+ state.stopped = true
+ val cmd = PumpResumeCommand(mode = 1, subId = 1)
+
+ val frames = emulator.handle(cmd.encode())
+ val decoded = cmd.decode(frames.single())
+
+ // The 4th subId byte is omitted; the decoder must default it rather than fail.
+ assertThat(frames.single()).hasLength(3)
+ assertThat(decoded.resultCode).isEqualTo(0)
+ assertThat(decoded.mode).isEqualTo(1)
+ assertThat(decoded.subId).isEqualTo(0)
+ assertThat(state.stopped).isFalse()
+ assertThat(state.modeRaw).isEqualTo(CarelevoPumpEmulator.MODE_BASAL)
+ }
+
+ @Test
+ fun `patch discard round-trip discards the patch and returns it to READY`() {
+ val cmd = PatchDiscardCommand()
+
+ val decoded = cmd.decode(emulator.handle(cmd.encode()).single())
+
+ assertThat(decoded.resultCode).isEqualTo(0)
+ assertThat(state.discarded).isTrue()
+ assertThat(state.pumpStateRaw).isEqualTo(CarelevoPumpEmulator.PUMP_STATE_READY)
+ }
+
+ @Test
+ fun `alert alarm set round-trip stores the alarm mode`() {
+ val cmd = AlertAlarmSetCommand(mode = 3)
+
+ val frames = emulator.handle(cmd.encode())
+ val decoded = cmd.decode(frames.single())
+
+ assertThat(frames.single()[0]).isEqualTo(AlertAlarmSetCommand.RESPONSE_OPCODE)
+ assertThat(decoded.resultCode).isEqualTo(0)
+ assertThat(state.alertAlarmMode).isEqualTo(3)
+ }
+
+ // ---- Fault injection --------------------------------------------------------------------------
+
+ @Test
+ fun `silent opcodes make the patch stay quiet`() {
+ state.silentOpcodes += InfusionInfoCommand.REQUEST_OPCODE
+ val silent = InfusionInfoCommand()
+ val loud = PatchDiscardCommand()
+
+ assertThat(emulator.handle(silent.encode())).isEmpty()
+ // ...and only that opcode goes quiet.
+ assertThat(emulator.handle(loud.encode())).hasSize(1)
+ }
+
+ @Test
+ fun `result code overrides come back through the real decoder`() {
+ state.resultCodeOverrides[CarelevoPumpEmulator.OP_PUMP_STOP] = 12
+ state.resultCodeOverrides[CarelevoPumpEmulator.OP_PATCH_DISCARD] = 7
+ val stop = PumpStopCommand(durationMinutes = 30, subId = 0)
+ val discard = PatchDiscardCommand()
+
+ assertThat(stop.decode(emulator.handle(stop.encode()).single()).resultCode).isEqualTo(12)
+ assertThat(discard.decode(emulator.handle(discard.encode()).single()).resultCode).isEqualTo(7)
+ // A non-success result must not apply the state mutation.
+ assertThat(state.stopped).isFalse()
+ assertThat(state.discarded).isFalse()
+ }
+
+ @Test
+ fun `silent opcode wins over a result code override`() {
+ state.silentOpcodes += CarelevoPumpEmulator.OP_PATCH_DISCARD
+ state.resultCodeOverrides[CarelevoPumpEmulator.OP_PATCH_DISCARD] = 7
+
+ assertThat(emulator.handle(PatchDiscardCommand().encode())).isEmpty()
+ }
+
+ // ---- Malformed input --------------------------------------------------------------------------
+
+ @Test
+ fun `empty request is ignored`() {
+ assertThat(emulator.handle(ByteArray(0))).isEmpty()
+ }
+
+ @Test
+ fun `unknown opcode is ignored`() {
+ assertThat(emulator.handle(byteArrayOf(0x7F, 0x01, 0x02))).isEmpty()
+ }
+
+ // ---- Helpers ----------------------------------------------------------------------------------
+
+ /** Runs the real 0x3B round-trip and returns the decoded response. */
+ private fun readMac(key: Int): MacAddressResponse {
+ val cmd = MacAddressCommand(key.toByte())
+ return cmd.decode(emulator.handle(cmd.encode()).single())
+ }
+
+ /**
+ * The app-side handshake fold, exactly as `CarelevoBleSession.runPairing` computes it:
+ * `(macResponse.macAddress + macResponse.checkSum).convertHexToByteArray().checkSumV2(key)`.
+ *
+ * `convertHexToByteArray`/`checkSumV2` are `internal` to `:pump:carelevo`, and Kotlin only grants
+ * internal visibility to a module's own test source set — so this module cannot call them and the
+ * hex-parse + XOR fold is replicated here instead. Kept deliberately literal (parse the decoded
+ * hex strings, fold from `key.toByte()`) so it stays a faithful stand-in for the session's code.
+ */
+ private fun sessionFold(macResponse: MacAddressResponse, key: Int): Int =
+ (macResponse.macAddress + macResponse.checkSum)
+ .chunked(2)
+ .map { it.toInt(16).toByte() }
+ .fold(key.toByte()) { acc, b -> acc xor b }
+ .toUByte()
+ .toInt()
+
+ private companion object {
+
+ /** Above 127 on purpose — proves the key survives the Int→Byte→unsigned round-trip. */
+ const val MAC_KEY = 0xC3
+
+ val DATE_TIME: DateTime = DateTime(2026, 7, 16, 14, 30, 45)
+
+ const val TOLERANCE = 1e-9
+ }
+}
diff --git a/pump/carelevo/build.gradle.kts b/pump/carelevo/build.gradle.kts
new file mode 100644
index 000000000000..656fdddd19c4
--- /dev/null
+++ b/pump/carelevo/build.gradle.kts
@@ -0,0 +1,41 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.compose.compiler)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt)
+ id("android-module-dependencies")
+ id("test-module-dependencies")
+ id("compose-test-module-dependencies")
+ id("jacoco-module-dependencies")
+}
+
+android {
+ namespace = "app.aaps.pump.carelevo"
+
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(project(":core:data"))
+ implementation(project(":core:interfaces"))
+ implementation(project(":core:utils"))
+ implementation(project(":core:ui"))
+ implementation(project(":core:keys"))
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+
+ implementation(libs.com.google.guava)
+ implementation(libs.androidx.lifecycle.process)
+ implementation(libs.io.reactivex.rxjava3.rxandroid)
+ implementation(libs.com.polidea.rxandroidble3)
+ implementation(libs.com.jakewharton.rx3.replaying.share)
+ implementation(libs.com.google.android.material)
+
+ implementation(libs.com.google.dagger.hilt.android)
+ implementation(libs.androidx.hilt.navigation.compose)
+ ksp(libs.com.google.dagger.compiler)
+ ksp(libs.com.google.dagger.hilt.compiler)
+ ksp(libs.com.google.dagger.android.processor)
+}
diff --git a/pump/carelevo/src/main/AndroidManifest.xml b/pump/carelevo/src/main/AndroidManifest.xml
new file mode 100644
index 000000000000..b20c45d1cc6a
--- /dev/null
+++ b/pump/carelevo/src/main/AndroidManifest.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/CarelevoPumpPlugin.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/CarelevoPumpPlugin.kt
new file mode 100644
index 000000000000..bc1e9ba34f9c
--- /dev/null
+++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/CarelevoPumpPlugin.kt
@@ -0,0 +1,762 @@
+package app.aaps.pump.carelevo
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import android.content.IntentFilter
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.ProcessLifecycleOwner
+import app.aaps.core.data.plugin.PluginType
+import app.aaps.core.data.pump.defs.ManufacturerType
+import app.aaps.core.data.pump.defs.PumpDescription
+import app.aaps.core.data.pump.defs.PumpType
+import app.aaps.core.data.pump.defs.TimeChangeType
+import app.aaps.core.interfaces.configuration.Config
+import app.aaps.core.interfaces.configuration.ExternalOptions
+import app.aaps.core.interfaces.logging.AAPSLogger
+import app.aaps.core.interfaces.logging.LTag
+import app.aaps.core.interfaces.plugin.PluginDescription
+import app.aaps.core.interfaces.profile.Profile
+import app.aaps.core.interfaces.profile.ProfileFunction
+import app.aaps.core.interfaces.protection.ProtectionCheck
+import app.aaps.core.interfaces.pump.BlePreCheck
+import app.aaps.core.interfaces.pump.DetailedBolusInfo
+import app.aaps.core.interfaces.pump.Pump
+import app.aaps.core.interfaces.pump.PumpEnactResult
+import app.aaps.core.interfaces.pump.PumpInsulin
+import app.aaps.core.interfaces.pump.PumpPluginBase
+import app.aaps.core.interfaces.pump.PumpProfile
+import app.aaps.core.interfaces.pump.PumpRate
+import app.aaps.core.interfaces.pump.PumpSync
+import app.aaps.core.interfaces.pump.defs.fillFor
+import app.aaps.core.interfaces.queue.CommandQueue
+import app.aaps.core.interfaces.queue.CustomCommand
+import app.aaps.core.interfaces.resources.ResourceHelper
+import app.aaps.core.interfaces.rx.AapsSchedulers
+import app.aaps.core.interfaces.sharedPreferences.SP
+import app.aaps.core.interfaces.ui.IconsProvider
+import app.aaps.core.interfaces.ui.UiInteraction
+import app.aaps.core.interfaces.utils.fabric.FabricPrivacy
+import app.aaps.core.keys.DoubleKey
+import app.aaps.core.keys.IntKey
+import app.aaps.core.keys.interfaces.Preferences
+import app.aaps.core.keys.interfaces.withEntries
+import app.aaps.core.ui.R as CoreUiR
+import app.aaps.core.ui.compose.icons.IcPluginCarelevo
+import app.aaps.core.ui.compose.preference.PreferenceSubScreenDef
+import app.aaps.pump.carelevo.ble.CarelevoBleSession
+import app.aaps.pump.carelevo.ble.data.BleState
+import app.aaps.pump.carelevo.ble.data.BondingState
+import app.aaps.pump.carelevo.ble.data.DeviceModuleState
+import app.aaps.pump.carelevo.ble.data.DeviceModuleState.Companion.codeToDeviceResult
+import app.aaps.pump.carelevo.ble.data.NotificationState
+import app.aaps.pump.carelevo.ble.data.PeripheralConnectionState
+import app.aaps.pump.carelevo.ble.data.ServiceDiscoverState
+import app.aaps.pump.carelevo.command.CarelevoActivationExecutor
+import app.aaps.pump.carelevo.command.CmdTimeZoneUpdate
+import app.aaps.pump.carelevo.command.CmdUpdateBuzzer
+import app.aaps.pump.carelevo.command.CmdUpdateExpiredThreshold
+import app.aaps.pump.carelevo.command.CmdUpdateLowInsulinNotice
+import app.aaps.pump.carelevo.command.CmdUpdateMaxBolus
+import app.aaps.pump.carelevo.common.CarelevoAlarmNotifier
+import app.aaps.pump.carelevo.common.CarelevoObserveReceiver
+import app.aaps.pump.carelevo.common.CarelevoPatch
+import app.aaps.pump.carelevo.common.keys.CarelevoBooleanPreferenceKey
+import app.aaps.pump.carelevo.common.keys.CarelevoIntPreferenceKey
+import app.aaps.pump.carelevo.common.model.PatchState
+import app.aaps.pump.carelevo.compose.CarelevoComposeContent
+import app.aaps.pump.carelevo.coordinator.CarelevoBasalProfileUpdateCoordinator
+import app.aaps.pump.carelevo.coordinator.CarelevoBolusCoordinator
+import app.aaps.pump.carelevo.coordinator.CarelevoConnectionCoordinator
+import app.aaps.pump.carelevo.coordinator.CarelevoSettingsCoordinator
+import app.aaps.pump.carelevo.coordinator.CarelevoTempBasalCoordinator
+import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo
+import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel
+import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel
+import app.aaps.pump.carelevo.domain.type.AlarmType.Companion.isCritical
+import app.aaps.pump.carelevo.ext.transformNotificationStringResources
+import io.reactivex.rxjava3.core.Completable
+import io.reactivex.rxjava3.core.Observable
+import io.reactivex.rxjava3.disposables.CompositeDisposable
+import io.reactivex.rxjava3.disposables.Disposable
+import io.reactivex.rxjava3.kotlin.plusAssign
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Provider
+import javax.inject.Singleton
+import kotlin.jvm.optionals.getOrNull
+
+@Singleton
+class CarelevoPumpPlugin @Inject constructor(
+ aapsLogger: AAPSLogger,
+ rh: ResourceHelper,
+ preferences: Preferences,
+ commandQueue: CommandQueue,
+ private val aapsSchedulers: AapsSchedulers,
+ private val sp: SP,
+ private val fabricPrivacy: FabricPrivacy,
+ private val profileFunction: ProfileFunction,
+ private val context: Context,
+ private val protectionCheck: ProtectionCheck,
+ private val blePreCheck: BlePreCheck,
+ private val iconsProvider: IconsProvider,
+ private val config: Config,
+ private val uiInteraction: UiInteraction,
+ private var pumpEnactResultProvider: Provider,
+ private val carelevoPatch: CarelevoPatch,
+
+ private val carelevoAlarmNotifier: CarelevoAlarmNotifier,
+ private val basalProfileUpdateCoordinator: CarelevoBasalProfileUpdateCoordinator,
+ private val bolusCoordinator: CarelevoBolusCoordinator,
+ private val tempBasalCoordinator: CarelevoTempBasalCoordinator,
+ private val connectionCoordinator: CarelevoConnectionCoordinator,
+ private val settingsCoordinator: CarelevoSettingsCoordinator,
+ private val activationExecutor: CarelevoActivationExecutor
+) : PumpPluginBase(
+ pluginDescription = PluginDescription()
+ .mainType(PluginType.PUMP)
+ .composeContent { _ ->
+ CarelevoComposeContent(
+ aapsLogger = aapsLogger,
+ carelevoAlarmNotifier = carelevoAlarmNotifier,
+ protectionCheck = protectionCheck,
+ blePreCheck = blePreCheck,
+ iconsProvider = iconsProvider,
+ config = config
+ )
+ }
+ .icon(IcPluginCarelevo)
+ .pluginName(R.string.carelevo)
+ .shortName(R.string.carelevo_shortname)
+ .description(R.string.carelevo_description),
+ ownPreferences = listOf(CarelevoBooleanPreferenceKey::class.java, CarelevoIntPreferenceKey::class.java),
+ aapsLogger, rh, preferences, commandQueue
+), Pump {
+
+ private var bleReceiverDisposable: Disposable? = null
+ private val pluginDisposable = CompositeDisposable()
+
+ private var _pumpType: PumpType = PumpType.CAREMEDI_CARELEVO
+ private val _pumpDescription = PumpDescription().fillFor(_pumpType)
+
+ private var scope: CoroutineScope? = null
+ private var lifecycleObserver: LifecycleEventObserver? = null
+
+ // Auto-resume watchdog cadence + safety margin: poll this often, and reconcile once the stop window has
+ // elapsed plus this margin (so the patch has actually auto-resumed before AAPS clears its suspend).
+ private val autoResumePollMs = 30_000L
+ private val autoResumeMarginMs = 30_000L
+
+ // The coroutine BLE session every pump op runs over. Field-injected — its @Inject constructor
+ // holds no eager BLE state.
+ @Inject lateinit var bleSession: CarelevoBleSession
+
+ override suspend fun onStart() {
+ super.onStart()
+
+ applyDefaultCageThresholdsIfNeeded()
+ registerPreferenceChangeObserver()
+ initializeOnStart()
+ registerBleReceiverIfNeeded()
+ startAlarmObserving()
+ startAutoResumeWatchdog()
+ // Consume patch-pushed frames (alarms, stop/basal-restart reports) whenever a session is open.
+ bleSession.unsolicitedHandler = carelevoPatch::onUnsolicited
+ }
+
+ override suspend fun onStop() {
+ super.onStop()
+ aapsLogger.debug(LTag.PUMP, "onStop called")
+ bleSession.unsolicitedHandler = null
+ settingsCoordinator.clearUserSettings(pluginDisposable)
+ pluginDisposable.clear()
+
+ // onStart/onStop run as separate, unjoined pluginScope coroutines (PluginBase), so a fast
+ // disable→re-enable can overlap them. Tear down only the generation captured here, and clear
+ // a field only if it still points at that generation — never clobber a scope/observer that a
+ // concurrent onStart may have just re-created (this fn suspends at withContext below).
+ val scopeToCancel = scope
+ val observerToRemove = lifecycleObserver
+
+ scopeToCancel?.cancel()
+ if (scope === scopeToCancel) scope = null
+
+ carelevoAlarmNotifier.stopObserving()
+
+ observerToRemove?.let { observer ->
+ withContext(Dispatchers.Main) {
+ ProcessLifecycleOwner.get().lifecycle.removeObserver(observer)
+ }
+ }
+ if (lifecycleObserver === observerToRemove) lifecycleObserver = null
+ }
+
+ private fun registerPreferenceChangeObserver() {
+ val newScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ scope = newScope
+
+ // Settings pushes go through the queue (connect-before-execute) like every other patch op — a
+ // direct fire-and-forget write would silently fail while the pump idle-disconnects between commands.
+ preferences.observe(DoubleKey.SafetyMaxBolus)
+ .drop(1)
+ .onEach { commandQueue.customCommand(CmdUpdateMaxBolus(preferences.get(DoubleKey.SafetyMaxBolus))) }
+ .launchIn(newScope)
+
+ preferences.observe(CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS)
+ .drop(1)
+ .onEach {
+ val hours = sp.getInt(CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS.key, 0)
+ commandQueue.customCommand(CmdUpdateExpiredThreshold(hours))
+ }
+ .launchIn(newScope)
+
+ preferences.observe(CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS)
+ .drop(1)
+ .onEach {
+ val hours = sp.getInt(CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS.key, 0)
+ // Zero = reminder off; skip enqueuing so the pump isn't reconnected just to no-op.
+ if (hours != 0) commandQueue.customCommand(CmdUpdateLowInsulinNotice(hours))
+ }
+ .launchIn(newScope)
+
+ preferences.observe(CarelevoBooleanPreferenceKey.CARELEVO_BUZZER_REMINDER)
+ .drop(1)
+ .onEach {
+ val on = sp.getBoolean(CarelevoBooleanPreferenceKey.CARELEVO_BUZZER_REMINDER.key, false)
+ commandQueue.customCommand(CmdUpdateBuzzer(on))
+ }
+ .launchIn(newScope)
+
+ // Deferred settings-sync recovery: a max-bolus / low-insulin change that couldn't reach the patch
+ // (changed while offline, or during a bolus) leaves a needXSyncPatch flag on the stored user
+ // settings; when the patch is booted again, push it through the queue. The combiner is PURE and the
+ // enqueue runs on IO.
+ pluginDisposable += Observable.combineLatest(
+ carelevoPatch.patchState,
+ carelevoPatch.infusionInfo,
+ carelevoPatch.userSettingInfo
+ ) { state, infusion, setting ->
+ computeSettingsSyncNeed(state.getOrNull(), infusion.getOrNull(), setting.getOrNull())
+ }
+ .distinctUntilChanged()
+ .subscribeOn(aapsSchedulers.io)
+ .observeOn(aapsSchedulers.io)
+ .subscribe { need ->
+ newScope.launch {
+ need.maxBolusDose?.let { commandQueue.customCommand(CmdUpdateMaxBolus(it)) }
+ need.lowInsulinHours?.let { commandQueue.customCommand(CmdUpdateLowInsulinNotice(it)) }
+ }
+ }
+ }
+
+ /**
+ * Auto-resume watchdog. A timed pump-stop makes the patch auto-resume at the stop duration, but with
+ * per-op BLE sessions there is no live link to receive the patch's stop-report push, and a 0x91 status
+ * read does not carry the suspend flag — so `isStopped` would stay latched until an app-initiated resume
+ * (issue #4993).
+ *
+ * Implemented as a resilient periodic check rather than a one-shot timer, so it is robust to the failure
+ * modes a single fire-and-forget timer has:
+ * - Each tick is independently guarded, so a transient reconcile failure neither kills the watchdog
+ * (it simply retries next tick) nor is permanent.
+ * - Elapsed time is measured from a DURABLE anchor ([CarelevoBasalInfusionInfoDomainModel.updatedAt],
+ * persisted at stop time and untouched by 0x91 status reads), so an app restart mid-stop still
+ * resumes on schedule instead of re-arming a full window.
+ * - A status read that momentarily resurrects `isStopped` is re-cleared on the next tick, because the
+ * anchor is stable.
+ */
+ private fun startAutoResumeWatchdog() {
+ scope?.launch {
+ while (isActive) {
+ try {
+ checkAutoResume()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Throwable) {
+ aapsLogger.error(LTag.PUMPCOMM, "auto-resume check failed (will retry)", e)
+ }
+ delay(autoResumePollMs)
+ }
+ }
+ }
+
+ /** One watchdog tick: reconcile a timed stop whose window has elapsed. Idempotent — safe to call repeatedly. */
+ private suspend fun checkAutoResume() {
+ val patchInfo = carelevoPatch.patchInfo.value?.getOrNull() ?: return
+ if (patchInfo.isStopped != true) return
+ val stopMinutes = patchInfo.stopMinutes ?: return
+ if (stopMinutes <= 0) return
+ // Durable stop-start anchor: basalInfusionInfo.updatedAt is set at persistStopped, survives an app
+ // restart (persisted), and is not moved by 0x91 status reads (which only rewrite patchInfo). Skip
+ // until infusion info is loaded.
+ val stopStartedAt = carelevoPatch.infusionInfo.value?.getOrNull()?.basalInfusionInfo?.updatedAt?.millis ?: return
+ val elapsedMs = System.currentTimeMillis() - stopStartedAt
+ if (elapsedMs < stopMinutes * 60_000L + autoResumeMarginMs) return
+ aapsLogger.info(LTag.PUMPCOMM, "auto-resume: ${stopMinutes}min stop elapsed (${elapsedMs / 1000}s) -> reconcile")
+ carelevoPatch.reconcileAutoResumed()
+ commandQueue.readStatus("Carelevo auto-resume")
+ }
+
+ private data class SettingsSyncNeed(val maxBolusDose: Double?, val lowInsulinHours: Int?)
+
+ /** Pure: which deferred patch settings still need pushing (flag set AND the patch is booted). */
+ private fun computeSettingsSyncNeed(
+ state: PatchState?,
+ infusion: CarelevoInfusionInfoDomainModel?,
+ setting: CarelevoUserSettingInfoDomainModel?
+ ): SettingsSyncNeed {
+ if (state !is PatchState.ConnectedBooted || setting == null) return SettingsSyncNeed(null, null)
+ // Max-bolus is a device safety cap — do not re-push mid-bolus (mirrors the use case's own guard).
+ // "No bolus running" requires BOTH channels idle (the deleted observeSyncPatch had this as `||`,
+ // which was true during a single-channel bolus and triggered a spurious mid-bolus reconnect).
+ val noBolusRunning = infusion?.extendBolusInfusionInfo == null && infusion?.immeBolusInfusionInfo == null
+ val maxBolusDose = if (setting.needMaxBolusDoseSyncPatch && noBolusRunning) setting.maxBolusDose ?: 0.0 else null
+ val lowInsulinHours = if (setting.needLowInsulinNoticeAmountSyncPatch) setting.lowInsulinNoticeAmount ?: 0 else null
+ return SettingsSyncNeed(maxBolusDose, lowInsulinHours)
+ }
+
+ private fun initializeOnStart() {
+ // Run initialization directly on start instead of gating it on EventAppInitialized.
+ // onStart() is now a suspend function launched fire-and-forget, so the (non-replayed)
+ // EventAppInitialized could fire before this subscription was registered — leaving the
+ // patch uninitialized and appearing deactivated after an app update (dev fixed the same
+ // race in eopatch). initPatchOnce() does not depend on other plugins; the basal profile is
+ // applied best-effort here and, if not yet available, is set later via setNewBasalProfile().
+ pluginDisposable += carelevoPatch.initPatchOnce()
+ .subscribeOn(aapsSchedulers.io)
+ .timeout(5, TimeUnit.SECONDS)
+ .onErrorComplete()
+ .doOnSubscribe { aapsLogger.debug(LTag.PUMPCOMM, "onStart: 1) initPatchOnce waiting") }
+ .doOnComplete { aapsLogger.debug(LTag.PUMPCOMM, "onStart: 1) initPatchOnce completed") }
+ .andThen(
+ Completable.fromAction {
+ val profile = runBlocking { profileFunction.getProfile() }
+ if (profile != null) {
+ carelevoPatch.setProfile(profile)
+ aapsLogger.debug(LTag.PUMPCOMM, "onStart: 3) setProfile done: $profile")
+ } else {
+ aapsLogger.debug(LTag.PUMPCOMM, "onStart: 3) profile not ready, deferring to setNewBasalProfile")
+ }
+ }
+ )
+ .subscribe(
+ { aapsLogger.debug(LTag.PUMPCOMM, "onStart: ALL COMPLETE") },
+ { e -> aapsLogger.error(LTag.PUMPCOMM, "onStart: chain error", e) }
+ )
+
+ pluginDisposable += carelevoPatch.patchInfo
+ .observeOn(aapsSchedulers.io)
+ .subscribe({
+ _reservoirLevel.value = PumpInsulin(it.getOrNull()?.insulinRemain ?: 0.0)
+ _batteryLevel.value = 0
+ }, fabricPrivacy::logException)
+ }
+
+ private fun registerBleReceiverIfNeeded() {
+ if (bleReceiverDisposable?.isDisposed == false) return
+
+ // Seed the adapter state so isBluetoothEnabled()/patchState resolve before the first broadcast —
+ // the receiver is now the ONLY btState source (adapter on/off; there is no resting link to track).
+ carelevoPatch.onBluetoothStateChanged(currentAdapterBleState())
+
+ // The emulated patch has no radio, so the state seeded above is the whole truth. Subscribing
+ // anyway would let the host's adapter being switched off report OFF and kill a live emulated
+ // session — the one thing emulation is supposed to be immune to.
+ if (isEmulating) return
+
+ bleReceiverDisposable = CarelevoObserveReceiver(context, createBluetoothIntentFilter())
+ .subscribe { intent ->
+ aapsLogger.debug(LTag.PUMPBTCOMM, "CarelevoObserveReceiver called: ${intent.action}")
+ if (intent.action == BluetoothAdapter.ACTION_STATE_CHANGED) {
+ val value = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
+ if (value in setOf(BluetoothAdapter.STATE_ON, BluetoothAdapter.STATE_OFF, BluetoothAdapter.STATE_TURNING_ON, BluetoothAdapter.STATE_TURNING_OFF)) {
+ carelevoPatch.onBluetoothStateChanged(adapterBleState(value.codeToDeviceResult()))
+ }
+ }
+ }
+
+ bleReceiverDisposable?.let { pluginDisposable.add(it) }
+ }
+
+ /**
+ * The emulated patch is reachable regardless of the host adapter, so report ON without asking it.
+ * Without this the whole driver — every `isBluetoothEnabled()` gate in the coordinators and view
+ * models — refuses before a request ever reaches the emulated transport. Mirrors the way Dana
+ * skips its BLE pre-check while emulating; Dana needs no equivalent here only because it never
+ * reads the adapter's enabled state at all.
+ */
+ private fun currentAdapterBleState(): BleState {
+ val enabled = isEmulating ||
+ (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter?.isEnabled == true
+ return adapterBleState(if (enabled) DeviceModuleState.DEVICE_STATE_ON else DeviceModuleState.DEVICE_STATE_OFF)
+ }
+
+ private val isEmulating: Boolean get() = config.isEnabled(ExternalOptions.EMULATE_CARELEVO)
+
+ /** Adapter-level state only — bond/discovery/notification fields are per-session now and never tracked. */
+ private fun adapterBleState(enabled: DeviceModuleState): BleState = BleState(
+ isEnabled = enabled,
+ isBonded = BondingState.BOND_NONE,
+ isServiceDiscovered = ServiceDiscoverState.DISCOVER_STATE_NONE,
+ isConnected = PeripheralConnectionState.CONN_STATE_NONE,
+ isNotificationEnabled = NotificationState.NOTIFICATION_NONE
+ )
+
+ private fun createBluetoothIntentFilter(): IntentFilter {
+ return IntentFilter().apply {
+ addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
+ }
+ }
+
+ private fun applyDefaultCageThresholdsIfNeeded() {
+ if (sp.getBoolean(CarelevoBooleanPreferenceKey.CARELEVO_CAGE_DEFAULT_APPLIED.key, false)) return
+
+ sp.edit {
+ putInt(IntKey.OverviewCageWarning.key, 96)
+ putInt(IntKey.OverviewCageCritical.key, 168)
+ putBoolean(CarelevoBooleanPreferenceKey.CARELEVO_CAGE_DEFAULT_APPLIED.key, true)
+ }
+ }
+
+ private suspend fun startAlarmObserving() {
+ aapsLogger.debug(LTag.NOTIFICATION, "startAlarmObserving:: onStart")
+
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_START) {
+ aapsLogger.debug(LTag.NOTIFICATION, "Foreground transition -> refresh alarms")
+ carelevoAlarmNotifier.refreshAlarms()
+ }
+ }
+ lifecycleObserver = observer
+ withContext(Dispatchers.Main) {
+ ProcessLifecycleOwner.get().lifecycle.addObserver(observer)
+ }
+
+ carelevoAlarmNotifier.startObserving { alarms ->
+ aapsLogger.debug(LTag.NOTIFICATION, "observe alarms size=${alarms.size}, $alarms")
+ handleAlarms(alarms)
+ }
+ }
+
+ // Critical alarms already escalated to the global alarm (so a re-emission of the same list
+ // doesn't re-fire the sound). Pruned to the currently-active set on every pass.
+ private val globallyAlarmedIds = mutableSetOf()
+
+ private fun handleAlarms(alarms: List) {
+ aapsLogger.debug(LTag.NOTIFICATION, "startAlarmObserving handleAlarms:: $alarms")
+ globallyAlarmedIds.retainAll(alarms.map { it.alarmId }.toSet())
+ if (alarms.isEmpty()) return
+
+ val critical = alarms.filter { it.alarmType.isCritical() }
+ if (critical.isNotEmpty()) {
+ // filter-with-add: keeps only the not-yet-escalated alarms AND marks them escalated.
+ val fresh = critical.filter { globallyAlarmedIds.add(it.alarmId) }
+ if (carelevoAlarmNotifier.alarmHostActive) {
+ // The in-app host is mounted — it presents the full-screen alarm and starts the
+ // sound itself (CarelevoAlarmHost/CarelevoAlarmViewModel).
+ aapsLogger.debug(LTag.NOTIFICATION, "critical alarm handled by compose host")
+ } else if (fresh.isNotEmpty()) {
+ // No in-app surface (backgrounded, or user on another screen): fire the global AAPS
+ // alarm (sound + full-screen intent) — a critical patch alarm must NEVER depend on
+ // the user having the Carelevo screen open.
+ val first = fresh.first()
+ uiInteraction.runAlarm(
+ status = rh.gs(first.cause.transformNotificationStringResources().first),
+ title = rh.gs(R.string.carelevo),
+ soundId = CoreUiR.raw.error
+ )
+ }
+ } else {
+ carelevoAlarmNotifier.showTopNotification(alarms)
+ }
+ }
+
+ override fun getPreferenceScreenContent() = PreferenceSubScreenDef(
+ key = "carelevo_settings",
+ titleResId = R.string.carelevo,
+ items = listOf(
+ CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS.withEntries(
+ (20..50 step 5).associateWith { "$it U" }
+ ),
+ CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS.withEntries(
+ (24..167 step 1).associateWith { "$it ${rh.gs(app.aaps.core.interfaces.R.string.hours)}" }
+ ),
+ CarelevoBooleanPreferenceKey.CARELEVO_BUZZER_REMINDER
+ ),
+ icon = pluginDescription.icon
+ )
+
+ // A patch is "configured" once activation has persisted a patch record (patchInfo present) — the
+ // same signal isInitialized() gates on first, so isInitialized() implies isConfigured() and the
+ // contract invariant !isConfigured() => !isInitialized() holds by construction. Intentionally
+ // independent of BLE: an attached-but-disconnected patch is still configured and may be delivering.
+ override fun isConfigured(): Boolean =
+ carelevoPatch.patchInfo.value?.getOrNull() != null
+
+ override fun isInitialized(): Boolean {
+ return connectionCoordinator.isInitialized()
+ }
+
+ override fun isSuspended(): Boolean {
+ // Real delivery-suspend (pump stopped by the user), NOT the BLE connection state. Otherwise, a
+ // normal idle disconnect (NotConnectedBooted, now that disconnect() actually disconnects) would
+ // be reported as suspended and surface as an error/suspended icon on the overview.
+ return carelevoPatch.patchInfo.value?.getOrNull()?.isStopped ?: false
+ }
+
+ override fun isBusy(): Boolean {
+ return false
+ }
+
+ override fun isConnected(): Boolean {
+ return connectionCoordinator.isConnected()
+ }
+
+ override fun isConnecting(): Boolean {
+ return connectionCoordinator.isConnecting()
+ }
+
+ override fun isHandshakeInProgress(): Boolean {
+ return false
+ }
+
+ override fun connect(reason: String) {
+ connectionCoordinator.connect(reason)
+ }
+
+ override fun disconnect(reason: String) {
+ connectionCoordinator.disconnect(reason)
+ }
+
+ override fun stopConnecting() {
+ connectionCoordinator.stopConnecting()
+ }
+
+ override suspend fun getPumpStatus(reason: String) = readInfusionInfo()
+
+ /**
+ * Status read over the [app.aaps.pump.carelevo.ble.BleClient] stack: read Infusion Info (0x31 → 0x91)
+ * on the session's own connection and persist it through `carelevoPatch.applyInfusionInfoReport` —
+ * reservoir/pump-state/totals all refresh. Runs on
+ * the QueueWorker thread, blocked inside this status read.
+ */
+ private suspend fun readInfusionInfo() {
+ val address = carelevoPatch.getPatchInfoAddress() ?: run {
+ aapsLogger.warn(LTag.PUMPCOMM, "newBle.readInfusionInfo skipped: no patch address")
+ return
+ }
+ try {
+ val info = bleSession.readInfusionInfo(address)
+ carelevoPatch.applyInfusionInfoReport(
+ runningMinutes = info.runningMinutes,
+ remains = info.insulinRemaining,
+ infusedTotalBasalAmount = info.infusedTotalBasalAmount,
+ infusedTotalBolusAmount = info.infusedTotalBolusAmount,
+ pumpStateRaw = info.pumpStateRaw,
+ modeRaw = info.modeRaw
+ )
+ _lastDataTime.value = System.currentTimeMillis()
+ aapsLogger.info(
+ LTag.PUMPCOMM,
+ "newBle.readInfusionInfo OK remains=${info.insulinRemaining} basal=${info.infusedTotalBasalAmount} " +
+ "bolus=${info.infusedTotalBolusAmount} pumpState=${info.pumpStateRaw} mode=${info.modeRaw} running=${info.runningMinutes}"
+ )
+ } catch (e: Throwable) {
+ aapsLogger.error(LTag.PUMPCOMM, "newBle.readInfusionInfo FAILED", e)
+ }
+ }
+
+ override suspend fun setNewBasalProfile(profile: PumpProfile): PumpEnactResult {
+ aapsLogger.debug(LTag.PUMP, "setNewBasalProfile called - ${carelevoPatch.resolvePatchState()}")
+ _lastDataTime.value = System.currentTimeMillis()
+ // PROFILE_SET_OK / FAILED_UPDATE_PROFILE are posted centrally by the CommandQueue from the returned
+ // success/enacted (unified across pumps) — this method only returns the right values.
+ val result = when (carelevoPatch.resolvePatchState()) {
+ is PatchState.NotConnectedNotBooting -> {
+ // No active patch yet — store the profile for when a patch is activated. A deferred write,
+ // not an actual change, so enacted=false (no PROFILE_SET_OK); success=true keeps the
+ // not-ready case out of the failure alarm (matches the other queue-managed pumps).
+ carelevoPatch.setProfile(profile)
+ pumpEnactResultProvider.get().success(true).enacted(false)
+ }
+
+ else -> {
+ // Patch present. setNewBasalProfile runs on the queue worker AFTER the queue guaranteed a
+ // fully-connected link, so this is the live push path even if the cached patchState briefly
+ // reads NotConnectedBooted. updateBasalProfile returns the real success/enacted result.
+ updateBasalProfile(profile)
+ }
+ }
+ aapsLogger.debug(LTag.PUMP, "result success=${result.success} enacted=${result.enacted} comment=${result.comment}")
+ return result
+ }
+
+ private fun updateBasalProfile(profile: Profile): PumpEnactResult {
+ return basalProfileUpdateCoordinator.updateBasalProfile(
+ profile = profile,
+ cancelExtendedBolus = {
+ bolusCoordinator.cancelExtendedBolus(
+ serialNumber = serialNumber(),
+ onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() }
+ )
+ },
+ cancelTempBasal = {
+ tempBasalCoordinator.cancelTempBasal(
+ serialNumber = serialNumber(),
+ onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() }
+ )
+ },
+ onProfileUpdated = { updatedProfile ->
+ _lastDataTime.value = System.currentTimeMillis()
+ carelevoPatch.setProfile(updatedProfile)
+ }
+ )
+ }
+
+ override fun isThisProfileSet(profile: PumpProfile): Boolean {
+ return carelevoPatch.checkIsSameProfile(profile)
+ }
+
+ // Activation ops (safety check, …) are queued so they get the CommandQueue's managed
+ // connect-before-execute / reconnect lifecycle instead of a direct BLE call.
+ override fun executeCustomCommand(customCommand: CustomCommand): PumpEnactResult? =
+ activationExecutor.execute(customCommand)
+
+ private val _lastDataTime = MutableStateFlow(0L)
+ override val lastDataTime: StateFlow = _lastDataTime.asStateFlow()
+
+ override val lastBolusTime: StateFlow
+ get() = bolusCoordinator.lastBolusTime
+
+ override val lastBolusAmount: StateFlow
+ get() = bolusCoordinator.lastBolusAmount
+
+ override val baseBasalRate: PumpRate
+ get() = PumpRate(carelevoPatch.profile.value?.getOrNull()?.getBasal() ?: 0.0)
+
+ private val _reservoirLevel = MutableStateFlow(PumpInsulin(0.0))
+ override val reservoirLevel: StateFlow = _reservoirLevel
+
+ private val _batteryLevel = MutableStateFlow(null)
+ override val batteryLevel: StateFlow = _batteryLevel
+
+ // start imme bolus infusion
+ override suspend fun deliverTreatment(detailedBolusInfo: DetailedBolusInfo): PumpEnactResult {
+ return bolusCoordinator.deliverTreatment(
+ detailedBolusInfo = detailedBolusInfo,
+ serialNumber = serialNumber(),
+ onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() },
+ pluginDisposable = pluginDisposable
+ )
+ }
+
+ // cancel imme bolus
+ override fun stopBolusDelivering() {
+ bolusCoordinator.cancelImmediateBolus(
+ serialNumber = serialNumber(),
+ onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() }
+ )
+ }
+
+ // enforceNew is intentionally ignored on all three TBR entry points: with per-op sessions there
+ // is no resting link whose state a "keep the running TBR" optimization could trust, so Carelevo
+ // always re-programs the requested TBR (the Pump contract explicitly allows this; the flag is
+ // only an optimization hint).
+ override suspend fun setTempBasalAbsolute(absoluteRate: Double, durationInMinutes: Int, enforceNew: Boolean, tbrType: PumpSync.TemporaryBasalType): PumpEnactResult {
+ return tempBasalCoordinator.setTempBasalAbsolute(
+ absoluteRate = absoluteRate,
+ durationInMinutes = durationInMinutes,
+ tbrType = tbrType,
+ serialNumber = serialNumber(),
+ onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() }
+ )
+ }
+
+ override suspend fun setTempBasalPercent(percent: Int, durationInMinutes: Int, enforceNew: Boolean, tbrType: PumpSync.TemporaryBasalType): PumpEnactResult {
+ return tempBasalCoordinator.setTempBasalPercent(
+ percent = percent,
+ durationInMinutes = durationInMinutes,
+ tbrType = tbrType,
+ serialNumber = serialNumber(),
+ onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() }
+ )
+ }
+
+ override suspend fun cancelTempBasal(enforceNew: Boolean): PumpEnactResult {
+ return tempBasalCoordinator.cancelTempBasal(
+ serialNumber = serialNumber(),
+ onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() }
+ )
+ }
+
+ override suspend fun setExtendedBolus(insulin: Double, durationInMinutes: Int): PumpEnactResult {
+ return bolusCoordinator.setExtendedBolus(
+ insulin = insulin,
+ durationInMinutes = durationInMinutes,
+ serialNumber = serialNumber()
+ )
+ }
+
+ override suspend fun cancelExtendedBolus(): PumpEnactResult {
+ return bolusCoordinator.cancelExtendedBolus(
+ serialNumber = serialNumber(),
+ onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() }
+ )
+ }
+
+ override fun manufacturer(): ManufacturerType {
+ return ManufacturerType.CareMedi
+ }
+
+ override fun model(): PumpType {
+ return PumpType.CAREMEDI_CARELEVO
+ }
+
+ override fun serialNumber(): String {
+ return carelevoPatch.patchInfo.value?.getOrNull()?.manufactureNumber ?: ""
+ }
+
+ override val pumpDescription: PumpDescription
+ get() = _pumpDescription
+
+ override val isFakingTempsByExtendedBoluses: Boolean
+ get() = false
+
+ override suspend fun loadTDDs(): PumpEnactResult {
+ return pumpEnactResultProvider.get()
+ }
+
+ override fun canHandleDST(): Boolean {
+ return false
+ }
+
+ override suspend fun timezoneOrDSTChanged(timeChangeType: TimeChangeType) {
+ super.timezoneOrDSTChanged(timeChangeType)
+ // Route through the queue (connect-before-execute) like every other patch op. The pump
+ // idle-disconnects between commands, so a direct fire-and-forget write would silently fail
+ // while resting. Skip ONLY when no patch is active; if the patch is present but its insulinRemain
+ // is not yet known (e.g. before the first status read after reconnect) still push with 0 rather
+ // than dropping the clock update (original `?: 0` semantics).
+ val patchInfo = carelevoPatch.patchInfo.value?.getOrNull() ?: return
+ val insulin = patchInfo.insulinRemain?.toInt() ?: 0
+ val result = commandQueue.customCommand(CmdTimeZoneUpdate(insulinAmount = insulin))
+ if (result.success) _lastDataTime.value = System.currentTimeMillis()
+ }
+}
diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/BleClient.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/BleClient.kt
new file mode 100644
index 000000000000..733759b1f7f7
--- /dev/null
+++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/BleClient.kt
@@ -0,0 +1,182 @@
+package app.aaps.pump.carelevo.ble
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharedFlow
+
+/**
+ * Request/response client over a [app.aaps.pump.carelevo.ble.gatt.GattConnection].
+ *
+ * Serializes outgoing writes with a mutex so at most one request is in flight at any
+ * moment, and correlates each request with the peripheral's notification by opcode
+ * pair (and, for the immediate-bolus command, by echoed actionId).
+ *
+ * Notifications arriving outside any active request are forwarded to
+ * [unsolicitedEvents] so alarm/status-report consumers can subscribe independently.
+ */
+interface BleClient {
+
+ /**
+ * Send [cmd], suspend until the matching response notification arrives, then
+ * return the decoded response.
+ *
+ * Does **not** impose a timeout — callers wrap in `withTimeout(...)` if a
+ * deadline is wanted. Different pump operations have legitimately different
+ * deadlines (e.g. safety-check is ~100 s, most requests are ~3 s), so the
+ * policy lives at the caller.
+ *
+ * Throws:
+ * - [BleDisconnectedException] if the GATT connection drops while the request
+ * is pending.
+ * - [app.aaps.pump.carelevo.ble.gatt.GattWriteException] if the BLE write fails.
+ * - [kotlinx.coroutines.CancellationException] on coroutine cancellation
+ * (including `withTimeout`).
+ */
+ suspend fun request(cmd: BleCommand): R
+
+ /**
+ * Send [cmd], suspend until **all** of its [BleMultiCommand.expectedResponseOpcodes]
+ * have arrived (in any order), then decode them together.
+ *
+ * For requests the pump answers with more than one notification — e.g. Patch
+ * Information Inquiry (`0x33`), answered by RPT1 (`0x93`) + RPT2 (`0x94`). The first
+ * notification seen for each expected opcode wins; a duplicate or any non-matching
+ * notification (alarm, status push) falls through to [unsolicitedEvents].
+ *
+ * Same deadline policy as [request] — no built-in timeout, wrap in `withTimeout(...)`.
+ * A single deadline wraps the whole call (not per-response).
+ *
+ * Throws [BleDisconnectedException] / [app.aaps.pump.carelevo.ble.gatt.GattWriteException] /
+ * [kotlinx.coroutines.CancellationException] on the same conditions as [request].
+ */
+ suspend fun requestMultiple(cmd: BleMultiCommand): R
+
+ /**
+ * Send [cmd] and return a cold [Flow] of every decoded notification matching
+ * [BleStreamCommand.expectedResponseOpcode], completing when [BleStreamCommand.isTerminal]
+ * is `true` for a decoded response.
+ *
+ * For streaming/progress requests — e.g. Safety Check (`0x12`): the pump emits
+ * repeated progress reports then a terminal SUCCESS/error, all on `0x72`. The
+ * request write happens when the returned flow is collected; the request slot is
+ * held for the whole stream (single-in-flight), so a concurrent [request] waits
+ * until the stream terminates.
+ *
+ * The flow throws [BleDisconnectedException] if the link drops mid-stream. As with
+ * [request], impose a deadline by wrapping collection in `withTimeout(...)`.
+ *
+ * **Do not call any [BleClient] method from inside this flow's collector.** The
+ * request slot (a non-reentrant mutex) is held for the whole stream, so a reentrant
+ * `request`/`requestMultiple`/`requestStream` issued from the `collect {}` block
+ * self-deadlocks. Collect the stream to completion first, then issue the next
+ * request outside the collector.
+ */
+ fun requestStream(cmd: BleStreamCommand): Flow
+
+ /**
+ * Hot stream of notifications that did not match any active request — alarms,
+ * status pushes, cannula-insertion events, etc. Subscribers see only events
+ * emitted after they subscribe (no replay). Independent from [request] —
+ * alarm handling and request/response correlation do not interfere.
+ */
+ val unsolicitedEvents: SharedFlow
+}
+
+/**
+ * A single CareLevo protocol request, paired with the Kotlin type of its expected
+ * response. Implementations encode the outgoing byte-array, declare the opcode
+ * pair, and decode the response bytes back into a typed model.
+ */
+interface BleCommand {
+
+ /** Opcode byte (position 0) of the outgoing write. */
+ val requestOpcode: Byte
+
+ /** Opcode byte (position 0) that the peripheral is expected to reply with. */
+ val expectedResponseOpcode: Byte
+
+ /**
+ * Optional correlation byte at position 1 of the response. Non-null for commands
+ * that echo a caller-chosen identifier (e.g. immediate bolus `actionId`). When
+ * non-null, [BleClient] only accepts responses whose byte-1 equals this value —
+ * belt-and-braces against a stale or unsolicited message with the same opcode.
+ */
+ val correlationByte: Byte? get() = null
+
+ /** Full outgoing payload, starting with [requestOpcode] at byte 0. */
+ fun encode(): ByteArray
+
+ /** Parse the full response payload (byte 0 is [expectedResponseOpcode]). */
+ fun decode(responsePayload: ByteArray): R
+}
+
+/**
+ * A CareLevo request that a single write answers with **more than one** notification —
+ * e.g. Patch Information Inquiry (`0x33`) → RPT1 (`0x93`) + RPT2 (`0x94`).
+ *
+ * [BleClient.requestMultiple] collects the first notification seen for each opcode in
+ * [expectedResponseOpcodes] (arrival order irrelevant) and completes once every opcode
+ * in the set has been received, then hands the collected payloads to [decode].
+ */
+interface BleMultiCommand {
+
+ /** Opcode byte (position 0) of the outgoing write. */
+ val requestOpcode: Byte
+
+ /**
+ * The full set of response opcodes this request produces. The request completes
+ * only once one notification for **each** opcode has arrived. Must be non-empty.
+ */
+ val expectedResponseOpcodes: Set
+
+ /** Full outgoing payload, starting with [requestOpcode] at byte 0. */
+ fun encode(): ByteArray
+
+ /**
+ * Parse the collected responses, keyed by their opcode (byte 0 of each payload).
+ * Every key in [expectedResponseOpcodes] is guaranteed present.
+ */
+ fun decode(responses: Map): R
+}
+
+/**
+ * A CareLevo request whose response is a **stream** — repeated notifications on one
+ * opcode, terminated by a distinguished response. E.g. Safety Check (`0x12`): the pump
+ * emits progress reports (`REP_REQUEST`) then a terminal SUCCESS/error, all on `0x72`.
+ *
+ * [BleClient.requestStream] decodes each notification matching [expectedResponseOpcode]
+ * and emits it; the stream completes when [isTerminal] returns `true` for a decoded
+ * response. Non-matching notifications fall through to [BleClient.unsolicitedEvents].
+ */
+interface BleStreamCommand {
+
+ /** Opcode byte (position 0) of the outgoing write. */
+ val requestOpcode: Byte
+
+ /** Opcode byte (position 0) each streamed response notification carries. */
+ val expectedResponseOpcode: Byte
+
+ /** Full outgoing payload, starting with [requestOpcode] at byte 0. */
+ fun encode(): ByteArray
+
+ /** Parse one streamed response payload (byte 0 is [expectedResponseOpcode]). */
+ fun decode(responsePayload: ByteArray): R
+
+ /** `true` when [response] is the final one — the stream completes after emitting it. */
+ fun isTerminal(response: R): Boolean
+}
+
+/** Marker type for decoded CareLevo responses. */
+interface BleResponse
+
+/**
+ * A notification that did not match any active request — an alarm, status report,
+ * or other peripheral-initiated push. Carries the raw payload; higher layers parse
+ * it via the existing parser registry.
+ */
+data class UnsolicitedMessage(
+ val opcode: Byte,
+ val payload: ByteArray
+)
+
+/** Thrown by a pending [BleClient.request] if the GATT connection drops. */
+class BleDisconnectedException(message: String = "connection dropped") : RuntimeException(message)
diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/BleClientImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/BleClientImpl.kt
new file mode 100644
index 000000000000..33d2e6c766fb
--- /dev/null
+++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/BleClientImpl.kt
@@ -0,0 +1,320 @@
+package app.aaps.pump.carelevo.ble
+
+import app.aaps.pump.carelevo.ble.gatt.GattConnState
+import app.aaps.pump.carelevo.ble.gatt.GattConnection
+import app.aaps.pump.carelevo.ble.gatt.GattEvent
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.util.UUID
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Production implementation of [BleClient].
+ *
+ * Correlation rules (see [BleClientContractTest] in tests for the full spec):
+ * - A single [requestMutex] serializes outgoing requests — at most one in flight,
+ * whether single-response, multi-response, or streaming.
+ * - Before calling [GattConnection.writeCharacteristic], the client registers a
+ * [Waiter] describing the response(s) it expects. This ordering eliminates the
+ * response-during-write race.
+ * - A long-lived collector on the injected [scope] reads [GattConnection.events] and
+ * offers each [GattEvent.Notification] to the active [Waiter]; anything the waiter
+ * does not consume is forwarded to [_unsolicitedEvents].
+ * - A [GattEvent.ConnectionStateChanged] with [GattConnState.DISCONNECTED] aborts the
+ * pending waiter (with [BleDisconnectedException]) atomically.
+ *
+ * The event collector is the single point through which every request completes, so it
+ * is hardened to survive any callback failure: the [waiter] is held in an
+ * [AtomicReference] (so a disconnect can atomically take-and-abort whatever request is
+ * current without clobbering a concurrently-registered one), consumer-supplied
+ * [BleStreamCommand.decode]/[BleStreamCommand.isTerminal] are guarded, and the collect
+ * body is wrapped so no unforeseen throw can permanently stop routing.
+ */
+class BleClientImpl(
+ private val gatt: GattConnection,
+ private val writeUuid: UUID,
+ @Suppress("unused")
+ private val notifyUuid: UUID,
+ scope: CoroutineScope
+) : BleClient {
+
+ // DROP_OLDEST + tryEmit: unsolicited fan-out (alarms/status are lossy pushes) can
+ // never back-pressure the event collector and stall in-flight request correlation.
+ private val _unsolicitedEvents = MutableSharedFlow(
+ extraBufferCapacity = 64,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ override val unsolicitedEvents: SharedFlow = _unsolicitedEvents.asSharedFlow()
+
+ private val requestMutex = Mutex()
+
+ /**
+ * The response(s) the in-flight request is waiting for. Exactly one is active at a
+ * time (guarded by [requestMutex] on the caller side). Held in an [AtomicReference]
+ * because the event collector aborts it on disconnect **outside** the mutex (a
+ * stream holds the mutex for its whole 100-210 s duration, so the collector must
+ * stay lock-free): [AtomicReference.getAndSet] takes-and-nulls atomically so a
+ * disconnect can never null a freshly-registered waiter without also aborting it.
+ */
+ private val waiterRef = AtomicReference(null)
+
+ private sealed interface Waiter {
+
+ /** Try to consume the notification [payload] (opcode is [opcode]). Return `true` iff consumed. */
+ fun offer(opcode: Byte, payload: ByteArray): Boolean
+
+ /** Abort this pending request (link dropped, or an unforeseen router failure). */
+ fun abort(cause: Throwable)
+
+ /**
+ * Called by the requesting coroutine when it unwinds WITHOUT having consumed the
+ * response (cancellation/timeout). After this returns, [offer] consumes nothing
+ * more, so late frames fall through to [unsolicitedEvents]. Returns any frame(s)
+ * this waiter had already absorbed but never delivered to the caller, so the
+ * caller can re-route them to unsolicited instead of silently swallowing a
+ * response the pump really sent (e.g. the ack of a bolus that DID start while
+ * our side timed out).
+ */
+ fun abandon(): List
+ }
+
+ private class SingleWaiter(
+ private val expectedOpcode: Byte,
+ private val correlationByte: Byte?,
+ private val deferred: CompletableDeferred
+ ) : Waiter {
+
+ override fun offer(opcode: Byte, payload: ByteArray): Boolean {
+ if (opcode != expectedOpcode) return false
+ val correlationOk = correlationByte == null ||
+ (payload.size > 1 && payload[1] == correlationByte)
+ if (!correlationOk) return false
+ // complete() returns false if already settled — a late duplicate (already
+ // completed) or an abandoned caller (deferred cancelled by abandon()) — so
+ // routeNotification forwards the frame to unsolicited either way.
+ return deferred.complete(payload)
+ }
+
+ override fun abort(cause: Throwable) {
+ deferred.completeExceptionally(cause)
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ override fun abandon(): List {
+ // cancel() races atomically against offer()'s complete(): exactly one wins.
+ // If cancel wins, a concurrent/late offer returns false and the frame is
+ // routed to unsolicited by the collector. If complete already won, the
+ // payload sits in a deferred nobody will await — hand it back for re-routing.
+ deferred.cancel()
+ return if (deferred.isCompleted && !deferred.isCancelled) listOf(deferred.getCompleted()) else emptyList()
+ }
+ }
+
+ private class MultiWaiter(
+ private val remaining: MutableSet,
+ private val deferred: CompletableDeferred