BullTron BLE Commands
Reverse-engineering notes for the BullTron Android app BLE protocol.
This repository documents the custom BLE GATT protocol used by the BullTron Android app to read battery telemetry such as SOC, current, voltage, remaining capacity, and estimated time-to-empty/time-to-full.
Target App
- App label: BullTron
- Package:
com.inuker.bluetooth.bulltron - Version analyzed:
1.1.33/1133001 - XAPK source: APKPure direct download
- XAPK SHA-256:
35388e6c2b9afeeb4bafd97df09fcb1e33427802dd98f74a2a4648099dda71ff - Base APK SHA-256:
f1d486b410c1fa2fae78837fcc27a63aa16c6cb1b45f096a17c9968908add363 - Signer CN:
smart_daly - Signer cert SHA-256:
996caf450ecdb34d0ba45d2b2c6a2df56243338c7be3b7b497168af4e3d00ae4
The XAPK was unpacked and the base APK code was checked against a separately mirrored PGYER APK. All three DEX files matched by SHA-256. The APK was decompiled with apktool and JADX; a few mappings were cross-checked against smali where JADX reported failed methods.
Main Finding
Normal telemetry does not use the standard BLE Battery Service. The app connects as a BLE GATT client, subscribes to a custom BullTron/Daly-style service, writes Modbus-like command frames to a write characteristic, then parses notify frames from a read/notify characteristic.
Android pairing/bonding does not appear to be required for normal telemetry. The
core BullTron path uses connect, MTU request, service discovery, notifications,
and writes. Generic library code contains bonding helpers, but the normal BMS
telemetry path does not call createBond().
Quick Start
Build the main live-data read frame:
python3 tools/bulltron_frame.py read 0 62
Expected output:
D2030000003ED7B9
Send that frame to characteristic 0000fff2-0000-1000-8000-00805f9b34fb
after subscribing to notifications on
0000fff1-0000-1000-8000-00805f9b34fb.
App Workflow
The app's normal telemetry flow is:
- Scan/select a BLE device in the app UI.
- Connect with the Inuker BLE client wrapper.
- Request MTU
512. - Discover services and find
0000fff0-0000-1000-8000-00805f9b34fb. - Subscribe to notifications on
fff1. - Write ASCII
HiLinkto the secret-key characteristic02f00000-0000-0000-0000-00000000ff05. - If the key readback is
0100, query version/band information with AT-style commands. - Write phone time to BMS registers with a
D210multi-register write at register0x00D4. - Periodically write
D203read frames tofff2. - Receive notifications on
fff1, split/buffer frames, validate CRC, parse 16-bit big-endian words, and update the UI.
Minimal BLE Workflow
For a custom client that only needs live telemetry:
-
Connect to the device over BLE GATT.
-
Request a large MTU if possible. The app requests
512. -
Discover services.
-
Subscribe to notifications on
fff1under servicefff0. -
Write ASCII
HiLinkto02f...ff05. -
Optionally query version with ASCII
AT+VER=?\r\non02f...ff04. -
Write the main read frame to
fff2:D2030000003ED7B9 -
Parse the notify response from
fff1.
The expected live-data response has byte count 0x7C, meaning 124 bytes / 62
16-bit words:
D203 7C <62 words> CRC
This workflow has now been confirmed in a real Android HCI snoop capture. The
capture shows the app using handle 0x0015 (fff2) for D203 command writes
and handle 0x0012 (fff1) for D203 notifications. The observed server MTU
is 247 after the app requests 517.
Live Telemetry Map
The main read command reads registers 0x0000..0x003D:
D203 0000 003E CRC
Important fields:
| Register | Meaning | Formula |
|---|---|---|
0x0000-0x001F |
Cell voltages | raw * 0.001 V |
0x0020-0x0027 |
Battery temperatures | raw - 40 deg C |
0x0028 |
Pack voltage | raw * 0.1 V |
0x0029 |
Current | (raw - 30000) * 0.1 A, with app-specific correction for two voltage profiles |
0x002A |
SOC / battery percent | raw / 10 % |
0x002F |
MOS / direction state | 1 = charging, 2 = discharging |
0x0030 |
Remaining capacity | raw * 0.1 Ah |
0x0033 |
Cycle count | raw count |
0x0035 |
Charge MOS | 1 = on |
0x0036 |
Discharge MOS | 1 = on |
0x003A-0x003D |
Alarm/status words | 16-bit bitmaps |
Time-to-empty/time-to-full is computed by the app instead of read as one live BLE value:
if register 0x002F == 1:
time-to-full = rated_capacity_Ah * (100 - SOC_percent) / abs(current_A) * 60
if register 0x002F == 2:
time-to-empty = rated_capacity_Ah * SOC_percent / abs(current_A) * 60
Repository Contents
docs/ble-flow.md- detailed connection/setup/query workflow, pairing answer, and value derivation.docs/command-catalog.md- services, characteristics, frame formats, observed commands, and telemetry register map.docs/live-capture.md- Android HCI snoop confirmation of handles, commands, response byte counts, and decoded live telemetry.evidence/apk-info.md- APK/XAPK provenance, hashes, signer info, and decompilation notes.tools/bulltron_frame.py- helper for building read/write frames with the same CRC format used by the app.tools/extract-ble-symbols.py- helper for scanning JADX/apktool output for BLE UUIDs and GATT calls.
Caveats
These findings are derived from the decompiled Android app, not from a live BLE capture. A capture is still useful to confirm firmware-specific behavior, current sign conventions, correction edge cases, and whether a specific battery requires link-layer encryption despite the app not initiating pairing itself.