285 lines
10 KiB
Markdown
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.
|