Files
bm200/BM200_PROTOCOL.md
Jeff Leung 43032dd845 Verify history depth with a 51840 read
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>
2026-10-31 20:28:54 -07:00

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 as 0x0E0 (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 with since.
  • 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. LIVE streams at 1 frame per second without D15503 or D15508.
  • 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 (0x0E6 to 0x4E6), 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

  1. Whether ACCEPT is needed. It is included because the app sends it when offline.
  2. Why a read stops short of the request, and what the header's 51792 means.
  3. Live status and acceleration counts while driving, and the temperature unit.
  4. Negative temperatures. The captured range was 10 to 28.
  5. Single connection. Assume the tag serves one central at a time, so close the vendor app during testing.
  6. The BM200Client reconnect loop. The captures came from probe.py, which has its own session code.