The watch is the peripheral / GATT server. The phone (Gadgetbridge, BLE central) writes to RX and
subscribes to TX. The wire format is Gadgetbridge's Bangle.js JSON-over-Nordic-UART dialect, so an
unmodified Gadgetbridge speaks it. Constants live in watch/.../ble/BleUuids.kt — keep this doc
and that file in sync.
| Attribute | UUID | Properties | Direction |
|---|---|---|---|
| NUS service | 6E400001-B5A3-F393-E0A9-E50E24DCCA9E |
primary service | — |
| RX | 6E400002-B5A3-F393-E0A9-E50E24DCCA9E |
WRITE / WRITE_NO_RESPONSE |
phone → watch |
| TX | 6E400003-B5A3-F393-E0A9-E50E24DCCA9E |
NOTIFY |
watch → phone |
| CCCD | 00002902-0000-1000-8000-00805f9b34fb |
descriptor on TX | phone subscribes |
The phone enables TX notifications by writing ENABLE_NOTIFICATION_VALUE to the CCCD.
Advertising: the watch advertises the NUS service UUID (primary packet) and a device name
matching Gadgetbridge's Bangle.js coordinator regex Bangle\.js.* — we use
Bangle.js PixelBridge, carried in the scan-response packet (a 128-bit UUID + a 21-char name
won't both fit in one 31-byte advertisement).
- phone → watch: bytes are
0x10+"GB("+<JSON>+")"+"\n". The leading0x10is Espruino's DLE/echo-off control byte — strip it. The shorthandGB({...})omits it. - watch → phone: raw JSON terminated with
\r\n(CRLF), one object per line.⚠️ Gadgetbridge's line splitter doessubstring(0, p-1)on the\nindex (it expects a trailing\r), so a bare\nwould eat your closing}→ "Malformed JSON". Gadgetbridge parses any line beginning with{. - NUS is an opaque byte stream → reassemble by the
\ndelimiter, never by packet boundary. The GATT server buffers inbound RX bytes and only parses a message when it sees0x0A. OurNusGattServerdoes exactly this.
- Default ATT MTU is 23 bytes (20 usable). The central negotiates via
requestMtu(max 517) and readsonMtuChanged; Gadgetbridge's Bangle.js driver requests MTU 131 whenallowHighMTUis on, otherwise chunks at 20 bytes. - Never assume a notification fits one packet. On the watch, buffer + split on
\n. On outbound, emit each JSON object followed by\nand let the stack/chunking carry it. - Use NOTIFY (unacknowledged) for the notification firehose; use write-with-response / indicate only where delivery must not drop.
- ✅
{"t":"notify","id":…,"src":…,"title":…,"subject":…,"body":…,"sender":…,"reply":<bool>?}— show/create.reply:true(Gadgetbridge 0.80+) means the source notification has aRemoteInput; the watch shows the Reply action only when it's present. Cards are always dismissible. - ✅
{"t":"notify-","id":…}— dismiss on the watch - ✅
{"t":"find","n":<boolean>}— start (true) / stop (false) the full-screen find-my-watch alert. Gadgetbridge's Find Device sends this;nis a JSON boolean. - ✅
{"t":"vibrate","n":<int>}— single buzz (handled defensively; Espruino sends it, Gadgetbridge does not). - ✅
{"t":"call","cmd":"incoming|outgoing|accept|start|end|reject|ignore","name":…,"number":…}— full-screen call screen (ring onincoming, In-call onaccept/start, clear onend/reject/ignore). - ✅
{"t":"musicinfo","artist":…,"album":…,"track":…,"dur":<ms>,"c":<count>,"n":<nr>}— now-playing metadata. - ✅
{"t":"musicstate","state":"play|pause|stop|","position":…,"shuffle":…,"repeat":…}— transport (empty/stopclears). - ✅
{"t":"canned_responses_sync","d":[{"text":…,"disp":…?}]}— synced quick-reply choices. - later:
{"t":"alarm",…},{"t":"weather",…}
- ✅
{"t":"notify","id":…,"n":"DISMISS"|"DISMISS_ALL"|"REPLY","msg":"<text>"}⚠️ The reply text goes inmsg(notreply) — Gadgetbridge'shandleNotificationControlreadsjson.getString("msg")and mapsid→the stored RemoteInput handle for REPLY. Gadgetbridge upper-casesnand doesEvent.valueOf(n). The watch UI emits only these three; Gadgetbridge also acceptsOPEN/MUTEif ever sent, but the notification card no longer exposes them. - ✅
{"t":"status","bat":<0-100>,"volt":<double>,"chg":<0|1>}— battery (on subscribe + on change). - ✅
{"t":"ver","fw":"<app-version>","hw":"<Build.MODEL>"}— version handshake (on subscribe). - ✅
{"t":"findPhone","n":<boolean>}— ring the phone (na JSON boolean). - ✅
{"t":"call","n":"ACCEPT|REJECT|IGNORE|END"}— call control (Event.valueOf(n.uppercase())). - ✅
{"t":"music","n":"play|pause|next|previous|volumeup|volumedown"}— media remote (lowercase).
Canonical examples
phone→watch: GB({"t":"notify","id":1575479849,"src":"Signal","title":"Alice","body":"hi"})
phone→watch: GB({"t":"notify-","id":1575479849})
watch→phone: {"t":"notify","id":1575479849,"n":"DISMISS"}
Gadgetbridge maps a watch-sent DISMISS → NotificationListenerService.cancelNotification(key)
and REPLY → the notification's Notification.Action + RemoteInput PendingIntent — full
dismiss/reply parity on stock Android.
- Always carry and echo the notification
id.NotificationListenerdoesn't always set stableNotificationSpecIDs, so the watch keys its UI and back-channel off theidit received (the Bangle.js dialect already usesid). - Bonding is optional for NUS data (no encryption required) but makes
connectGatt(autoConnect=true)reconnection durable across BT-cache clears. Choose the coordinator's bonding style (NONE/ASK) accordingly. - Serialize GATT ops through one queue to avoid
status 133.
Bangle.js protocol (Gadgetbridge) · Espruino Gadgetbridge protocol · Nordic UART Service · Android BLE transfer · Android BLE guide (Punch Through)