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.

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

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:

python3 bulltron_gui.py

If scanning finds no devices, check that Bluetooth is powered and unblocked:

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:

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:

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:

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:

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:

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:

    D2030000003ED7B9
    
  8. 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. 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:

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.

S
Description
BullTron Android app BLE protocol reverse engineering: services, characteristics, command frames, CRC helper, and telemetry register map for SOC, current, voltage, capacity, and time-to-empty.
Readme 174 KiB
Languages
Python 100%