Files
bulltron-ble-commands/README.md
T

160 lines
5.6 KiB
Markdown

# 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:
```sh
python3 tools/bulltron_frame.py read 0 62
```
Expected output:
```text
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:
1. Scan/select a BLE device in the app UI.
2. Connect with the Inuker BLE client wrapper.
3. Request MTU `512`.
4. Discover services and find `0000fff0-0000-1000-8000-00805f9b34fb`.
5. Subscribe to notifications on `fff1`.
6. Write ASCII `HiLink` to the secret-key characteristic
`02f00000-0000-0000-0000-00000000ff05`.
7. If the key readback is `0100`, query version/band information with AT-style
commands.
8. Write phone time to BMS registers with a `D210` multi-register write at
register `0x00D4`.
9. Periodically write `D203` read frames to `fff2`.
10. 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:
1. Connect to the device over BLE GATT.
2. Request a large MTU if possible. The app requests `512`.
3. Discover services.
4. Subscribe to notifications on `fff1` under service `fff0`.
5. Write ASCII `HiLink` to `02f...ff05`.
6. Optionally query version with ASCII `AT+VER=?\r\n` on `02f...ff04`.
7. Write the main read frame to `fff2`:
```text
D2030000003ED7B9
```
8. Parse the notify response from `fff1`.
The expected live-data response has byte count `0x7C`, meaning 124 bytes / 62
16-bit words:
```text
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`:
```text
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:
```text
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.