The tag accepted 51840 but sent a header of 51792 and 50807 units of data, about 69.5 days. Add the capture as a vector and correct the spec: the end marker's length tracks delivered data, and at least one bit error changed between reads, so the errors aren't all fixed in storage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
19 KiB
ANCEL BM200 BLE protocol: implementation spec
Language-neutral spec for capturing live readings and backfilling history from an ANCEL BM200, with no vendor app or cloud.
Source: real captures from one tag (firmware 1.8), plus the bytecode of com.dc.bm6_ancel 2.0.6 (BM6 protocol family) for anything the captures don't show. See Hardware validation for the evidence and limits, and Unverified for what is still untested.
To implement this in a new language, follow sections 1 to 5, then pass the vectors in section 6.
The existing BM2Client in this repo does not work with this device: the key, frame format and startup all differ.
1. Transport
| Item | Value |
|---|---|
| Advertised name | BM6 or Battery Guard |
| Service | 0000fff0-0000-1000-8000-00805f9b34fb |
| Write commands | 0000fff3-0000-1000-8000-00805f9b34fb |
| Notifications | 0000fff4-0000-1000-8000-00805f9b34fb |
Match on name: the tag advertises BM6 with an iBeacon payload, but does not advertise FFF0. The service only appears after connecting. Only FFF3 and FFF4 are used here. The tag also exposes FFF1, FFF2, FFF5, FEE1 and the standard device information service, whose purpose isn't documented.
FFF3 accepts write-with-response. The tag sent no notifications during a 5 s wait after subscribing, so write commands without waiting for it.
The relay must hold a GATT connection and write commands. Readings do not come from passive scanning: the app only uses advertisements to detect the tag (iBeacon prefix 02 15) and then connects.
2. Crypto
KEY = 6c 65 61 67 65 6e 64 ff fe 30 31 30 30 30 30 39 // "leagend" FF FE "0100009"
IV = 16 zero bytes
AES-128-CBC, no padding
encrypt(cmd):
return AES_CBC_ENCRYPT(KEY, IV, right_pad_with_zeros(cmd, 16))
decrypt(frame):
// IV resets for every block
if length(frame) == 0 or length(frame) % 16 != 0: reject frame
return concat(AES_CBC_DECRYPT(KEY, IV, block) for each 16-byte block of frame)
Notifications are 16 or 160 bytes. Don't assume 16, and make sure the BLE stack accepts the larger size.
3. Commands
| Name | Plaintext | Encrypted bytes written to FFF3 |
|---|---|---|
| VERSION | d15501 |
1ad6f05e4990ab24b84d57df81d8766e |
| ACCEPT | d1550200 |
9cb18029774c5cfc57c9f114d40db4e8 |
| LIVE | d15507 |
697ea0b5d54cf024e794772355554114 |
| HISTORY(10) | d1550500000000000a |
bb43a7f6b8c3454a614c83b9366a0b47 |
| HISTORY(22320) | d15505000000005730 |
8e8dd229b057e0567f1c7e0ef0a0ec11 |
| HISTORY(51840) | d1550500000000ca80 |
965d8b8fad4794162ccee2d35a44baa4 |
HISTORY(n) is d1 55 05 00 00 00 followed by n as 3 bytes, big-endian. n counts 4-byte units, not records: a stop record uses two units.
Do not send:
d1550211: the app sends this when its cloud check rejects a tag as not genuine.d15508xx: this writes battery type to the tag. The app sends it, but it changes tag configuration, so this spec leaves it out.
4. Session
on_connect:
subscribe FFF4
decoder = new Decoder()
write VERSION; wait for Version (timeout 10 s)
write ACCEPT; wait for AcceptAck (timeout 10 s)
// The app always does this short read first and discards the result.
write HISTORY(10); wait for HistoryBlock (timeout 10 s)
write HISTORY(history_count(since))
block = wait for HistoryBlock (timeout 10 min)
for record in block.records: // oldest first
if since is null or record.time > since:
deliver(record)
since = record.time
persist(since) // only after delivery
write LIVE
until disconnected or no frame for 120 s:
deliver each LiveReading as it arrives
on_disconnect or timeout:
wait 5 s, then reconnect
history_count(since):
if since is null: return MAX_UNITS
missing = max(floor((now - since) / 120 seconds), 0)
return min(missing + floor(missing / 50) + 10, MAX_UNITS)
history_count is in units. The missing / 50 margin covers stop records, which were 1.6% of records in the capture, and the + 10 overlap is from the app. Example: missing = 1000 gives 1030.
MAX_UNITS: the app uses 22320 (31 days) or 51840 (72 days, for one device variant only). 22320 is the safe default. The tested tag also accepted 51840, so use that for a first read if you want everything a tag holds. A 22320 read returned 21974 records, and a 51840 read returned 50070.
The timeouts and the reconnect delay are this spec's choices, not app values. 22320 units took 9.5 s and a 51840 request took 21.7 s, so the 10-minute history timeout is very generous.
A quick reconnect can fail. One attempt started about 19 s after a disconnect and did not find the tag within its 30 s connect timeout. The cause isn't known, so keep retrying.
since advances only from history records, never from live readings. A gap is then backfilled even if some live frames got through during it.
The app also requests crank data (D15503) between history and live. This spec skips that because it is out of scope.
5. Decoder
Each notification is decoded on its own, except history, which spans many frames. Feed frames in arrival order. For a remote relay, record received_at on the relay, because history timestamps are derived from it.
state: history_buffer = null, history_base = 0
feed(frame, received_at):
h = uppercase_hex(decrypt(frame)) // offsets below are hex-digit positions
// Live frames can arrive in the middle of a history transfer.
if h starts with "D15507FF": return nothing // ack
if h starts with "D15507": return parse_live(h, received_at)
if history_buffer is not null:
if h contains "FEFEFE": return nothing // app discards these frames
history_buffer += h
end = index_of(history_buffer, "FFFEFE") // any digit alignment, as in the app
if end < 0: return nothing
data = history_buffer[0 .. end)
history_buffer = null
return HistoryBlock(parse_history(data, history_base))
if h starts with "D15501":
return Version(hex(h[6..8)) + hex(h[8..10)) / 10)
if h starts with "D15502":
return AcceptAck(hex(h[6..8)))
if h starts with "D15505":
if hex(h[6..10)) == 0: return HistoryBlock(empty)
history_buffer = ""
history_base = history_base_time(received_at)
return nothing
return nothing // unknown: log it, don't fail
Live frame
parse_live(h, received_at):
magnitude = hex(h[8..10))
return LiveReading:
time = received_at
temperature = -magnitude if hex(h[6..8)) == 1 else magnitude
status = hex(h[10..12))
soc = hex(h[12..14))
voltage = hex(h[14..18)) / 100
rapid_acceleration = hex(h[18..22))
rapid_deceleration = hex(h[22..26))
Compare voltages as decimals or within a small tolerance, because / 100 isn't exact in binary floats.
| Byte | Field | Notes |
|---|---|---|
| 0-2 | D1 55 07 |
|
| 3 | temperature sign | 1 = negative |
| 4 | temperature | unit assumed to be °C |
| 5 | status | Store raw. The app treats 2 as engine running; a parked Prius sent 1 |
| 6 | state of charge | percent |
| 7-8 | voltage | big-endian, ÷100 |
| 9-10 | rapid acceleration count | big-endian |
| 11-12 | rapid deceleration count | big-endian |
| 13-15 | unknown | 02 FF FF in every captured frame; ignore |
The tag sends one live frame per second.
History
Records are 4 bytes, 8 hex digits: VVV SS TT F. Record 0 is the newest, and records are 2 minutes apart. They carry no timestamp, so times are derived from when the transfer starts.
A transfer is a header frame (D15505 + 2-byte unit count), then data frames, then an end frame: FFFEFE + 3 bytes + zero padding. Data is zero-padded to fill its last 160-byte frame, which the zero-voltage rule below discards.
Don't rely on getting what you asked for. The tag can return fewer units than requested, and fewer than its own header says: a request for 51840 got a header of 51792 and 50807 units of data. End on FFFEFE, not on a count. The 3 bytes after FFFEFE equal delivered_units * 4 + 9 in all captured reads, so logging a mismatch is a cheap completeness check. The + 9 is unexplained, so don't reject a transfer on it.
history_base_time(t):
base = t floored to the whole minute
if local_minute(base) is even: base -= 60 seconds
return base
parse_history(data, base):
while data starts with "00000000": data = data[8..] // see note below
records = []; pos = 0; index = 0
while pos + 8 <= length(data):
item = data[pos .. pos+8); pos += 8
flags = hex(item[7])
mark = "start" if (flags & 7) == 2
"stop" if (flags & 7) == 4
null otherwise
if mark == "stop": pos += 8 // 4 extra bytes: rapid accel, rapid decel (2 bytes each)
voltage = hex(item[0..3)) / 100
if voltage != 0: // zero slots are dropped but still use a time slot
magnitude = hex(item[5..7))
records.append(
time = base - index * 120 seconds,
voltage = voltage,
soc = hex(item[3..5)),
temperature = -magnitude if (flags & 8) != 0 else magnitude,
mark = mark)
index += 1
return reverse(records) // oldest first
Things to design for:
- Range-check history. The decoder returns values as read, and tags can return bit errors:
0x4E0(12.48 V) stored as0x0E0(2.24 V). In the consumer, flag or skip a record with voltage under 5 or state of charge over 100. See Hardware validation. - Timestamps are approximate. They assume the newest record was taken at the base time, so the same record can come out a slot different between reads. Do not use
(time, voltage)as a strict unique key. Filter withsince. - Leading zero records. This spec strips all of them. The app's loop has a quirk that can also skip real records after them, which is not reproduced here.
- The tag's clock is not used. A tag that was out of range for longer than its history depth has an unrecoverable gap.
6. Expected outputs
There are two sets: real frames from a tag, then synthetic frames for cases the captures don't contain. Each case starts with a new decoder, and frames are fed in order. Floats are shown as decimal values.
Real frames
Captured from a tag with firmware 1.8. Use timezone UTC. received_at is Unix seconds.
| Case | received_at |
Encrypted frame | Plaintext | Expected |
|---|---|---|---|---|
| Version | 1793502396.52 | b43b52e47dfc39856d80c65c8d8bcf33 |
d155010108… |
Version{version: 1.8} |
| Accept ack | 1793502396.60 | 9cb18029774c5cfc57c9f114d40db4e8 |
d1550200… |
AcceptAck{result: 0} |
| Live ack | 1793502396.73 | 8ae0358523fe154976de18fc102eb1d4 |
d15507ff… |
nothing |
| Live | 1793502397 | 7e2df76c6e569e4e1da179e98700234e |
d15507000e013f04fe0000000002ffff |
see below |
{"time": "2026-11-01T03:06:37Z", "voltage": 12.78, "soc": 63, "temperature": 14, "status": 1, "rapid_acceleration": 0, "rapid_deceleration": 0}
History, requesting 10 units, with received_at = 1793502396.67 for every frame (03:06:36Z, so the base is 03:05:00Z):
| # | Encrypted frame | Plaintext | Expected |
|---|---|---|---|
| 1 | 9de9fd5b453ef519186bf74f74166106 |
d15505000a… |
nothing |
| 2 | 0f5d678d20e26062ceb6f51a3e45bd7eea198c8a64a612f14595a5cf2a3c267531dee1330bee48cb3ec964350949ea72 followed by 485ee1040e6ff06db409a84383d55c8e 7 times |
4fe3f0e0 5063f0e0 50c3f0d4 00000000 5943e0e0 5963d0e0 5953c0e0 5953b0e2 4dc3a0e0 506390e4 then zeros, 160 bytes |
nothing |
| 3 | 3829637a7be84b710b9e0098ab8fc879 |
fffefe000031… |
block below |
{"records": [
{"time": "2026-11-01T02:49:00Z", "voltage": 12.86, "soc": 57, "temperature": 14, "mark": "stop"},
{"time": "2026-11-01T02:51:00Z", "voltage": 12.44, "soc": 58, "temperature": 14, "mark": null},
{"time": "2026-11-01T02:53:00Z", "voltage": 14.29, "soc": 59, "temperature": 14, "mark": "start"},
{"time": "2026-11-01T02:55:00Z", "voltage": 14.29, "soc": 60, "temperature": 14, "mark": null},
{"time": "2026-11-01T02:57:00Z", "voltage": 14.30, "soc": 61, "temperature": 14, "mark": null},
{"time": "2026-11-01T02:59:00Z", "voltage": 14.28, "soc": 62, "temperature": 14, "mark": null},
{"time": "2026-11-01T03:01:00Z", "voltage": 12.92, "soc": 63, "temperature": 13, "mark": "stop"},
{"time": "2026-11-01T03:03:00Z", "voltage": 12.86, "soc": 63, "temperature": 14, "mark": null},
{"time": "2026-11-01T03:05:00Z", "voltage": 12.78, "soc": 63, "temperature": 14, "mark": null}
]}
Full capture replay
findings/captures/*.jsonl holds every frame of three real sessions, one JSON object per line, so any language can replay them:
| Field | Meaning |
|---|---|
t |
Unix seconds when logged; use as received_at |
dir |
tx written to FFF3, rx notified on FFF4 |
cipher, plain |
Frame as hex, encrypted and decrypted |
events |
Expected events for the frame: type plus the fields above |
Feed rx rows to one decoder in order and compare with events. Times in the file look like 2026-11-01 03:05:00+00:00. Skip time on LiveReading, because it was stamped a moment before t. Use UTC or another timezone with a whole-hour offset. The last block in full-history must have 21974 records and 346 stops, from 2026-10-01T14:45:00Z to 2026-11-01T03:11:00Z. The last block in depth-51840 must have 50070 records and 737 stops, from 2026-08-23T14:27:00Z to 2026-11-01T03:25:00Z.
These events came from this repo's Python decoder, so they catch disagreement with it but aren't independent proof. They include the bit errors, as stored.
Synthetic: common values
Assume received_at = 1700000000 (2023-11-14T22:13:20Z) and timezone UTC unless stated.
Synthetic: single frames
| Case | Encrypted frame | Plaintext | Expected |
|---|---|---|---|
| Version | 13e4a294119b956c21350446e9fe3527 |
d15501010700… |
Version{version: 1.7} |
| Accept ack | 9cb18029774c5cfc57c9f114d40db4e8 |
d1550200… |
AcceptAck{result: 0} |
| Live ack | 8ae0358523fe154976de18fc102eb1d4 |
d15507ff… |
nothing |
| Empty history | 91c83073bb2dddcd6544ee0650ad075b |
d155050000… |
HistoryBlock{records: []} |
| Live | e3e117645ad018edcbeb11c769a15326 |
d155070019025505080003000400… |
see below |
| Live, negative temperature | 9889e1f5002c3594e26a8a87317004ea |
d155070105025505080003000400… |
same, with temperature: -5 |
15 bytes of 00 |
rejected, no state change |
{"time": "2023-11-14T22:13:20Z", "voltage": 12.88, "soc": 85, "temperature": 25, "status": 2, "rapid_acceleration": 3, "rapid_deceleration": 4}
Synthetic: history, with a live frame in the middle
| # | Encrypted frame | Plaintext | Expected |
|---|---|---|---|
| 1 | 46f6b39dec35c6faa5a92df16e2b7026 |
d155050003… |
nothing |
| 2 | 0c3f001bd08004a48534d5831b249422 |
00000000 50055194 00030004 4f050140 |
nothing |
| 3 | 6adc3339e32094bf1b6e3837daf1ee04 |
d155070019005505080000000000… |
LiveReading with status: 0, rapid_acceleration: 0, rapid_deceleration: 0, otherwise as above |
| 4 | 18e26d814ecb10ef977633c9fa77808b |
4e0000aa fffefe00… |
block below |
{"records": [
{"time": "2023-11-14T22:09:00Z", "voltage": 12.48, "soc": 0, "temperature": -10, "mark": "start"},
{"time": "2023-11-14T22:11:00Z", "voltage": 12.64, "soc": 80, "temperature": 20, "mark": null},
{"time": "2023-11-14T22:13:00Z", "voltage": 12.80, "soc": 85, "temperature": 25, "mark": "stop"}
]}
Synthetic: base time on an even minute
received_at = 1700000040 (22:14:00Z).
| # | Encrypted frame | Plaintext | Expected |
|---|---|---|---|
| 1 | 2e6a4e19b27a6a3332696e9d43e6a916 |
d155050001… |
nothing |
| 2 | 44b1770f090e4520f8e324ed87101c66 |
50055190 fffefe00… |
one record, time: 2023-11-14T22:13:00Z, voltage: 12.80, soc: 85, temperature: 25, mark: null |
Synthetic: FEFEFE frame is discarded
received_at = 1700000000.
| # | Encrypted frame | Plaintext | Expected |
|---|---|---|---|
| 1 | 2e6a4e19b27a6a3332696e9d43e6a916 |
d155050001… |
nothing |
| 2 | 7e5cb931800dbc0c789ccd531167dc82 |
fefefe00… |
nothing |
| 3 | 44b1770f090e4520f8e324ed87101c66 |
50055190 fffefe00… |
one record, same values as the previous case |
Session order
A compliant session writes exactly this to FFF3 when since is null. Replying to each write with a scripted fake tag is an easy test:
VERSION, ACCEPT, HISTORY(10), HISTORY(22320), LIVE
Hardware validation
Tested 2026-10-31 on one tag (firmware 1.8) fitted to a Prius, from macOS. For the Python implementation, run python -m unittest test_captures in findings/mac_test.
- Session: the section 4 order worked.
LIVEstreams at 1 frame per second withoutD15503orD15508. - Crypto: padding produces the same ciphertext block repeatedly within a 160-byte frame, which confirms the IV resets per block.
- History units: 22320 units returned 21974 records and 346 stops, which sum exactly to the request.
- History depth: a request for 51840 returned 50070 records and 737 stops, 50807 units, reaching back about 69.5 days. Because the tag returned less than requested, a larger request is unlikely to add anything, and none was tried. It isn't known whether that is the tag's capacity or just how long it has been logging.
- Repeat reads: two reads 14 minutes apart agreed on 21973 of the 21974 overlapping records. Derived times for the same record differed by one slot (2 minutes) across the whole overlap, as section 5 warns.
- Observed history values: 2026-08-23 to 2026-11-01 UTC, median voltage 12.55 to 12.64 V for each of August to October, temperature 10 to 36.
- Bit errors: 110 records in the 22320 read had an impossible voltage or state of charge, all between 2026-10-04 and 2026-10-05, and none appeared in the rest of the 69.5 days. A cipher error in transit would garble a whole block, so the errors arise on the tag. 109 repeated identically on the second read, but one changed from 2.30 V to 12.54 V (
0x0E6to0x4E6), so they aren't all fixed in storage. Errors in that window can also produce plausible wrong values, and a flipped flag bit can create a false stop mark, which would swallow the next record and shift older timestamps by one slot.
Hybrids. On a Prius, voltage doesn't show vehicle state. It has no alternator, and the DC-DC converter can drop to about 12 V while active. Don't present status or start/stop marks as engine state: they are untested against known vehicle state, and 73% of start marks were at 13.5 V or above, which suggests they may follow voltage. State of charge is the tag's own estimate and hasn't been validated on this car.
Unverified
- Whether
ACCEPTis needed. It is included because the app sends it when offline. - Why a read stops short of the request, and what the header's 51792 means.
- Live
statusand acceleration counts while driving, and the temperature unit. - Negative temperatures. The captured range was 10 to 28.
- Single connection. Assume the tag serves one central at a time, so close the vendor app during testing.
- The
BM200Clientreconnect loop. The captures came fromprobe.py, which has its own session code.