Status: v0.1 — 2026-09-07 — for developer review Scope: Protocol specification, xDrip-side integration (high level), and receiver-side implementation guide for open hardware (M5Stack, M5StickC, LilyGo, generic ESP32, PineTime, Bangle.js).
xDrip has historically supported wearables through per-device integrations (LeFun, Amazfit, Miband, …). Each of these required xDrip to act as a Bluetooth central, dial out to a closed-firmware device, and speak a proprietary — often undocumented and firmware-dependent — protocol. The result: high maintenance cost, fragile reconnection logic, and code that dies when a vendor changes firmware.
Open Bluetooth Broadcast inverts the model. xDrip becomes a BLE GATT server exposing one small, documented, versioned service. Any device that can act as a BLE central — which includes every ESP32 board, PineTime, and Bangle.js — connects to xDrip, bonds once, subscribes, and receives every new reading as a push notification over an encrypted link.
Consequences:
Out of scope by design: closed-firmware commercial bands (they are BLE peripherals and cannot connect out to xDrip). Those users are served today via Android notifications / Gadgetbridge.
| Decision | Choice | Rationale |
|---|---|---|
| Topology | xDrip = GATT server, device = central | Eliminates per-device code; supports multiple simultaneous receivers; matches how open firmware likes to work |
| Security | BLE bonding, Just Works, pairing-window gated | Encrypted link with zero UI on the receiver; the only weakness (MITM during first pairing) is mitigated by requiring the user to explicitly open a pairing window in xDrip |
| Wire units | Always mg/dl ×10, little-endian | One unambiguous wire format; display units are the receiver’s concern |
| Packet size | ≤ 20 bytes per notification | Fits the default BLE ATT MTU (23), so minimal receivers never need MTU negotiation |
| Time | Age in seconds, not epoch time | A clock-less receiver (bare ESP32, no NTP/RTC) can’t interpret absolute time but always understands “this value is N seconds old”; also halves the field size. xDrip’s internal epoch-milliseconds precision is far beyond what a once-per-reading push needs |
| Alarms | Separate characteristic | A minimal receiver ignores alarms simply by not subscribing |
| Status line | Optional opaque-text characteristic, opt-in | Restores watch-integration parity (AAPS IOB/basal display) without touching the fixed-binary core; display-only, never parsed |
| Backfill | Control point reserved, not implemented in v1 | Keeps v1 simple; UUID and semantics reserved now so adding it later breaks nothing |
| Versioning | Version byte in every packet + Status characteristic | Open ecosystems live or die on forward compatibility |
Base UUID randomly generated (v4); characteristic UUIDs vary only bytes 2–3, Nordic-UART style. Frozen as the protocol’s permanent identity from v0.1 onward.
| Element | UUID | Properties | Security |
|---|---|---|---|
| OBB Service | e9ca0001-e28a-47ac-bebb-2f51794c9581 |
— | — |
| Glucose | e9ca0002-e28a-47ac-bebb-2f51794c9581 |
Read, Notify | Encrypted (bonded) |
| Alarm | e9ca0003-e28a-47ac-bebb-2f51794c9581 |
Notify | Encrypted (bonded) |
| Status | e9ca0004-e28a-47ac-bebb-2f51794c9581 |
Read | Encrypted (bonded) |
| Backfill Control Point | e9ca0005-e28a-47ac-bebb-2f51794c9581 |
Write, Indicate | Encrypted (bonded) — reserved, v1 returns Not Supported |
| Status Line | e9ca0006-e28a-47ac-bebb-2f51794c9581 |
Read, Notify | Encrypted (bonded) — optional, off by default |
The 16-byte OBB Service UUID is included in the advertisement so receivers can filter scans on it.
All multi-byte fields are little-endian. All glucose values are mg/dl × 10 (uint16; e.g. 123.4 mg/dl → 1234). Time is expressed as age: seconds elapsed since the reading was taken (uint16, capped at 0xFFFE; 0xFFFF = unknown/older). Age requires no clock, no timezone, and no time sync on the receiver — a clock-less ESP32 immediately knows how fresh the value is, and a receiver that wants absolute time simply anchors age to its own clock at reception. (xDrip’s native timestamps are epoch milliseconds; the server computes age at send time.)
| Offset | Size | Type | Field | Notes |
|---|---|---|---|---|
| 0 | 1 | uint8 | version |
0x01 for this spec |
| 1 | 1 | uint8 | flags |
Bitmask, see below |
| 2 | 2 | uint16 | age |
Seconds since the reading was taken; 0xFFFF = unknown |
| 4 | 2 | uint16 | glucose |
mg/dl × 10; 0xFFFF = unavailable |
| 6 | 2 | int16 | delta |
mg/dl × 10 change vs. previous reading; 0x7FFF = unavailable |
| 8 | 1 | uint8 | trend |
Enum, see below |
| 9 | 1 | uint8 | reserved |
0x00; receivers must ignore |
flags bits: bit 0 = value is stale (older than ~11 min), bit 1 = sensor warming up, bit 2 = value is raw/uncalibrated, bits 3–7 reserved (ignore).
trend enum (follows xDrip/Dexcom convention):
| Value | Meaning |
|---|---|
| 0 | Unknown / not computable |
| 1 | Double up (↑↑) |
| 2 | Single up (↑) |
| 3 | Forty-five up (↗) |
| 4 | Flat (→) |
| 5 | Forty-five down (↘) |
| 6 | Single down (↓) |
| 7 | Double down (↓↓) |
| 8 | Out of range |
| Offset | Size | Type | Field | Notes |
|---|---|---|---|---|
| 0 | 1 | uint8 | version |
0x01 |
| 1 | 1 | uint8 | alarm_type |
Enum, see below |
| 2 | 2 | uint16 | age |
Seconds since the alarm was raised; 0xFFFF = unknown |
| 4 | 2 | uint16 | value |
mg/dl × 10 associated with the alarm; 0xFFFF = n/a |
| 6 | 1 | uint8 | reserved |
0x00 |
alarm_type enum: 0 = all clear (cancels active alarm on the receiver), 1 = urgent low, 2 = low, 3 = high, 4 = urgent high, 5 = missed readings, 6 = sensor problem, 7 = phone battery low. Values 8–255 reserved; unknown values must be treated as a generic alert.
| Offset | Size | Type | Field | Notes |
|---|---|---|---|---|
| 0 | 1 | uint8 | protocol_version |
Highest version this xDrip speaks |
| 1 | 1 | uint8 | capabilities |
bit 0 = backfill supported (0 in v1); others reserved |
| 2 | 1 | uint8 | phone_battery |
0–100 %, 0xFF = unknown |
| 3 | 1 | uint8 | source |
0 = unknown, 1 = CGM sensor, 2 = follower source; others reserved |
| 4 | 4 | uint32 | utc_time |
Current UTC time at the moment of the read, epoch seconds |
| 8 | 1 | int8 | tz_offset |
Phone’s UTC offset in 15-minute units (e.g. CET = +4, CEST = +8, IST = +22) |
Receivers should read Status once after connecting: it provides capabilities and a clock fix in a single read. Local wall time = utc_time + tz_offset × 900. Clock-less receivers (bare ESP32, no RTC/NTP) anchor this to their internal tick counter and may re-read Status periodically to correct drift; typical ESP32 drift between connects is negligible for display purposes.
Optional companion: standard Current Time Service (0x1805). xDrip may additionally expose the Bluetooth SIG CTS alongside the OBB service. Firmwares that already consume CTS for time sync — notably InfiniTime on the PineTime — then get their clock set with zero new code. CTS is additive and not required by this spec.
A pass-through of xDrip’s Extra Status Line (Settings → Less Common Settings → Extra Status Line): the same user-configured string shown on watch integrations, which may include calibration parameters, statistics (average, A1c, time-in-range, standard deviation), carbs and insulin totals, pump reservoir/IOB/battery, the external (AAPS) loop status such as IOB and basal, and a clock.
Properties and rules:
Rationale: this restores feature parity with the removed per-device watch integrations — for AAPS-coupled users the status line is the IOB/basal readout — without touching the fixed-binary core. A minimal receiver that only wants glucose simply never subscribes.
Settings → Smartwatch features → Open Bluetooth Broadcast (replacing the slots vacated by the removed LeFun and Amazfit entries), containing:
OpenBroadcastService — a foreground service (as required by modern Android for BLE work) owning one BluetoothGattServer with the OBB service definition, and one BluetoothLeAdvertiser advertising the service UUID. It tracks subscribed clients and their CCCD state, and enforces the bonded-only rule and the pairing window.StatusLine.extraStatusLine() on each reading and notifies subscribers; reuses the existing cached generator, no new logic.Recommended stack: NimBLE-Arduino (lighter and more robust than the Bluedroid Arduino BLE library).
Reference packet parsing (shared across all C/C++ targets):
#pragma pack(push, 1)
typedef struct {
uint8_t version; // expect 0x01, but accept and ignore-unknown
uint8_t flags; // bit0 stale, bit1 warmup, bit2 raw
uint16_t age; // seconds since reading, 0xFFFF = unknown
uint16_t glucose; // mg/dl x10, 0xFFFF = unavailable
int16_t delta; // mg/dl x10, 0x7FFF = unavailable
uint8_t trend; // enum per spec
uint8_t reserved;
} obb_glucose_t;
#pragma pack(pop)
Receiver sketch outline (NimBLE-Arduino):
#include <NimBLEDevice.h>
static const NimBLEUUID SVC ("e9ca0001-e28a-47ac-bebb-2f51794c9581");
static const NimBLEUUID GLU ("e9ca0002-e28a-47ac-bebb-2f51794c9581");
void onGlucose(NimBLERemoteCharacteristic*, uint8_t* data, size_t len, bool) {
if (len < sizeof(obb_glucose_t)) return;
obb_glucose_t r; memcpy(&r, data, sizeof(r)); // ESP32 is little-endian: direct copy is safe
if (r.glucose != 0xFFFF) {
float mgdl = r.glucose / 10.0f;
// update display: mgdl, r.delta, r.trend, (r.flags & 1) => stale
}
}
void setup() {
NimBLEDevice::init("OBB-Display");
NimBLEDevice::setSecurityAuth(true /*bond*/, false /*no MITM*/, true /*secure conn*/);
// 1. scan for SVC; 2. connect; 3. secure the link (triggers Just Works bonding
// the first time, while xDrip's pairing window is open);
// 4. subscribe:
// client->getService(SVC)->getCharacteristic(GLU)->subscribe(true, onGlucose);
}
Platform notes:
InfiniTime already runs as a BLE peripheral for its companion apps, but the NRF52’s SoftDevice supports the central role; a community watch face/app implementing the OBB client (scan → bond → subscribe → render value + trend) is the intended path. The 10-byte fixed packet and ignore-unknown rules were chosen specifically to keep such an implementation small. This spec document is the only contract such a project needs.
Bangle.js (Espruino) can act as a central directly from JavaScript:
var SVC = "e9ca0001-e28a-47ac-bebb-2f51794c9581";
var GLU = "e9ca0002-e28a-47ac-bebb-2f51794c9581";
NRF.requestDevice({ filters: [{ services: [SVC] }] })
.then(d => d.gatt.connect())
.then(g => g.startBonding().then(() => g)) // Just Works
.then(g => g.getPrimaryService(SVC))
.then(s => s.getCharacteristic(GLU))
.then(c => {
c.on('characteristicvaluechanged', e => {
var dv = e.target.value; // DataView
var age = dv.getUint16(2, true); // seconds since reading
var glucose = dv.getUint16(4, true) / 10; // little-endian
var delta = dv.getInt16(6, true) / 10;
var trend = dv.getUint8(8);
// draw on watch face
});
return c.startNotifications();
});
A Bangle.js app store entry (“xDrip OBB”) displaying value, delta arrow, and staleness is a natural first community deliverable.
[opcode, time range]) and stream historical readings as indications, terminated by a done marker — modeled loosely on the standard Record Access Control Point pattern. Capability bit 0 in Status will flip to 1 when implemented.One GATT service, three core binary characteristics plus an optional status-line text channel, ten bytes per reading, bonded-only, pairing gated by an explicit user action, no per-device code, and a spec any hobbyist can implement in an afternoon. It replaces the maintenance model that made LeFun/Amazfit-style integrations costly with a single open, documented door.