Document BullTron BLE commands

This commit is contained in:
2026-07-10 11:31:55 +02:00
commit 3675220179
8 changed files with 548 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
decompiled/
apk/
*.apk
*.apks
*.xapk
*.dex
*.jar
*.class
+31
View File
@@ -0,0 +1,31 @@
# BullTron BLE Commands
Reverse-engineering notes for the BullTron Android app BLE protocol.
## Target App
- App label: BullTron
- Package: `com.inuker.bluetooth.bulltron`
- Version analyzed: `1.1.33` / `1133001`
- APK source: PGYER old-version download
- APK SHA-256: `f11c49499cf226ccd6dc45e8f1780c11c4d84d785846182781886ab135b9a476`
- Signer CN: `smart_daly`
- Signer cert SHA-256: `996caf450ecdb34d0ba45d2b2c6a2df56243338c7be3b7b497168af4e3d00ae4`
The APK was decompiled with apktool and JADX. JADX reported some failed methods, so a few UI mappings were cross-checked against smali.
## 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 frames to a write characteristic, then parses notify frames from a read/notify characteristic.
Android pairing/bonding is not explicitly required in the app's normal BMS flow. The core path uses `connect`, MTU request, service discovery, notifications, and writes. Generic library code contains bonding helpers, but the BullTron telemetry path does not call `createBond()`.
## Documents
- `docs/ble-flow.md` - connection/setup/query flow, pairing answer, and value derivation
- `docs/command-catalog.md` - services, characteristics, command frames, and telemetry register map
- `tools/extract-ble-symbols.py` - helper for scanning JADX/apktool output for BLE UUIDs and GATT calls
## Status
The local repo contains the reverse-engineering results. Pushing to `git@git.mannheim.ccc.de:C3MA/bulltron-ble-commands.git` is currently blocked because C3MA Gitea does not allow organization push-to-create for this repo. Create an empty `C3MA/bulltron-ble-commands` repository or provide a token with repo creation rights, then push `main`.
+111
View File
@@ -0,0 +1,111 @@
# BullTron BLE Flow
This is the app flow used to obtain battery/controller information such as discharge current, SOC, voltage, and time-to-empty.
## Evidence
- App package: `com.inuker.bluetooth.bulltron`
- Version: `1.1.33`
- Core classes:
- `com.inuker.bluetooth.data.BluetoothRegulate`
- `com.inuker.bluetooth.data.CreateCtrDataHelper`
- `com.inuker.bluetooth.data.DataAnalysisHelper`
- `com.inuker.bluetooth.data.DeviceWorkInfo`
- `com.inuker.bluetooth.NEWBLE.MainControlActivity`
- `com.inuker.bluetooth.NEWBLE.LiveDataActivity`
## Connection Sequence
1. Select/scan a BLE device in the app UI.
2. `BluetoothRegulate.connectDevice()` calls the Inuker BLE client connect wrapper.
3. On successful connect, the app requests MTU `512`.
4. It discovers services and looks for the custom BMS service `0000fff0-0000-1000-8000-00805f9b34fb`.
5. It subscribes to notifications on `fff1` and also registers notify callbacks on the write/auxiliary characteristics it uses.
6. When the main notification subscription is ready, it writes the ASCII secret `HiLink` to the secret-key characteristic.
7. If the key readback is `0100`, it queries version and band information with AT-style commands.
8. It writes the current phone time to BMS registers using a `D210` multi-register write at `0x00D4`.
9. Main screens periodically send `D203` read frames. Notifications are buffered, frame-split, CRC checked, parsed, then broadcast to UI screens.
## Pairing / Bonding
- Needs Android bond before normal telemetry: likely no.
- Evidence: the BullTron path uses connect, MTU request, service discovery, notifications, and writes. I did not find an explicit `createBond()` call in the core `BluetoothRegulate` BMS flow.
- Caveat: generic third-party/library code bundled in the APK contains bonding helpers. A peripheral could still enforce encryption at firmware level, but the app itself does not start pairing as a required normal step.
- App-level setup/auth: yes, but lightweight. It writes ASCII `HiLink` to the secret-key characteristic before version/band setup.
## Services And Characteristics Used
The main BMS GATT surface is:
- Service: `0000fff0-0000-1000-8000-00805f9b34fb`
- Notify/read: `0000fff1-0000-1000-8000-00805f9b34fb`
- Write: `0000fff2-0000-1000-8000-00805f9b34fb`
- Name/AT command: `0000fff3-0000-1000-8000-00805f9b34fb`
The app also uses an auxiliary/version/OTA surface:
- Service: `02f00000-0000-0000-0000-00000000fe00`
- Version read/write: `02f00000-0000-0000-0000-00000000ff04`
- Secret key: `02f00000-0000-0000-0000-00000000ff05`
- OTA notify/read: `02f00000-0000-0000-0000-00000000ff02`
- OTA write: `02f00000-0000-0000-0000-00000000ff01`
## Getting Live Values
For the main live view, `MainControlActivity` sends:
```text
read registers 0x0000..0x003D:
D203 0000 003E CRC16
```
The BMS replies on `fff1` with a `D203` frame whose byte-count is `0x7C`, meaning 62 16-bit words. `DataAnalysisHelper` dispatches that to `DeviceWorkInfo.analysisRunDataInfo()`.
Important fields in that 62-word response:
| Register | Word index | Meaning | Formula |
| ---: | ---: | --- | --- |
| `0x0000`-`0x001F` | 0-31 | cell voltages | `raw * 0.001 V` |
| `0x0020`-`0x0027` | 32-39 | battery temperatures | `raw - 40 deg C` |
| `0x0028` | 40 | pack voltage | `raw * 0.1 V` |
| `0x0029` | 41 | current | `(raw - 30000) * 0.1 A`, with app-specific correction for two voltage profiles |
| `0x002A` | 42 | SOC / battery percent | `raw / 10 %` |
| `0x002B` | 43 | highest cell voltage | `raw * 0.001 V` |
| `0x002C` | 44 | lowest cell voltage | `raw * 0.001 V` |
| `0x002F` | 47 | MOS / direction state | `1 = charging/time-to-full`, `2 = discharging/time-to-empty` |
| `0x0030` | 48 | remaining capacity | `raw * 0.1 Ah` |
| `0x0032` | 50 | temperature sensor count | used to average temp values |
| `0x0033` | 51 | cycle count | raw count |
| `0x0035` | 53 | charge MOS | `1 = on` |
| `0x0036` | 54 | discharge MOS | `1 = on` |
| `0x0038` | 56 | voltage delta | `raw * 0.001 V` |
| `0x003A`-`0x003D` | 58-61 | alarm/status words | 16-bit bitmaps |
The app computes power from voltage and current:
```text
power W = abs(pack_voltage_V) * abs(current_A)
```
Time-to-empty/full is not a separate BLE value in the live view. The app computes it:
```text
if register 0x002F == 1:
time-to-full = (rated_capacity_Ah * (100 - SOC_percent) / abs(current_A)) * 60 minutes
if register 0x002F == 2:
time-to-empty = (rated_capacity_Ah * SOC_percent / abs(current_A)) * 60 minutes
```
The `rated_capacity_Ah` input comes from the settings register set parsed into `contentByteArrayByReadSet`. The app also has a separate read of register `0x0064` length `1` that stores `residueTime`, but the visible live time calculation above is derived from SOC/current/capacity unless current is zero.
## Minimal Query Flow With A BLE Client
1. Connect to the device over BLE GATT.
2. Request a large MTU if possible. The app requests `512`; smaller MTUs may still work if frames fit or are segmented.
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 read frame for registers `0x0000` length `0x003E` to `fff2`.
8. Parse the notify response from `fff1`, validate CRC, split into 16-bit big-endian words, then apply the formulas above.
+185
View File
@@ -0,0 +1,185 @@
# BullTron BLE Command Catalog
Status: decompiled from BullTron Android app `1.1.33`. Entries are based on code paths, not live BLE captures.
## Services
| Service UUID | Purpose | Evidence |
| --- | --- | --- |
| `0000fff0-0000-1000-8000-00805f9b34fb` | Main BMS service | `BluetoothRegulate` constants and service discovery |
| `02f00000-0000-0000-0000-00000000fe00` | Version, secret key, OTA | `BluetoothRegulate` constants and setup writes |
| `0000ff00-0000-1000-8000-00805f9b34fb` | WiFi provisioning/config path | WaitConfig constants, not normal BMS telemetry |
## Characteristics
| Characteristic UUID | Direction | Purpose |
| --- | --- | --- |
| `0000fff1-0000-1000-8000-00805f9b34fb` | notify/read | Main BMS response/telemetry frames |
| `0000fff2-0000-1000-8000-00805f9b34fb` | write, notify callback registered | Main BMS command frames |
| `0000fff3-0000-1000-8000-00805f9b34fb` | write/read | AT-style BLE module/name/band commands |
| `02f00000-0000-0000-0000-00000000ff04` | write/read/notify | Version query, `AT+VER=?\r\n` |
| `02f00000-0000-0000-0000-00000000ff05` | write/read | Secret key handshake, ASCII `HiLink` |
| `02f00000-0000-0000-0000-00000000ff02` | notify/read | OTA response |
| `02f00000-0000-0000-0000-00000000ff01` | write | OTA data |
## Frame Format
The main BMS protocol is Modbus-like over BLE.
Read holding/register block:
```text
D203 <start:16-bit> <count:16-bit> <crc16>
```
Single-register write:
```text
D206 <register:16-bit> <value:16-bit> <crc16>
```
Multi-register write:
```text
D210 <start:16-bit> <count:16-bit> <payload:count*2 bytes> <crc16>
```
Password write:
```text
D210 00C9 0003 <utf8 password bytes> <crc16>
```
Time sync write:
```text
D210 00D4 0003 <YY MM DD HH MM SS> <crc16>
```
OTA chunk:
```text
8110 <location> <length> <data> <crc16>
```
The CRC is Modbus CRC16 with polynomial `0xA001`, initial value `0xFFFF`, and the app returns the final word byte-swapped as four uppercase hex digits.
## Response Format
For `D203` responses:
```text
D203 <byte_count:8-bit> <payload words> <crc16>
```
`byte_count` is the payload byte length. The live-data response uses `0x7C`, which is 124 bytes / 62 words.
For `D206` and `D210` responses, the app treats the acknowledgement as a fixed 8-byte / 16-hex-character frame.
## Commands Observed In App
| Name | Characteristic | Operation | Payload | Response/Notification | Meaning |
| --- | --- | --- | --- | --- | --- |
| Secret key | `02f...ff05` | write ASCII | `HiLink` | readback/notify `0100` means OK | Enables version/band setup path |
| Query version | `02f...ff04` | write ASCII | `AT+VER=?\r\n` | version notify/read | BLE/app/MCU version query |
| Query/change band | `fff3` | write ASCII | `AT+BAND=...` / query variant | AT response | BLE module band/region setup |
| Change BLE name | `fff3` | write ASCII | `AT+NAME=<name>` | AT response | Rename BLE module |
| Product info | `fff3` | write ASCII | `AT+PRODUCTINFO...` | AT response | Product info/config |
| Read live data | `fff2` | write hex frame | `D2030000003E<crc>` | `fff1` notify with byte count `7C` | Reads main 62-word telemetry block |
| Read last cell voltages | `fff2` | write hex frame | `D203003E0010<crc>` | `fff1` notify with byte count `20` | Reads last/extra cell voltage block |
| Read password | `fff2` | write hex frame | `D20300C90003<crc>` | `fff1` notify with byte count `06` | Reads 3-word password field |
| Read residue/calefaction flag | `fff2` | write hex frame | `D20300640001<crc>` | `fff1` notify with byte count `02` | Stores `residueTime`; `0001` also marks heating true |
| Sync time | `fff2` | write hex frame | `D21000D40003<YYMMDDHHMMSS><crc>` | `D210` ack | Writes phone time to BMS |
| Write password | `fff2` | write hex frame | `D21000C90003<password_hex><crc>` | `D210` ack | Sets password/control code |
| Single register write | `fff2` | write hex frame | `D206<register><value><crc>` | `D206` ack | Generic settings/control write |
| Multi-register write | `fff2` | write hex frame | `D210<start><count><payload><crc>` | `D210` ack | Generic settings/config write |
## Live Telemetry Register Map
Read command:
```text
D203 0000 003E CRC
```
Response dispatch:
```text
D203 7C <62 words> CRC
```
Words are parsed as unsigned 16-bit big-endian hex words before scaling.
| Register | Index | Scale / Transform | Unit | Field |
| ---: | ---: | --- | --- | --- |
| `0x0000`-`0x001F` | 0-31 | `raw * 0.001` | V | cell voltages |
| `0x0020`-`0x0027` | 32-39 | `raw - 40` | deg C | battery temperature sensors |
| `0x0028` | 40 | `raw * 0.1` | V | pack voltage |
| `0x0029` | 41 | `(raw - 30000) * 0.1` | A | current; negative/positive sign follows firmware convention |
| `0x002A` | 42 | `raw / 10` | % | battery percent / SOC |
| `0x002B` | 43 | `raw * 0.001` | V | highest cell voltage |
| `0x002C` | 44 | `raw * 0.001` | V | lowest cell voltage |
| `0x002F` | 47 | raw enum | state | `1 = charging`, `2 = discharging` in live-time UI |
| `0x0030` | 48 | `raw * 0.1` | Ah | remaining capacity |
| `0x0032` | 50 | raw count, capped at 8 | count | temperature sensor count |
| `0x0033` | 51 | raw | cycles | cycle count |
| `0x0035` | 53 | `1 = on` | boolean | charge MOS |
| `0x0036` | 54 | `1 = on` | boolean | discharge MOS |
| `0x0038` | 56 | `raw * 0.001` | V | cell voltage delta |
| `0x003A`-`0x003D` | 58-61 | 16-bit bitmaps | bits | alarm/status words |
The app applies an extra current correction for two recognized voltage profile pairs from settings registers:
| Setting values | Correction |
| --- | --- |
| settings word `0x000C` formats as `3.71` and word `0x000E` formats as `2.71` | multiply current by `3.472` |
| settings word `0x000C` formats as `3.72` and word `0x000E` formats as `2.72` | multiply current by `3.37` |
## Time-To-Empty / Time-To-Full
The UI computes time from live registers instead of just displaying a raw BLE value:
```text
current_A = abs(decoded register 0x0029)
soc_percent = register 0x002A / 10
remaining_capacity_Ah = register 0x0030 * 0.1
rated_capacity_Ah = parsed settings capacity
if register 0x002F == 1:
label = TimeToFull
minutes = rated_capacity_Ah * (100 - soc_percent) / current_A * 60
if register 0x002F == 2:
label = TimetoEmpty
minutes = rated_capacity_Ah * soc_percent / current_A * 60
```
If current is zero or the state is neither `1` nor `2`, the UI shows `-- h --m`.
## Dispatch By Read Byte Count
`DataAnalysisHelper.analysisPressValueByCan()` dispatches `D203` replies by byte count:
| Byte count | Meaning in app |
| --- | --- |
| `7C` | main run/live info |
| `20` | last battery/cell voltage block |
| `52` | settings info |
| `40` | version data |
| `06` | password data |
| `12` | balancing state |
| `0A` | new alarm info |
| `4A` | device parameter info |
| `04` | communication/protocol or production date |
| `FE` | history info |
| `18` | serial number |
| `02` | residue time / heating flag |
| `FC` | Ali/BMS data `0x00`-`0x7D` |
| `E0` | Ali/BMS data `0x80`-`0xEF` |
| `38` | BLE registers `0x63`-`0x7E` |
| `22` | BLE registers `0xDF`-`0xEF` |
## Caveats
- This catalog is code-derived. A live BLE capture is still useful to confirm sign conventions, current correction edge cases, and whether a specific BullTron battery firmware requires link-layer encryption.
- The app bundles WiFi/Ali IoT flows too; those are separate from the local BLE telemetry path documented here.
+19
View File
@@ -0,0 +1,19 @@
# APK Evidence
- File analyzed locally: `bulltron-1.1.33-pgyer.apk`
- Source: PGYER old-version download page
- App package: `com.inuker.bluetooth.bulltron`
- App label: `BullTron`
- Version: `1.1.33` / `1133001`
- SHA-256: `f11c49499cf226ccd6dc45e8f1780c11c4d84d785846182781886ab135b9a476`
- Signer DN: `CN=smart_daly, OU=smart_daly, O=smart_daly, L=smart_daly, ST=smart_daly`
- Signer cert SHA-256: `996caf450ecdb34d0ba45d2b2c6a2df56243338c7be3b7b497168af4e3d00ae4`
Local decompile outputs used during analysis:
- apktool output: `bulltron-ble-work/apktool-out`
- JADX output: `bulltron-ble-work/jadx-out`
- BLE symbol scan: `bulltron-ble-work/ble-symbols-jadx.md`
- smali BLE symbol scan: `bulltron-ble-work/ble-symbols-smali.md`
The APK itself is intentionally not committed into this repository.
+56
View File
@@ -0,0 +1,56 @@
{
"backupVersionCode": 8003,
"packageName": "com.inuker.bluetooth.bulltron",
"packageLabel": "BullTron",
"versionName": "1.1.33",
"versionCode": 1133001,
"sourceDir": "/data/app/~~PtflDjIaun5IdJ3Zt9haCA==/com.inuker.bluetooth.bulltron-YRoA8T7fLzV9a4JZCdXfYQ==/base.apk",
"splitSourceDirs": [
"/data/app/~~PtflDjIaun5IdJ3Zt9haCA==/com.inuker.bluetooth.bulltron-YRoA8T7fLzV9a4JZCdXfYQ==/split_config.arm64_v8a.apk",
"/data/app/~~PtflDjIaun5IdJ3Zt9haCA==/com.inuker.bluetooth.bulltron-YRoA8T7fLzV9a4JZCdXfYQ==/split_config.de.apk",
"/data/app/~~PtflDjIaun5IdJ3Zt9haCA==/com.inuker.bluetooth.bulltron-YRoA8T7fLzV9a4JZCdXfYQ==/split_config.xxhdpi.apk"
],
"backupDate": "2026-07-10T11:03:53.576193",
"hasApk": true,
"hasAppData": true,
"hasDevicesProtectedData": true,
"iv": [
-11,
-9,
-95,
-112,
9,
89,
77,
119,
-123,
-93,
-40,
-121,
44,
-31,
-46,
-31
],
"cpuArch": "arm64-v8a",
"permissions": [
" android.permission.ACCESS_COARSE_LOCATION",
" android.permission.ACCESS_FINE_LOCATION",
" android.permission.BLUETOOTH_ADMIN",
" android.permission.BLUETOOTH_ADVERTISE",
" android.permission.BLUETOOTH_CONNECT",
" android.permission.BLUETOOTH_SCAN",
" android.permission.CAMERA",
" android.permission.CHANGE_NETWORK_STATE",
" android.permission.CHANGE_WIFI_STATE",
" android.permission.FLASHLIGHT",
" android.permission.GET_TASKS",
" android.permission.USE_EXACT_ALARM",
" android.permission.VIBRATE",
" com.google.android.c2dm.permission.RECEIVE",
" com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE",
" com.google.android.gms.permission.AD_ID",
"android.permission.BLUETOOTH"
],
"size": 34260163
}
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Build BullTron/Daly-style BLE frames observed in the Android app."""
from __future__ import annotations
import argparse
def crc16_modbus_swapped(hex_data: str) -> str:
data = bytes.fromhex(hex_data)
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
swapped = ((crc & 0xFF00) >> 8) | ((crc & 0x00FF) << 8)
return f"{swapped:04X}"
def read_frame(start: int, count: int) -> str:
body = f"D203{start:04X}{count:04X}"
return body + crc16_modbus_swapped(body)
def write_single_frame(register: int, value: int) -> str:
body = f"D206{register:04X}{value:04X}"
return body + crc16_modbus_swapped(body)
def write_multi_frame(start: int, count: int, payload_hex: str) -> str:
payload = payload_hex.replace(" ", "").upper()
body = f"D210{start:04X}{count:04X}{payload}"
return body + crc16_modbus_swapped(body)
def main() -> None:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="cmd", required=True)
p_read = sub.add_parser("read")
p_read.add_argument("start", type=lambda x: int(x, 0))
p_read.add_argument("count", type=lambda x: int(x, 0))
p_write = sub.add_parser("write")
p_write.add_argument("register", type=lambda x: int(x, 0))
p_write.add_argument("value", type=lambda x: int(x, 0))
p_multi = sub.add_parser("write-multi")
p_multi.add_argument("start", type=lambda x: int(x, 0))
p_multi.add_argument("count", type=lambda x: int(x, 0))
p_multi.add_argument("payload_hex")
args = parser.parse_args()
if args.cmd == "read":
print(read_frame(args.start, args.count))
elif args.cmd == "write":
print(write_single_frame(args.register, args.value))
elif args.cmd == "write-multi":
print(write_multi_frame(args.start, args.count, args.payload_hex))
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Scan decompiled Android sources for BLE UUIDs and GATT-related calls."""
from __future__ import annotations
import argparse
import re
from pathlib import Path
UUID_RE = re.compile(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
)
GATT_TERMS = [
"BluetoothGatt",
"BluetoothGattCallback",
"BluetoothGattCharacteristic",
"BluetoothGattDescriptor",
"BluetoothLeScanner",
"startScan",
"startLeScan",
"connectGatt",
"createBond",
"discoverServices",
"getService",
"getCharacteristic",
"setCharacteristicNotification",
"writeCharacteristic",
"readCharacteristic",
"writeDescriptor",
"onCharacteristicChanged",
"onCharacteristicRead",
"onCharacteristicWrite",
]
def interesting(path: Path) -> bool:
return path.suffix.lower() in {".java", ".kt", ".xml", ".smali"}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path, help="JADX/apktool output directory")
args = parser.parse_args()
for path in sorted(p for p in args.source.rglob("*") if p.is_file() and interesting(p)):
try:
text = path.read_text(errors="ignore")
except OSError:
continue
matches = sorted(set(UUID_RE.findall(text)))
terms = [term for term in GATT_TERMS if term in text]
if not matches and not terms:
continue
print(f"## {path}")
if matches:
print("UUIDs:")
for value in matches:
print(f"- {value.lower()}")
if terms:
print("GATT terms:")
for value in terms:
print(f"- {value}")
print()
return 0
if __name__ == "__main__":
raise SystemExit(main())