Reverse-engineering the Haicen / Chiptrip 12 V LiFePO4 BMS over BLE

How we decoded the Modbus-over-BLE protocol used by the HC BMS Android app, including the GATT layout, the six polling requests, frame formats, and the OTA bootloader trap that almost bricked our session.

#projects #software #reverse-engineering #bluetooth #bms #lifepo4 #esp32

A.k.a. “BT_TS-prefix” smart batteries, Android app HC BMS (package com.chiptrip.monitorbattery, namespace cn.com.chiptrip.handerstarbms).

The same physical battery is sold under different brands; if your battery’s BLE name starts with BT_TS, the official Android app is “HC BMS”, and the manufacturer string in GATT reads Silicon Labs, this is the same protocol.

I have a LiFePO4 battery at my cabin and I wanted to be able to monitor the state of the battery (along with my Victrion MPPT charger) to keep an eye on it. The battery does come with a CAN port, but who like cables? So, the quest was clear; figure out how to poll the battery using BLE! I loaded up Claude and we got to work (mostly Claude, but I like to pretend I contributed).

This post describes the on-the-wire BLE protocol that the HC BMS Android app uses to read and configure these batteries, and how to talk to the BMS yourself from any BLE central (ESP32, Raspberry Pi, etc.).


TL;DR

  • Transport: Nordic UART Service (6e400001-…), the BMS exposes the standard NUS UUIDs and you write to 6e400002 / get notifications on 6e400003.
  • Wire format: plain Modbus RTU (slave 0x01, function 0x03 “read holding registers”), little-endian CRC-16/MODBUS on the wire, big-endian 16-bit registers in the data portion.
  • The BMS does not stream unprompted. You must poll it. The HC BMS app polls 6 register blocks once a second.
  • One thing not to do: don’t write to f7bf3564-fb6d-4e53-88a4-5e37e0326063. That’s the Silicon Labs OTA control characteristic, and writing anything to it boots the BMS into the OTA loader. (Recoverable with a battery power-cycle, but easy to confuse for a “magic wakeup” byte.)

The hardware

  • Device name: BT_TS<voltage><Ah>_<serial>, e.g. BT_TS12V125_0056 for a 12 V / 125 Ah unit.
  • Manufacturer (0x2A29): Silicon Labs
  • Hardware revision (0x2A27): V1.1 2021-01-18
  • Firmware revision (0x2A26): V0003,Dec 21 2022,14:01:04
  • System ID (0x2A23): the public BLE address with FF:FE inserted in the middle (standard BT pattern).

The BLE radio is a Silicon Labs EFR32, almost certainly bridged over UART to a separate BMS MCU that speaks Modbus RTU. The BLE side is essentially a transparent Modbus-over-BLE pipe.

GATT layout

Generic Attribute (0x1801)
  Service Changed                       0x2A05  [I]   + 0x2902
Generic Access (0x1800)
  Device Name                           0x2A00  [RW]
  Appearance                            0x2A01  [R]
Device Information (0x180A)
  Manufacturer Name String              0x2A29  [R]
  System ID                             0x2A23  [R]
  Firmware Revision String              0x2A26  [R]
  Hardware Revision String              0x2A27  [R]

Nordic UART Service (6e400001-b5a3-f393-e0a9-e50e24dcca9e)
  TX                                    6e400003-…  [N R]
                                          + 0x2902 (CCCD)
                                          + 0x2901 (Char User Description)
  RX                                    6e400002-…  [R W WNR]
                                          + 0x2901
  (unused)                              6e400004-…  [I R]
  (unused)                              6e400005-…  [N W WNR]

Silicon Labs OTA service (1d14d6ee-fd63-4fa1-bfa4-8f47b42119f0)
  OTA control                           f7bf3564-fb6d-4e53-88a4-5e37e0326063  [W]
  ⚠️  do not write, puts the BMS into OTA bootloader mode

You need exactly two characteristics for normal monitoring:

  • RX (6e400002-b5a3-f393-e0a9-e50e24dcca9e), the central writes Modbus requests here. Use write-without-response.
  • TX (6e400003-b5a3-f393-e0a9-e50e24dcca9e), subscribe with notifications enabled (write 0x0001 to its 0x2902 CCCD descriptor). The BMS sends Modbus responses here.

0x180A reads are nice-to-haves for telemetry (firmware version etc.). The other NUS characteristics and the OTA service are not needed.

The wire protocol

It’s Modbus RTU, just shipped over BLE notifies/writes instead of an RS-485 pair.

Request (central → BMS, written to RX)

