Files
bulltron-ble-commands/README.md
T

285 lines
10 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`.
## Local Desktop App
This repo includes a small Python GUI client that can scan for BullTron-style
BLE devices, connect, poll telemetry, and display the decoded values locally on
a PC.
### Debian
```sh
sudo apt update
sudo apt install python3 python3-venv python3-pip python3-tk bluez
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install -r requirements.txt
```
Run the GUI:
```sh
python3 bulltron_gui.py
```
If scanning finds no devices, check that Bluetooth is powered and unblocked:
```sh
rfkill list bluetooth
bluetoothctl power on
bluetoothctl scan on
```
On some Debian installs, normal users cannot access BlueZ D-Bus discovery
properly. If the GUI cannot scan/connect, first try logging out and back in
after making sure your user is in the bluetooth group:
```sh
sudo usermod -aG bluetooth "$USER"
```
If that still fails, run once with elevated privileges to confirm it is a local
permission issue rather than a protocol issue:
```sh
sudo .venv/bin/python bulltron_gui.py
```
The scan list shows every BLE device returned by the OS; BullTron-looking
devices are only sorted first and marked with `*`. If the battery is not shown
at all, close the Android BullTron app so it is not holding the GATT connection,
increase the GUI scan duration, and try the `Direct connect` field with the
address observed in the HCI capture:
```text
7C:3E:82:1C:7A:86
```
### Windows 11
Install Python 3.11 or newer from <https://www.python.org/downloads/windows/>
or from the Microsoft Store. During the python.org install, enable `Add
python.exe to PATH`.
Open PowerShell in this repository and run:
```powershell
py -3 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python bulltron_gui.py
```
If PowerShell blocks venv activation, allow scripts for your user and retry:
```powershell
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
.\.venv\Scripts\Activate.ps1
```
Windows 11 uses the native WinRT Bluetooth stack through `bleak`, so no BlueZ or
extra Bluetooth driver package is needed. Make sure Bluetooth is enabled in
Windows Settings and that the BullTron battery is nearby and not already held by
the Android app. Pairing in Windows should not be required for normal telemetry;
the original Android flow also reads telemetry without bonding.
If the Windows scan does not list the battery, first close the Android app and
use the GUI's longer scan duration. If the PC can still not discover it, use the
`Direct connect` field with the captured address `7C:3E:82:1C:7A:86`. Some
Windows Bluetooth adapters will only connect to an address after they have seen
at least one advertisement from that peripheral in the current session.
The GUI shows:
- live pack voltage, current, discharge watts, SOC, remaining Ah, cell max/min,
imbalance, cell count, cycle count, charge/discharge MOS, separate
charge/discharge current and watts, and computed time-to-empty/time-to-full
- alarm/status words decoded into readable alarm names where known
- system/settings values such as the control PIN, firmware/version text,
product info, battery code/SN, parsed production date where possible, and
inferred battery Ah when the settings block contains a plausible capacity
- raw TX/RX frames for debugging and protocol confirmation
The settings tab has MOS on/off controls, but writes are deliberately guarded:
you must enter the BMS PIN, tick `Enable MOS writes`, and confirm each write.
The default write registers mirror the BullTron system screen: `0x00A5` for
charge MOS and `0x00A6` for discharge MOS. The live MOS status still comes from
read-only telemetry registers `0x0035` and `0x0036`.
The MOS status can lag the requested control state until that MOSFET path is
actually used. For example, disabling charging may still display as ON while
the pack is idle or discharging; it only reports OFF once charging is attempted
and the BMS applies the charge MOS state.
Scan results are sorted so likely BullTron devices appear first. The Android app
accepts scanned devices whose BLE name contains `DL` or `B35`, or whose legacy
advertising payload contains marker bytes for `DL`, `PU`, or `JHB`. The desktop
app mirrors those name/advertising hints and also treats the confirmed BMS
service UUID `fff0` as a strong match when the host Bluetooth stack exposes it
during scanning.
## 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. Current app logic uses the remaining-capacity register when present,
falling back to rated capacity times SOC fraction only if remaining Ah is not
available:
```text
remaining_Ah = register_0x0030_Ah if available else rated_capacity_Ah * SOC_percent / 100
if current_A > 0:
time-to-full = max(rated_capacity_Ah - remaining_Ah, 0) / abs(current_A) * 60
if current_A < 0:
time-to-empty = remaining_Ah / 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 and confirmed against
one real Android HCI snoop capture. More captures are 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.