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.
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 to6e400002/ get notifications on6e400003. - Wire format: plain Modbus RTU (slave
0x01, function0x03“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_0056for 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 withFF:FEinserted 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 (write0x0001to its0x2902CCCD 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.
| # | Start | Count (regs) | Hex on the wire (8 bytes) | Response data | Contains |
|---|---|---|---|---|---|
| 1 | 0xD000 | 38 (0x0026) | 01 03 D0 00 00 26 FC D0 | 76 bytes | cell voltages + pack stats (Frame A) |
| 2 | 0xD026 | 25 (0x0019) | 01 03 D0 26 00 19 5D 0B | 50 bytes | SOC, SOH, capacities, cell count (Frame B) |
| 3 | 0xD100 | 21 (0x0015) | 01 03 D1 00 00 15 BD 39 | 42 bytes | Frame D, first ~6 bytes look like timestamps/temps |
| 4 | 0xD115 | 12 (0x000C) | 01 03 D1 15 00 0C 6D 37 | 24 bytes | Frame C, status / cycle counters (TODO) |
| 5 | 0xD200 | 1 (0x0001) | 01 03 D2 00 00 01 BD 72 | 2 bytes | Frame F, single status flag (TODO) |
| 6 | 0x2318 | 4 (0x0004) | 01 03 23 18 00 04 CF 8A | 8 bytes | Frame 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 index | Byte offset | Field |
|---|---|---|
| 0..3 | 0..7 | individual cell voltages in mV (one per used cell) |
| 4..31 | 8..63 | 0xEE49 sentinel marking unused cell slots, the BMS firmware supports up to 30 cells; iterate 0xEE49 to find the boundary |
| 32 | 64..65 | max cell mV |
| 33 | 66..67 | min cell mV |
| 34 | 68..69 | max-cell index |
| 35 | 70..71 | (purpose unconfirmed; usually equals cell-count) |
| 36 | 72..73 | cell count in use |
| 37 | 74..75 | pack 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 offset | Field | Units |
|---|---|---|
| 28..29 | SOC | percent (0..100) |
| 32..33 | remaining capacity | × 100 (Ah) → divide by 100 |
| 34..35 | rated capacity | × 100 (Ah) |
| 36..37 | full-charge capacity | × 100 (Ah) |
| 38..39 | cell count | int |
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.
| Frame | Start | Bytes data | First few bytes (sample) |
|---|---|---|---|
| C | 0xD115 | 24 | 24 0C 00 00 02 A7 00 00 00 00 … |
| D | 0xD100 | 42 | 12 0A 1F 17 3B 1E 00 00 …, looks like packed timestamp or sensor list |
| E | 0x2318 | 8 | small status / config |
| F | 0xD200 | 2 | 00 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
0x0001to TX’s0x2902” is enough. The HC BMS app also writes to0x2901(Char User Description, normally read-only) inside itsenableNotifyloop, 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:
- Scan for the BMS by name (
BT_TS12V125_…) or known address. - Connect with NimBLE-Arduino, negotiate MTU 247.
- Subscribe to TX (
6e400003). For paranoia it also writes[0x01, 0x00]to every descriptor of TX, mirroring HC BMS’senableNotify. (As noted, this isn’t required, but harmless.) - 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:
-
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/f7bf3564are the Silicon Labs OTA UUIDs, well-known once you know to search.) -
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. -
Passive listen. Subscribed to every notify/indicate channel and waited 90 s. Zero notifies. First confirmation that the BMS doesn’t broadcast unprompted.
-
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 toxx:xx:xx:xx:b8:28and 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. -
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.
-
Decompiled the APK. Pulled
com.chiptrip.monitorbatteryoff the phone withadb pulland ran androguard on it. (No need for jadx + JDK; androguard is pip-installable and gives you Smali bytecode.) Astrings-equivalent overclasses.dexfound 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., andBleService.writeDataCrcis 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 frameAA 55 <ADDR> 00 <ADDR> 0A 0Dand is wired up via the message-handler dispatch onmsg.what == 7. We thought this was the polling loop. The BMS does respond to those frames, but only with a 5-byte stub86 02 92 61 00that happens to encode the SOC byte. The real protocol is plain Modbus viawriteDataCrc;queryDeviceis 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 both0x2902and0x2901. We thought this was a hidden trigger that flipped the BMS into stream mode. It isn’t, the writes to0x2901fail 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.