01  03  <start_hi>  <start_lo>  <count_hi>  <count_lo>  <crc_lo>  <crc_hi>
└┬┘ └┬┘ └─────┬───────────────┘ └─────┬───────────────┘ └────┬────────┘
 │   │       starting register      number of 16-bit       CRC-16/MODBUS
 │   │       (big-endian)           registers (BE)         over the first
 │   │                                                     6 bytes,
 │   │                                                     low byte first
 │   └─ function 0x03 = "read holding registers"
 └───── slave address; the BMS always answers as slave 0x01

Eight bytes per request. Always write-without-response (the reply comes back as a notification on TX).

Response (BMS → central, notified on TX)

01  03  <byte_count>  <data_byte ×N>  <crc_lo>  <crc_hi>
                      ^^^^^^^^^^^^^^^
                      N = byte_count = 2 × number_of_registers

byte_count is one byte; the data portion is then byte_count bytes; followed by the standard Modbus CRC. Inside the data portion, registers are 16-bit big-endian (network byte order).

The response can arrive split across multiple BLE notifications because the per-frame BLE payload is MTU − 3 ≤ 244 bytes. The biggest response (Frame A, 81 bytes total) does fit in one notify if you’ve negotiated MTU 247, but smaller MTUs will fragment it. Reassemble by buffering bytes and consuming one full Modbus frame at a time.

CRC

Standard CRC-16/MODBUS: poly 0xA001 (reflected 0x8005), init 0xFFFF, no final XOR, little-endian on the wire. Reference C:

static uint16_t modbus_crc16(const uint8_t *data, size_t len) {
    uint16_t crc = 0xFFFF;
    for (size_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (int b = 0; b < 8; b++) {
            crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : (crc >> 1);
        }
    }
    return crc;
}

The six polls

These are the exact requests the HC BMS app sends, in order, once per cycle. Most of the polls have static CRCs because the request bytes are constant, but you should compute them at runtime so you can read other register blocks without thinking.

#StartCount (regs)Hex on the wire (8 bytes)Response dataContains
10xD00038 (0x0026)01 03 D0 00 00 26 FC D076 bytescell voltages + pack stats (Frame A)
20xD02625 (0x0019)01 03 D0 26 00 19 5D 0B50 bytesSOC, SOH, capacities, cell count (Frame B)
30xD10021 (0x0015)01 03 D1 00 00 15 BD 3942 bytesFrame D, first ~6 bytes look like timestamps/temps
40xD11512 (0x000C)01 03 D1 15 00 0C 6D 3724 bytesFrame C, status / cycle counters (TODO)
50xD2001 (0x0001)01 03 D2 00 00 01 BD 722 bytesFrame F, single status flag (TODO)
60x23184 (0x0004)01 03 23 18 00 04 CF 8A8 bytesFrame E, small status / config (TODO)

The HC BMS app additionally has Modbus 0x06 (write single register) commands hard-coded for changing alarm thresholds, e.g. 01 06 11 02 00 0A … writes 0x000A to register 0x1102. Not needed for read-only monitoring; documented here for completeness.

Frame A, cell voltages + pack stats (0xD000, 38 registers, 76 bytes data)

Big-endian 16-bit registers throughout.

Reg indexByte offsetField
0..30..7individual cell voltages in mV (one per used cell)
4..318..630xEE49 sentinel marking unused cell slots, the BMS firmware supports up to 30 cells; iterate 0xEE49 to find the boundary
3264..65max cell mV
3366..67min cell mV
3468..69max-cell index
3570..71(purpose unconfirmed; usually equals cell-count)
3672..73cell count in use
3774..75pack voltage in 10 mV units (1339 → 13.39 V)

To find the cell count without trusting reg 36, just scan from index 0 forward and stop when you see 0xEE49, that’s how the firmware encodes “no cell here”.

Sample on a 12 V / 4-cell pack:

cells in use:  4
cell mV:       3349  3349  3351  3346
max cell mV:   3351 (idx 3)
min cell mV:   3346
pack voltage:  1339 (×10 mV) = 13.39 V

Five-millivolt spread across all four cells, typical of a healthy LiFePO4 stack with a working balancer.

Frame B, SOC and capacity (0xD026, 25 registers, 50 bytes data)

Field offsets are byte offsets within the data portion (i.e. immediately after the 01 03 32 header). All BE 16-bit. Most other bytes in the frame are zero / reserved.

Byte offsetFieldUnits
28..29SOCpercent (0..100)
32..33remaining capacity× 100 (Ah) → divide by 100
34..35rated capacity× 100 (Ah)
36..37full-charge capacity× 100 (Ah)
38..39cell countint

