commit 3675220179f4e5291caf5ddb457cb97075ed72d5 Author: acidburns Date: Fri Jul 10 11:31:55 2026 +0200 Document BullTron BLE commands diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bb7642 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +decompiled/ +apk/ +*.apk +*.apks +*.xapk +*.dex +*.jar +*.class diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bdf570 --- /dev/null +++ b/README.md @@ -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`. diff --git a/docs/ble-flow.md b/docs/ble-flow.md new file mode 100644 index 0000000..376a8c7 --- /dev/null +++ b/docs/ble-flow.md @@ -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. diff --git a/docs/command-catalog.md b/docs/command-catalog.md new file mode 100644 index 0000000..0d1e169 --- /dev/null +++ b/docs/command-catalog.md @@ -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 +``` + +Single-register write: + +```text +D206 +``` + +Multi-register write: + +```text +D210 +``` + +Password write: + +```text +D210 00C9 0003 +``` + +Time sync write: + +```text +D210 00D4 0003 +``` + +OTA chunk: + +```text +8110 +``` + +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` 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=` | 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` | `fff1` notify with byte count `7C` | Reads main 62-word telemetry block | +| Read last cell voltages | `fff2` | write hex frame | `D203003E0010` | `fff1` notify with byte count `20` | Reads last/extra cell voltage block | +| Read password | `fff2` | write hex frame | `D20300C90003` | `fff1` notify with byte count `06` | Reads 3-word password field | +| Read residue/calefaction flag | `fff2` | write hex frame | `D20300640001` | `fff1` notify with byte count `02` | Stores `residueTime`; `0001` also marks heating true | +| Sync time | `fff2` | write hex frame | `D21000D40003` | `D210` ack | Writes phone time to BMS | +| Write password | `fff2` | write hex frame | `D21000C90003` | `D210` ack | Sets password/control code | +| Single register write | `fff2` | write hex frame | `D206` | `D206` ack | Generic settings/control write | +| Multi-register write | `fff2` | write hex frame | `D210` | `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. diff --git a/evidence/apk-info.md b/evidence/apk-info.md new file mode 100644 index 0000000..1f7f729 --- /dev/null +++ b/evidence/apk-info.md @@ -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. diff --git a/evidence/bulltron-backup-manifest.json b/evidence/bulltron-backup-manifest.json new file mode 100644 index 0000000..bb1ef3c --- /dev/null +++ b/evidence/bulltron-backup-manifest.json @@ -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 +} \ No newline at end of file diff --git a/tools/bulltron_frame.py b/tools/bulltron_frame.py new file mode 100755 index 0000000..ddfbe98 --- /dev/null +++ b/tools/bulltron_frame.py @@ -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() diff --git a/tools/extract-ble-symbols.py b/tools/extract-ble-symbols.py new file mode 100644 index 0000000..08d8de3 --- /dev/null +++ b/tools/extract-ble-symbols.py @@ -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())