
Reverse-engineered BLE protocol for the CMF Watch Pro 2, documenting GATT layout, AES-128-CBC encrypted command frames, authentication handshake, and health data sync for alternative companion app development.
Unofficial. This document describes the Bluetooth Low Energy (BLE) protocol of the CMF Watch Pro 2 (CMF by Nothing), reconstructed by reverse engineering for an alternative companion app. It is not affiliated with or endorsed by Nothing/CMF. Use at your own risk.
All multi-byte integers in the frame header and opcodes are big-endian. Integers inside
command payloads are little-endian unless stated otherwise (this mirrors the device firmware) β
watch out for the exceptions (GOALS_SET, GPS_PUSH, bulk-transfer offset/length are big-endian).
Every non-obvious claim below is tagged with how it was established:
Where a later section corrects an earlier one, the earlier text is kept with a pointer rather than deleted β knowing which readings were tried and refuted saves the next person the same detour.
Test device for all captures: CMF Watch Pro 2-5485, fw 1.0.0.73, serial CI04102520008192,
MCU Actions ATS3089C (Cortex-M4), screen 466Γ360.
The phone is the GATT client; the watch is the peripheral, advertising as CMF Watch Pro 2-XXXX
(4 hex chars).
| Purpose | Service | Characteristic | Properties |
|---|---|---|---|
| Command write | 0000fff0-0000-1000-8000-00805f9b34fb | 0000fff2-β¦ | Write |
| Command notify | 0000fff0-β¦ | 0000fff1-β¦ | Notify |
| Shell write (AT) | β | 77d4ff01-2fe2-2334-0d35-9ccd078f529c | Write |
| Shell notify (AT) | β | 77d4ff02-β¦ | Notify |
| Bulk data write | β | 02f00000-0000-0000-0000-00000000ffe1 | Write |
| Bulk data notify | β | 02f00000-β¦ffe2 | Notify |
Enable notifications by writing 01 00 to each CCCD (00002902-β¦). The command channel
(fff1/fff2) carries the framed protocol below. The shell channel (77d4β¦) carries plain
AT-style text (e.g. AT GETSECRET; see Β§14). The data channel (02f0β¦) carries large binary
blobs (watchface, firmware, AGPS), coordinated by control opcodes on the command channel.
Service UUIDs β the watch advertises ~10 primary services. Enumerated on a real unit:
0xfff0 (command), 0x180f (battery), 0x180a (device info), 0xefe7, 0xffd0,
02f00000-β¦ffe0 and 02f00000-β¦fe00 (data), 77d4e67c-2fe2-2334-0d35-9ccd078f529c (shell /
pairing), e49a3001-f69a-11e8-8eb2-f2801f1b9fd1, f48a23c0-f69a-11e8-8eb2-f2801f1b9fd1.
β οΈ The shell service UUID is
77d4e67c-β¦, not77d4ff00-β¦. Earlier revisions of this document assumed the service shared theff00prefix of its characteristics (77d4ff01/77d4ff02, Β§14) β it does not, at least on the unit this was checked on (finding from freethinkel/fmc, see Β§Sources). The characteristic UUIDs are unchanged. Not verified whether77d4e67cis stable across units β enumerate rather than hardcode.
π Web Bluetooth callout. Chromium only ever discovers services that the page listed in
optionalServices, even for an unfilteredgetPrimaryServices()call β a page listing 3 services sees 3, whilechrome://bluetooth-internals(Chrome's own C++ layer, unscoped) shows all 10. If you write a browser client, list every UUID above up front or pairing will fail with services that plainly exist. No Web Bluetooth in Firefox/Safari; needs a user gesture + HTTPS/localhost.
β A whole real session ran on the single command channel β during a 160 s heavy-use capture there was no traffic on the data/firmware or shell channels except during an explicit OTA/watchface transfer.
0xF5)Every command-channel message is wrapped in one or more 11-byte-header frames:
+------+-----------+--------+-------------+-------------+--------+-------------------+
| 0xF5 | chunkLen | cmd1 | chunkCount | chunkIndex | cmd2 | chunk bytes β¦ |
| 1 B | 2 B (BE) | 2 B BE | 2 B BE | 2 B BE | 2 B BE | chunkLen bytes |
+------+-----------+--------+-------------+-------------+--------+-------------------+
\__________________________ 11-byte header ____________________________/
cmd1/cmd2 together form the opcode (see Β§6). π confirmed against the official app's frame
builder (C6117b.m30831g).chunkCount = total chunks for this command; chunkIndex is 1-based.chunkLen = number of bytes of chunk in this frame.cmd1/cmd2,
increasing chunkIndex) and reassembled in order.cmd1 = 0xFFFF: cmd2 in 0x80xx/0x90xx = phoneβwatch (request/set); 0x00xx/0xa0xx =
watchβphone (reply). Pairs match by the low byte (0x9055β0xa055, 0x8051β0x0051).cmd1: cmd2 suffix = 0x0001 SET, 0x0002 GET, 0x0003 ACK.For each chunk, the body is payloadPiece β CRC32_LE(payloadPiece) (4-byte CRC, little-endian,
zlib/IEEE). If the command is encrypted (see Β§3), the whole payloadPiece β CRC is then
AES-128-CBC/PKCS7 encrypted and that ciphertext becomes the frame chunk.
Plaintext quirk: for plaintext opcodes the watch counts the 4-byte CRC in chunkLen but does
not transmit it. So when decoding a plaintext frame, the actual data length is chunkLen β 4.
(Encrypted frames carry the CRC inside the ciphertext as normal.)
Chunk sizing (so encrypted chunks land on AES block boundaries), with maxWrite = mtu β 3:
floor((maxWrite β 11) / 16) * 16 β 4 β 1maxWrite β 11 β 4 β 2β
All observed encrypted frame chunkLen values were multiples of 16 (block alignment holds).
CmfCharacteristic.AES_IV):
50 51 52 53 54 55 56 57 60 61 62 63 64 65 66 5A.Key derivation:
authkey = SHA256( rnd1 β rnd2 β secret )[0..16] // persisted across sessions
sessionKey = SHA256( nonce β authkey )[0..16] // per connection
secret = 16-byte device secret (obtainable from the watch via the shell command
AT GETSECRET β GETSECRET:<32-hex>,OK).rnd1 = 16 random bytes chosen by the phone; rnd2 = 16 random bytes from the watch.nonce = bytes from the watch's nonce reply.After the key is set, all command-channel frames are AES-encrypted except the plaintext opcodes listed in Β§5.
β
Both derivations validated: authkey recovered from a rooted phone's ntwatch.db matched the
value derived from a captured rnd1/rnd2/secret; sessionKey reproduced from a captured nonce
decrypts live frames.
Two entry paths share the same nonce/confirm tail.