Sample:

SOC:                  98 %
remaining capacity:   12307 → 123.07 Ah
rated capacity:       12500 → 125.00 Ah
full-charge capacity: 12500 → 125.00 Ah
cell count:           4

Other registers in this frame change over time but their meaning is not yet pinned down. Bytes [22..29] carry a slowly-varying field that looks like instantaneous current, for a monitor that just wants SOC/V/cap, it’s not load-bearing.

Frames C, D, E, F, partially decoded

Confirmed contents from live captures, but the field meanings still need a couple of stationary-vs-loaded captures to nail down.

FrameStartBytes dataFirst few bytes (sample)
C0xD1152424 0C 00 00 02 A7 00 00 00 00 …
D0xD1004212 0A 1F 17 3B 1E 00 00 …, looks like packed timestamp or sensor list
E0x23188small status / config
F0xD200200 00, probably an alarm flag bit-field

If you hook these up to a load and a charger and watch which fields move in step with current, it’s straightforward to map them. The reference firmware just hex-dumps them for now.

Connecting and starting the poll cycle (the things that don’t matter)

These were dead ends during reverse engineering, listed here so you don’t repeat them:

  • Connection parameters: anything reasonable works. We use min 30 ms, max 50 ms, latency 0, supervision 6 s, but 15 ms or 100 ms intervals also worked once we sent the right payloads.
  • MTU: anything from 23 to 247 works; the BMS will fragment large responses into multiple notifications.
  • CCCD subtleties: a plain “write 0x0001 to TX’s 0x2902” is enough. The HC BMS app also writes to 0x2901 (Char User Description, normally read-only) inside its enableNotify loop, but that write fails on this BMS too, it’s not the trigger for streaming, it just looks like one.
  • Bonding / pairing: not required. The BMS happily talks to any unbonded central.

The actual gating thing is just: subscribe to TX, then write a Modbus 0x03 request to RX, and read responses on TX.

ESP32 reference implementation

The full firmware does:

  1. Scan for the BMS by name (BT_TS12V125_…) or known address.
  2. Connect with NimBLE-Arduino, negotiate MTU 247.
  3. Subscribe to TX (6e400003). For paranoia it also writes [0x01, 0x00] to every descriptor of TX, mirroring HC BMS’s enableNotify. (As noted, this isn’t required, but harmless.)
  4. Loop: send one of the 6 polls per second to RX, reassemble Modbus frames from incoming notifications, decode Frame A and Frame B into structured fields and print them.

The key bits:

// Build a Modbus 0x03 read-holding-registers request.
static void buildModbusRead(uint16_t start, uint16_t count, uint8_t out[8]) {
    out[0] = 0x01;                 // slave
    out[1] = 0x03;                 // function: read holding registers
    out[2] = (start >> 8) & 0xFF;
    out[3] = start & 0xFF;
    out[4] = (count >> 8) & 0xFF;
    out[5] = count & 0xFF;
    uint16_t crc = modbusCrc16(out, 6);
    out[6] = crc & 0xFF;           // CRC low first
    out[7] = (crc >> 8) & 0xFF;
}
// In the notify callback, append received bytes to a buffer and consume
// one complete Modbus frame at a time. byte_count + 5 = total frame length.
static bool tryConsumeFrame(uint8_t *buf, size_t &len) {
    if (len < 5) return false;
    if (buf[0] != 0x01 || buf[1] != 0x03) {            // resync on misalignment
        memmove(buf, buf + 1, --len);
        return true;
    }
    size_t total = 3 + buf[2] + 2;                     // header + data + CRC
    if (len < total) return false;                     // wait for more

    uint16_t crc_calc = modbusCrc16(buf, 3 + buf[2]);
    uint16_t crc_wire = buf[3 + buf[2]] | (buf[4 + buf[2]] << 8);
    if (crc_calc == crc_wire) {
        decodeFrame(buf[2], buf + 3, buf[2]);          // dispatch by byte_count
    }
    memmove(buf, buf + total, len - total);            // shift buffer down
    len -= total;
    return true;
}

PlatformIO platformio.ini:

[env:esp32dev]
platform = espressif32@^6.7.0
board = esp32dev
framework = arduino

monitor_speed = 115200
monitor_filters = time, esp32_exception_decoder
upload_speed = 460800

build_flags =
    -DCORE_DEBUG_LEVEL=1
    -DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=1     ; only ever talk to one BMS
    -DCONFIG_NIMBLE_CPP_LOG_LEVEL=0
    -DFW_VERSION=\"0.4.0\"

