Field note · Healthcare · BLE
Streaming Dexcom G6 to Android: backfill, auth, and the 5-minute cadence.
The Dexcom G6 is a great sensor and a picky BLE peripheral. Treat it like a stock-standard GATT device and you'll lose readings, miss the auth handshake, or get stuck waiting for a measurement that's actually five minutes away. Here's the integration shape we used at Level2 — UnitedHealth Group's Type 2 diabetes platform — to ship clinical-grade glucose streaming with no data loss.
The protocol shape
A G6 transmitter exposes a vendor-specific service rather than the SIG-defined CGM Service (0x181F). The service UUID and its three relevant characteristics are public knowledge from the xDrip+ project:
- Communication service —
F8083532-849E-531C-C594-30F1F86A4EA5 - Control —
F8083534-…(commands in, status out) - Authentication —
F8083535-…(challenge / response) - Backfill —
F8083538-…(historical EGV records)
You scan with a service-UUID filter on the comm service. Once you connect, the dance is: discover services → request MTU → authenticate → enable notifications → request backfill → live notify every 5 minutes.
Authentication is mandatory
Before the transmitter will tell you anything useful, it expects a cryptographic challenge bound to the transmitter's serial number (printed on the sensor packaging — six or seven alphanumeric characters). The flow, per the publicly documented G5/G6 protocol:
- App writes an 8-byte authentication request to the Auth characteristic.
- Transmitter responds with an 8-byte challenge.
- App returns AES-128-ECB of the challenge, using a 16-byte key derived from the serial number.
- Transmitter writes
status=okback, or disconnects.
Skip this and your setCharacteristicNotification(measurement, true) will silently never fire. Worst possible failure mode for a clinical product — looks fine in QA, no alarms in production, just zero data.
Backfill: the burst before the cadence
Once authenticated, the very first thing you do is ask the transmitter for backfill. The G6 holds about three hours of historical EGV (Estimated Glucose Value) records on-device. On connect, you ask for everything since your last persisted sequence number and the transmitter blasts you 24-ish 9-byte records in a few hundred milliseconds, one per BLE notification.
// Skeleton — actual record parsing omitted.
suspend fun backfillSince(lastSeq: Int): List<EgvRecord> = withConnection { gatt ->
val req = BackfillRequest(lastSeq, lookbackMinutes = 180)
gatt.writeCharacteristic(controlChar, req.encode())
// Drain backfill notifications until we get the terminator.
val records = mutableListOf<EgvRecord>()
backfillNotifications
.takeWhile { !it.isTerminator() }
.collect { records += EgvRecord.parse(it.value) }
db.insertAll(records.distinctBy { it.seq })
records
}
Two non-obvious things:
- Persist by sequence number, not timestamp. Phone clocks drift; the transmitter's monotonic counter does not. Dedup on
seq. - Burst hits your DB hard. 24 inserts in 200 ms is fine for Room, but if you're flowing each record through reactive streams to the UI you'll thrash recompositions. Batch the persist, then emit one "history updated" tick.
The 5-minute live cadence
After backfill, you sit on the live measurement notification. One reading, every five minutes — not five seconds. That cadence is fixed by the sensor hardware. Anyone showing you a Dexcom log with sub-minute reads is showing you a backfill burst, a simulator, or someone else's data.
What this means for the app:
- UI freshness is misleading. The "live" reading you display can be up to 5 minutes old. Show the timestamp, always. Color-code if older than 7 minutes.
- Aggressive reconnect is wasteful. If the link is healthy, you have ~290 seconds of nothing-to-do between notifications. The G6 already negotiates a loose connection interval (typically near the upper end of the spec) because it rarely has anything to say; don't fight it by forcing aggressive parameters on your end.
- Reconnect strategy matters more than read strategy. The expensive event isn't the read — it's the connect. Optimize for fast, reliable reconnect, not for read latency.
Offline-first is non-negotiable
Level2 patients carried the app to grocery stores, on flights, into elevators, into rural areas. The cloud backend wasn't allowed to be the source of truth. Reading flow:
- BLE notification arrives → parse → write to Room (
egv_readingstable). - WorkManager job, constrained on network, syncs unsent rows to the backend.
- UI binds to a Room
Flow, not the network. Network is purely a sink.
This is the boring, obvious answer. It's also the one a surprising number of healthcare apps don't ship. The shape that matters: the BLE callback never blocks on the network. Persist locally, return immediately, let WorkManager handle the rest.
Failure modes worth handling
- Transmitter end-of-life. G6 transmitters expire at 90 days. The app needs to surface this proactively, not after readings stop.
- Sensor warmup. First two hours of a new sensor session, readings can be noisy or absent. Don't alarm.
- RSSI degradation. Below -85 dBm we'd start buffering more aggressively and skip optional reads.
- Bond loss. If Android forgets the bond, the next auth attempt fails silently. Detect, prompt the user, re-bond.
Bottom line
Treat the G6 as what it is: a sensor with a mandatory auth handshake, a one-shot backfill on connect, and a strict 5-minute live cadence. Persist by sequence number, do all I/O off the BLE callback, and assume the network isn't there. Get those four things right and the rest of the integration is just data plumbing.