Skip to content

Commit c23d739

Browse files
CAPTCGclaude
andcommitted
Sync Eversense plugin to latest from AndroidAPSEversenseCoexist
Full sync of all Eversense-related code: - Complete eversense module: build.gradle.kts, manifest, proguard, all source files (callbacks, enums, models, e3/e365 packets, utils, crypto) - E365 cloud upload: EversenseHttp365Util with token management, uploadGlucoseReadings (base64 EssentialLog, bare JSON array body) - BLE reconnect exponential backoff: 5s→10s→20s→40s→60s cap for GATT errors; 30s for status-19 (transmitter rejection); 5s for clean disconnect - EversensePlugin: cloud upload toggle, toast notifications, E3/E365 section visibility gating, duplicate watcher registration fix - Unit tests: EversenseHttp365UtilTest (13 tests via MockWebServer), CalibrationPacketTest (29 tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f6b7fd4 commit c23d739

58 files changed

Lines changed: 2455 additions & 434 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

plugins/eversense/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/build

plugins/eversense/build.gradle.kts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,28 @@
11
plugins {
22
alias(libs.plugins.android.library)
33
alias(libs.plugins.ksp)
4+
5+
id("kotlin-android")
46
id("kotlinx-serialization")
57
id("android-module-dependencies")
68
id("test-module-dependencies")
79
}
10+
811
android {
912
namespace = "com.nightscout.eversense"
1013
}
14+
1115
dependencies {
1216
api(libs.androidx.core)
13-
api(platform(libs.kotlinx.serialization.bom))
1417
api(libs.kotlinx.serialization.json)
18+
1519
api(libs.org.slf4j.api)
1620
api(libs.com.github.tony19.logback.android)
21+
1722
implementation("org.bouncycastle:bcpkix-jdk18on:1.81")
1823
implementation("org.bouncycastle:bcprov-jdk18on:1.81")
24+
25+
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
1926
}
27+
28+

plugins/eversense/consumer-rules.pro

Whitespace-only changes.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Add project specific ProGuard rules here.
2+
# You can control the set of applied configuration files using the
3+
# proguardFiles setting in build.gradle.
4+
#
5+
# For more details, see
6+
# http://developer.android.com/guide/developing/tools/proguard.html
7+
8+
# If your project uses WebView with JS, uncomment the following
9+
# and specify the fully qualified class name to the JavaScript interface
10+
# class:
11+
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12+
# public *;
13+
#}
14+
15+
# Uncomment this to preserve the line number information for
16+
# debugging stack traces.
17+
#-keepattributes SourceFile,LineNumberTable
18+
19+
# If you keep the line number information, uncomment this to
20+
# hide the original source file name.
21+
#-renamesourcefileattribute SourceFile
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
4+
<uses-permission android:name="android.permission.BLUETOOTH" />
5+
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
6+
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
7+
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
8+
9+
<application>
10+
</application>
11+
12+
</manifest>

plugins/eversense/src/main/kotlin/com/nightscout/eversense/EversenseCGMPlugin.kt

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,7 @@ class EversenseCGMPlugin {
4646
gattCallback = EversenseGattCallback(this, preference)
4747
}
4848

49-
// FIX 3: setSmoothing now returns Boolean and logs on failure.
50-
fun setCoexistenceMode(enabled: Boolean) {
51-
gattCallback?.coexistenceMode = enabled
52-
EversenseLogger.info(TAG, "Coexistence mode: $enabled")
53-
}
54-
55-
fun setSmoothing(value: Boolean): Boolean {
49+
fun setSmoothing(value: Boolean): Boolean {
5650
val state = getCurrentState() ?: run {
5751
EversenseLogger.error(TAG, "Cannot set smoothing: current state is null. Has setContext been called?")
5852
return false
@@ -73,6 +67,7 @@ class EversenseCGMPlugin {
7367
}
7468

7569
fun isConnected(): Boolean = gattCallback?.isConnected() ?: false
70+
fun is365(): Boolean = gattCallback?.is365() ?: false
7671

7772
fun getCurrentState(): EversenseState? {
7873
val preferences = preferences ?: run {

plugins/eversense/src/main/kotlin/com/nightscout/eversense/EversenseGattCallback.kt

Lines changed: 54 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class EversenseGattCallback(
5151
private const val magicDescriptorUUID = "00002902-0000-1000-8000-00805f9b34fb"
5252

5353
private const val WRITE_TIMEOUT_MS = 5000L
54+
private const val CALIBRATION_TIMEOUT_MS = 15000L
5455
}
5556

5657
// FIX 1: Dedicated BLE executor for callbacks; separate network executor for HTTP calls
@@ -77,19 +78,17 @@ class EversenseGattCallback(
7778
@Volatile
7879
private var connected: Boolean = false
7980

80-
// Tracks consecutive failed connection attempts to detect transmitter placement issues
81+
// Tracks consecutive status-19 failures to detect transmitter placement issues
8182
@Volatile
8283
private var failedConnectionAttempts: Int = 0
8384
private val PLACEMENT_WARNING_THRESHOLD = 3
8485

85-
// When true, AAPS disconnects after each sync to give the official Eversense app
86-
// a 2-minute window to connect and upload data to the cloud. Reconnects automatically.
86+
// Tracks consecutive general reconnect attempts (reset on successful connection).
87+
// Used to compute exponential backoff so AAPS retries quickly after boot (when the
88+
// official Eversense app temporarily holds the BLE connection) and backs off for
89+
// sustained failures to avoid draining the battery.
8790
@Volatile
88-
var coexistenceMode: Boolean = false
89-
90-
// True during a planned coexistence disconnect — suppresses normal auto-reconnect
91-
@Volatile
92-
private var plannedDisconnect: Boolean = false
91+
private var reconnectAttempts: Int = 0
9392

9493
fun isConnected(): Boolean = connected
9594
fun is365(): Boolean = security == EversenseSecurityType.SecureV2
@@ -135,6 +134,9 @@ class EversenseGattCallback(
135134
bluetoothGatt = gatt
136135
// FIX 3: Set connected flag on confirmed STATE_CONNECTED.
137136
connected = true
137+
// Reset backoff counters on successful connection
138+
reconnectAttempts = 0
139+
failedConnectionAttempts = 0
138140

139141
preferences.edit(commit = true) {
140142
putString(StorageKeys.REMOTE_DEVICE_KEY, gatt.device.address)
@@ -147,7 +149,9 @@ class EversenseGattCallback(
147149
}
148150

149151
if (!gatt.requestMtu(512)) {
150-
EversenseLogger.error(TAG, "Failed to request MTU")
152+
EversenseLogger.warning(TAG, "requestMtu returned false — skipping to discoverServices with default payload size")
153+
payloadSize = 20
154+
gatt.discoverServices()
151155
}
152156
return
153157
}
@@ -173,15 +177,33 @@ class EversenseGattCallback(
173177
}
174178

175179
val storedAddress = preferences.getString(StorageKeys.REMOTE_DEVICE_KEY, null)
176-
if (storedAddress != null && !plannedDisconnect) {
177-
val delayMs = if (status == BluetoothGatt.GATT_SUCCESS) 5000L else 10000L
178-
EversenseLogger.info(TAG, "Scheduling auto-reconnect in ${delayMs/1000}s (status: $status)")
180+
if (storedAddress != null) {
181+
// Exponential backoff so AAPS reclaims the transmitter quickly after boot
182+
// (when the official Eversense app temporarily holds the BLE connection)
183+
// and avoids battery drain during sustained unavailability.
184+
//
185+
// Status 19 = transmitter actively rejected us (placement issue, not competition) —
186+
// use a fixed 30 s interval so we don't spam it.
187+
// Status GATT_SUCCESS = clean disconnect (we or the transmitter closed cleanly) —
188+
// reconnect quickly in 5 s.
189+
// All other status codes (e.g. 133 = GATT_ERROR, device busy) = backoff:
190+
// attempt 0 → 5 s, attempt 1 → 10 s, attempt 2 → 20 s, attempt 3 → 40 s,
191+
// attempt 4+ → 60 s cap.
192+
val delayMs: Long = when {
193+
status == 19 -> 30_000L
194+
status == BluetoothGatt.GATT_SUCCESS -> 5_000L
195+
else -> {
196+
val attempt = reconnectAttempts++
197+
minOf(5_000L * (1L shl minOf(attempt, 4)), 60_000L)
198+
}
199+
}
200+
EversenseLogger.info(TAG, "Scheduling auto-reconnect in ${delayMs / 1000}s (status: $status, attempt: $reconnectAttempts)")
179201
handler.postDelayed({
180-
EversenseLogger.info(TAG, "Attempting auto-reconnect...")
202+
EversenseLogger.info(TAG, "Attempting auto-reconnect (attempt $reconnectAttempts)...")
181203
plugin.connect(null)
182204
}, delayMs)
183205
} else {
184-
if (!plannedDisconnect) { EversenseLogger.warning(TAG, "No stored device address — skipping auto-reconnect") } else { EversenseLogger.info(TAG, "Planned coexistence disconnect — suppressing auto-reconnect") }
206+
EversenseLogger.warning(TAG, "No stored device address — skipping auto-reconnect")
185207
}
186208
}
187209
}
@@ -318,16 +340,6 @@ class EversenseGattCallback(
318340
bleExecutor.submit {
319341
EversenseE3Communicator.readGlucose(this, preferences, plugin.watchers)
320342
EversenseE3Communicator.fullSync(this, preferences, plugin.watchers)
321-
if (coexistenceMode) {
322-
EversenseLogger.info(TAG, "Coexistence — disconnecting to give official app 2-minute window")
323-
plannedDisconnect = true
324-
disconnect()
325-
handler.postDelayed({
326-
EversenseLogger.info(TAG, "Coexistence reconnect — official app window ended")
327-
plannedDisconnect = false
328-
plugin.connect(null)
329-
}, 120000L)
330-
}
331343
}
332344
return
333345
}
@@ -345,16 +357,6 @@ class EversenseGattCallback(
345357
Eversense365Communicator.readGlucose(this, preferences, plugin.watchers)
346358
Eversense365Communicator.fullSync(this, preferences, plugin.watchers)
347359
}
348-
if (coexistenceMode && !plannedDisconnect) {
349-
EversenseLogger.info(TAG, "Coexistence — disconnecting to give official app 2-minute window")
350-
plannedDisconnect = true
351-
disconnect()
352-
handler.postDelayed({
353-
EversenseLogger.info(TAG, "Coexistence reconnect — official app window ended")
354-
plannedDisconnect = false
355-
plugin.connect(null)
356-
}, 120000L)
357-
}
358360
}
359361
return
360362
} else if (data.size >= 4 && data[0] == Eversense365Packets.NotificationResponseId && data[1] == 0x03.toByte()) {
@@ -389,12 +391,13 @@ class EversenseGattCallback(
389391

390392
if (EversenseE3Packets.isErrorPacket(data[0])) {
391393
EversenseLogger.error(TAG, "Received error response - data: ${data.toHexString()}")
394+
packet.isErrorResponse = true
392395
packet.notifyAll()
393396
return
394397
}
395398

396399
if (security == EversenseSecurityType.None) {
397-
if (packetAnnotation.responseId != data[0]) {
400+
if (!packet.skipResponseIdValidation && packetAnnotation.responseId != data[0]) {
398401
EversenseLogger.warning(TAG, "Incorrect responseId - expected: ${packetAnnotation.responseId}, got: ${data[0]}")
399402
return
400403
}
@@ -419,7 +422,7 @@ class EversenseGattCallback(
419422
@SuppressLint("MissingPermission")
420423
@OptIn(ExperimentalStdlibApi::class)
421424
@Throws(EversenseWriteException::class)
422-
fun <T : EversenseBasePacket.Response> writePacket(packet: EversenseBasePacket): T {
425+
fun <T : EversenseBasePacket.Response> writePacket(packet: EversenseBasePacket, timeoutMs: Long = WRITE_TIMEOUT_MS): T {
423426
val gatt = bluetoothGatt ?: throw EversenseWriteException("Gatt is null — not connected")
424427

425428
val requestCharacteristic = requestCharacteristic
@@ -441,11 +444,14 @@ class EversenseGattCallback(
441444
// Previously, a timeout would fall through to parseResponse() silently, likely
442445
// producing a confusing cast exception rather than a clear timeout error.
443446
val start = System.currentTimeMillis()
444-
packet.wait(WRITE_TIMEOUT_MS)
447+
packet.wait(timeoutMs)
445448
val elapsed = System.currentTimeMillis() - start
446-
if (elapsed >= WRITE_TIMEOUT_MS) {
449+
if (elapsed >= timeoutMs) {
447450
currentPacket.set(null)
448-
throw EversenseWriteException("Timed out waiting for response after ${WRITE_TIMEOUT_MS}ms — packet: ${packet.getAnnotation()?.responseId}")
451+
throw EversenseWriteException("Timed out waiting for response after ${timeoutMs}ms — packet: ${packet.getAnnotation()?.responseId}")
452+
} else if (packet.isErrorResponse) {
453+
currentPacket.set(null)
454+
throw EversenseWriteException("Transmitter returned error response — packet: ${packet.getAnnotation()?.responseId}")
449455
}
450456
} catch (e: EversenseWriteException) {
451457
throw e
@@ -478,6 +484,8 @@ class EversenseGattCallback(
478484

479485
EversenseLogger.info(TAG, "E3 auth complete — ready for full sync")
480486
EversenseE3Communicator.fullSync(this, preferences, plugin.watchers)
487+
EversenseLogger.info(TAG, "E3 transmitter ready — notifying watchers")
488+
handler.post { plugin.watchers.forEach { it.onTransmitterReady() } }
481489
}
482490

483491
@SuppressLint("MissingPermission")
@@ -507,6 +515,11 @@ class EversenseGattCallback(
507515
return
508516
}
509517

518+
// Cache access token so it can be used for cloud uploads without re-login
519+
val expiryMs = System.currentTimeMillis() + (authSession.expires_in * 1000L)
520+
preferences.edit().putString(StorageKeys.ACCESS_TOKEN, authSession.access_token)
521+
.putLong(StorageKeys.ACCESS_TOKEN_EXPIRY, expiryMs).apply()
522+
510523
val fleet = networkExecutor.submit<Any?> {
511524
EversenseHttp365Util.getFleetSecretV2(
512525
accessToken = authSession.access_token,
@@ -543,6 +556,8 @@ class EversenseGattCallback(
543556

544557
EversenseLogger.info(TAG, "365 auth complete — ready for full sync")
545558
Eversense365Communicator.fullSync(this, preferences, plugin.watchers)
559+
EversenseLogger.info(TAG, "365 transmitter ready — notifying watchers")
560+
handler.post { plugin.watchers.forEach { it.onTransmitterReady() } }
546561

547562
} catch (exception: Exception) {
548563
EversenseLogger.error(TAG, "[365] authV2 failed: $exception")

plugins/eversense/src/main/kotlin/com/nightscout/eversense/callbacks/EversenseWatcher.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ interface EversenseWatcher {
1111
fun onConnectionChanged(connected: Boolean)
1212
fun onAlarmReceived(alarm: ActiveAlarm) {}
1313
fun onTransmitterNotPlaced() {}
14+
fun onTransmitterReady() {}
1415
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.nightscout.eversense.enums
2+
3+
enum class BatteryLevel(val code: Int) {
4+
PERCENTAGE_0(0),
5+
PERCENTAGE_5(1),
6+
PERCENTAGE_10(2),
7+
PERCENTAGE_25(3),
8+
PERCENTAGE_35(4),
9+
PERCENTAGE_45(5),
10+
PERCENTAGE_55(6),
11+
PERCENTAGE_65(7),
12+
PERCENTAGE_75(8),
13+
PERCENTAGE_85(9),
14+
PERCENTAGE_95(10),
15+
PERCENTAGE_100(11),
16+
UNKNOWN(255);
17+
18+
fun toPercentage(): Int = when (this) {
19+
PERCENTAGE_0 -> 0
20+
PERCENTAGE_5 -> 5
21+
PERCENTAGE_10 -> 10
22+
PERCENTAGE_25 -> 25
23+
PERCENTAGE_35 -> 35
24+
PERCENTAGE_45 -> 45
25+
PERCENTAGE_55 -> 55
26+
PERCENTAGE_65 -> 65
27+
PERCENTAGE_75 -> 75
28+
PERCENTAGE_85 -> 85
29+
PERCENTAGE_95 -> 95
30+
PERCENTAGE_100 -> 100
31+
UNKNOWN -> -1
32+
}
33+
34+
companion object {
35+
fun from(code: Int): BatteryLevel =
36+
values().firstOrNull { it.code == code } ?: UNKNOWN
37+
}
38+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.nightscout.eversense.enums
2+
3+
enum class CalibrationFlag(val code: Int) {
4+
NOT_ENTERED_FOR_CALIBRATION(0),
5+
ACTUALLY_USED_FOR_CALIBRATION(1),
6+
MARKED_SUSPICIOUS(2),
7+
GLUCOSE_TOO_LOW_TO_READ(3),
8+
GLUCOSE_TOO_HIGH_TO_READ(4),
9+
GLUCOSE_RAPID_CHANGE(5),
10+
INVALID_TIME(6),
11+
INSUFFICIENT_DATA(7),
12+
SENSOR_EOL(8),
13+
DROPOUT_PHASE(9),
14+
AUTO_LINK_MODE_ACTIVE(10),
15+
SENSOR_LED_DISCONNECT(11),
16+
OTHER_FAILURE(12),
17+
THIS_ONE_USED_PREVIOUS_ONE_DELETED(13),
18+
THIS_SUSPICIOUS_PREVIOUS_DELETED(14),
19+
INSUFFICIENT_DATA_POST_FS_ENTRY(15),
20+
UNKNOWN_FAILURE(255);
21+
22+
fun getTitle(): String = when (this) {
23+
ACTUALLY_USED_FOR_CALIBRATION,
24+
NOT_ENTERED_FOR_CALIBRATION -> "Calibration accepted"
25+
MARKED_SUSPICIOUS -> "Suspicious"
26+
GLUCOSE_TOO_LOW_TO_READ -> "Glucose too low"
27+
GLUCOSE_TOO_HIGH_TO_READ -> "Glucose too high"
28+
GLUCOSE_RAPID_CHANGE -> "Glucose changing too fast"
29+
INVALID_TIME -> "Invalid time"
30+
INSUFFICIENT_DATA,
31+
INSUFFICIENT_DATA_POST_FS_ENTRY -> "Insufficient data"
32+
SENSOR_EOL -> "Sensor End of Life"
33+
DROPOUT_PHASE -> "Dropout phase"
34+
AUTO_LINK_MODE_ACTIVE -> "Autolink"
35+
SENSOR_LED_DISCONNECT -> "Sensor disconnected"
36+
OTHER_FAILURE -> "Other failure"
37+
THIS_ONE_USED_PREVIOUS_ONE_DELETED,
38+
THIS_SUSPICIOUS_PREVIOUS_DELETED -> "Previous calibration deleted"
39+
UNKNOWN_FAILURE -> "Unknown failure"
40+
}
41+
42+
companion object {
43+
fun from(code: Int): CalibrationFlag =
44+
values().firstOrNull { it.code == code } ?: UNKNOWN_FAILURE
45+
}
46+
}

0 commit comments

Comments
 (0)