lib_deps =
    h2zero/NimBLE-Arduino@^1.4.2
    bblanchon/ArduinoJson@^7.2.0

How we figured this out (the meandering bit)

In case you’re trying to do something similar with a different BMS, here’s what worked and what didn’t, in roughly chronological order:

  1. GATT walk. Connected with a generic BLE explorer firmware, listed every service and characteristic. Saw the Nordic UART UUIDs immediately, encouraging, and a custom 1d14d6ee-… service. We probed with known BMS commands (JBD, Daly, JK) sent to every writable characteristic… and accidentally bricked our session by writing a Daly probe to the OTA control char, which booted the BMS into the loader. Lesson: any write-only characteristic in a custom service is suspect; look up its UUID before poking it. (1d14d6ee / f7bf3564 are the Silicon Labs OTA UUIDs, well-known once you know to search.)

  2. Looked at NUS reads for clues. Reading 6e400002’s value returned a stale Modbus-shaped blob (01 03 D0 …) left over from the HC BMS app’s last session. That correctly suggested Modbus, but seeing it on the write characteristic was misleading, it’s just the last-written value getting read back, not the BMS streaming.

  3. Passive listen. Subscribed to every notify/indicate channel and waited 90 s. Zero notifies. First confirmation that the BMS doesn’t broadcast unprompted.

  4. Tried Android HCI snoop log. On this user’s OPPO phone, the HCI snoop binary files (btsnoop_hci.log) inside the bug-report zip were 16-byte-empty shells; the only Bluetooth logs were OPPO’s verbose stack-debug text logs with MAC addresses redacted to xx:xx:xx:xx:b8:28 and no payload bytes. Snoop logging on OPPO/OnePlus stock ROMs is broken or filtered enough to be useless for protocol work. If your phone isn’t a Pixel, plan on the snoop-log option not working.

  5. nRF Connect “debug attach” on Android. This was the breakthrough for seeing the protocol in flight: open the HC BMS app, then switch to nRF Connect and tap “Debug” on the live connection. nRF logs every notify with full payload bytes. We saw 6 distinct response shapes cycling every ~10 s, the Modbus responses were obvious from the framing, but we had no record of what the requests were because nRF debug only mirrors the foreign app’s GATT operations and apparently misses descriptor writes / RX writes.

  6. Decompiled the APK. Pulled com.chiptrip.monitorbattery off the phone with adb pull and ran androguard on it. (No need for jadx + JDK; androguard is pip-installable and gives you Smali bytecode.) A strings-equivalent over classes.dex found exactly the static hex byte strings used as Modbus templates: 01 03 D0 00 00 26 FC D0, 01 03 D0 26 00 19 5D 0B, etc., and BleService.writeDataCrc is the method that sends them, with CRC recomputed at runtime. Plugging those into the firmware made everything light up.

The two main red herrings along the way:

  • BleService.queryDevice(I, I, I) in the smali, which builds a 7-byte vendor-specific frame AA 55 <ADDR> 00 <ADDR> 0A 0D and is wired up via the message-handler dispatch on msg.what == 7. We thought this was the polling loop. The BMS does respond to those frames, but only with a 5-byte stub 86 02 92 61 00 that happens to encode the SOC byte. The real protocol is plain Modbus via writeDataCrc; queryDevice is dead/legacy code or a secondary protocol the live app doesn’t use.
  • BleService.enableNotify’s “write to all descriptors” loop, which writes [0x01, 0x00] to both 0x2902 and 0x2901. We thought this was a hidden trigger that flipped the BMS into stream mode. It isn’t, the writes to 0x2901 fail with “Write Not Permitted” on every central, and the BMS streams or doesn’t stream regardless of whether you do it.

Searchable keywords for the next person

If you found this looking for any of these, you’re in the right place:

Haicen Chiptrip HanderStar BT_TS12V125 BT_TS12V100 HC BMS Android app cn.com.chiptrip.handerstarbms com.chiptrip.monitorbattery Silicon Labs BLE BMS 6e400001-b5a3-f393-e0a9-e50e24dcca9e Nordic UART Service Modbus RTU LiFePO4 12 V smart battery reverse engineering ESP32 NimBLE.

Acknowledgements

The decompilation route follows the same playbook the BMS_BLE-HA and aiobmsble authors have used to onboard 40+ BMS types, recommended reading if you’re trying to add this BMS as a new plugin there. The ABC-BMS / SOK reverse-engineering thread was also useful as a worked example of a vendor-specific BMS protocol over BLE.