BM200 protocol findings and test bench

Spec with pseudocode and test vectors, APK disassembly, analysis tools,
shelved reference Python and the macOS BLE probe. Findings are not yet
validated against a real tag. The vendor XAPK and nested lib clone are
excluded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EinWd3UT2Xw7UakFtTp6hn
This commit is contained in:
Vibe Myass
2026-11-01 02:58:16 +00:00
commit 13b95345c7
17 changed files with 389496 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# Vendor app package
*.xapk
*.apk
# Separate git repo (upstream clone with its own bm200-protocol branch)
/bm2-battery-monitor/
# Test bench output and Python leftovers
probe-*.jsonl
.venv/
__pycache__/
.DS_Store

273
BM200_PROTOCOL.md Normal file
View File

@@ -0,0 +1,273 @@
# ANCEL BM200 BLE protocol: implementation spec
Language-neutral spec for capturing live readings and backfilling history from an ANCEL BM200, with no vendor app or cloud.
**Source:** bytecode of `com.dc.bm6_ancel` 2.0.6 (BM6 protocol family). **Not yet tested against a real tag.** The test vectors check an implementation against this reading of the app; they are not captures. See [Unverified](#unverified) before relying on it.
The existing `BM2Client` in this repo does not work with this device: the key, frame format and startup all differ.
## 1. Transport
| Item | Value |
|---|---|
| Advertised name | `BM6` or `Battery Guard` |
| Service | `0000fff0-0000-1000-8000-00805f9b34fb` |
| Write commands | `0000fff3-0000-1000-8000-00805f9b34fb` |
| Notifications | `0000fff4-0000-1000-8000-00805f9b34fb` |
The relay must hold a GATT connection and write commands. Readings do not come from passive scanning: the app only uses advertisements to detect the tag (iBeacon prefix `02 15`) and then connects.
## 2. Crypto
```
KEY = 6c 65 61 67 65 6e 64 ff fe 30 31 30 30 30 30 39 // "leagend" FF FE "0100009"
IV = 16 zero bytes
AES-128-CBC, no padding
encrypt(cmd):
return AES_CBC_ENCRYPT(KEY, IV, right_pad_with_zeros(cmd, 16))
decrypt(frame):
// IV resets for every block
if length(frame) == 0 or length(frame) % 16 != 0: reject frame
return concat(AES_CBC_DECRYPT(KEY, IV, block) for each 16-byte block of frame)
```
## 3. Commands
| Name | Plaintext | Encrypted bytes written to FFF3 |
|---|---|---|
| VERSION | `d15501` | `1ad6f05e4990ab24b84d57df81d8766e` |
| ACCEPT | `d1550200` | `9cb18029774c5cfc57c9f114d40db4e8` |
| LIVE | `d15507` | `697ea0b5d54cf024e794772355554114` |
| HISTORY(10) | `d1550500000000000a` | `bb43a7f6b8c3454a614c83b9366a0b47` |
| HISTORY(22320) | `d15505000000005730` | `8e8dd229b057e0567f1c7e0ef0a0ec11` |
| HISTORY(51840) | `d1550500000000ca80` | `965d8b8fad4794162ccee2d35a44baa4` |
`HISTORY(n)` is `d1 55 05 00 00 00` followed by `n` as 3 bytes, big-endian.
Do not send:
- `d1550211`: the app sends this when its cloud check rejects a tag as not genuine.
- `d15508xx`: this writes battery type to the tag. The app sends it, but it changes tag configuration, so this spec leaves it out.
## 4. Session
```
on_connect:
subscribe FFF4
decoder = new Decoder()
write VERSION; wait for Version (timeout 10 s)
write ACCEPT; wait for AcceptAck (timeout 10 s)
// The app always does this short read first and discards the result.
write HISTORY(10); wait for HistoryBlock (timeout 10 s)
write HISTORY(history_count(since))
block = wait for HistoryBlock (timeout 10 min)
for record in block.records: // oldest first
if since is null or record.time > since:
deliver(record)
since = record.time
persist(since) // only after delivery
write LIVE
until disconnected or no frame for 120 s:
deliver each LiveReading as it arrives
on_disconnect or timeout:
wait 5 s, then reconnect
history_count(since):
if since is null: return MAX_RECORDS
missing = floor((now - since) / 120 seconds)
return min(max(missing, 0) + 10, MAX_RECORDS) // +10 overlap, as in the app
```
`MAX_RECORDS`: the app uses 22320 (31 days) or 51840 (72 days, for one device variant only). Start with 22320.
The timeouts and the reconnect delay are this spec's choices, not app values. The 10-minute history timeout is a guess based on transfer size: 22320 records is about 5,600 notifications.
`since` advances only from history records, never from live readings. A gap is then backfilled even if some live frames got through during it.
The app also requests crank data (`D15503`) between history and live. This spec skips that because it is out of scope.
## 5. Decoder
Each notification is decoded on its own, except history, which spans many frames. Feed frames in arrival order. For a remote relay, record `received_at` on the relay, because history timestamps are derived from it.
```
state: history_buffer = null, history_base = 0
feed(frame, received_at):
h = uppercase_hex(decrypt(frame)) // offsets below are hex-digit positions
// Live frames can arrive in the middle of a history transfer.
if h starts with "D15507FF": return nothing // ack
if h starts with "D15507": return parse_live(h, received_at)
if history_buffer is not null:
if h contains "FEFEFE": return nothing // app discards these frames
history_buffer += h
end = index_of(history_buffer, "FFFEFE") // any digit alignment, as in the app
if end < 0: return nothing
data = history_buffer[0 .. end)
history_buffer = null
return HistoryBlock(parse_history(data, history_base))
if h starts with "D15501":
return Version(hex(h[6..8)) + hex(h[8..10)) / 10)
if h starts with "D15502":
return AcceptAck(hex(h[6..8)))
if h starts with "D15505":
if hex(h[6..10)) == 0: return HistoryBlock(empty)
history_buffer = ""
history_base = history_base_time(received_at)
return nothing
return nothing // unknown: log it, don't fail
```
### Live frame
```
parse_live(h, received_at):
magnitude = hex(h[8..10))
return LiveReading:
time = received_at
temperature = -magnitude if hex(h[6..8)) == 1 else magnitude
status = hex(h[10..12))
soc = hex(h[12..14))
voltage = hex(h[14..18)) / 100
rapid_acceleration = hex(h[18..22))
rapid_deceleration = hex(h[22..26))
```
| Byte | Field | Notes |
|---|---|---|
| 0-2 | `D1 55 07` | |
| 3 | temperature sign | 1 = negative |
| 4 | temperature | unit assumed to be °C |
| 5 | status | 2 = engine running; store other values raw |
| 6 | state of charge | percent |
| 7-8 | voltage | big-endian, ÷100 |
| 9-10 | rapid acceleration count | big-endian |
| 11-12 | rapid deceleration count | big-endian |
### History
Records are 4 bytes, 8 hex digits: `VVV SS TT F`. Record 0 is the **newest**, and records are 2 minutes apart. They carry no timestamp, so times are derived from when the transfer starts.
```
history_base_time(t):
base = t floored to the whole minute
if local_minute(base) is even: base -= 60 seconds
return base
parse_history(data, base):
while data starts with "00000000": data = data[8..] // see note below
records = []; pos = 0; index = 0
while pos + 8 <= length(data):
item = data[pos .. pos+8); pos += 8
flags = hex(item[7])
mark = "start" if (flags & 7) == 2
"stop" if (flags & 7) == 4
null otherwise
if mark == "stop": pos += 8 // 4 extra bytes: rapid accel, rapid decel (2 bytes each)
voltage = hex(item[0..3)) / 100
if voltage != 0: // zero slots are dropped but still use a time slot
magnitude = hex(item[5..7))
records.append(
time = base - index * 120 seconds,
voltage = voltage,
soc = hex(item[3..5)),
temperature = -magnitude if (flags & 8) != 0 else magnitude,
mark = mark)
index += 1
return reverse(records) // oldest first
```
Things to design for:
- **Timestamps are approximate.** They assume the newest record was taken at the base time, so the same record can come out a slot different between reads. Do not use `(time, voltage)` as a strict unique key. Filter with `since`.
- **Leading zero records.** This spec strips all of them. The app's loop has a quirk that can also skip real records after them, which is not reproduced here.
- **The tag's clock is not used.** A tag that was out of range for longer than its history depth has an unrecoverable gap.
## 6. Expected outputs
Assume `received_at = 1700000000` (2023-11-14T22:13:20Z) and timezone UTC unless stated. Each case starts with a new decoder, and frames are fed in order. Floats are shown as decimal values.
### Single frames
| Case | Encrypted frame | Plaintext | Expected |
|---|---|---|---|
| Version | `13e4a294119b956c21350446e9fe3527` | `d15501010700…` | `Version{version: 1.7}` |
| Accept ack | `9cb18029774c5cfc57c9f114d40db4e8` | `d1550200…` | `AcceptAck{result: 0}` |
| Live ack | `8ae0358523fe154976de18fc102eb1d4` | `d15507ff…` | nothing |
| Empty history | `91c83073bb2dddcd6544ee0650ad075b` | `d155050000…` | `HistoryBlock{records: []}` |
| Live | `e3e117645ad018edcbeb11c769a15326` | `d155070019025505080003000400…` | see below |
| Live, negative temperature | `9889e1f5002c3594e26a8a87317004ea` | `d155070105025505080003000400…` | same, with `temperature: -5` |
| 15 bytes of `00` | | | rejected, no state change |
```json
{"time": "2023-11-14T22:13:20Z", "voltage": 12.88, "soc": 85, "temperature": 25, "status": 2, "rapid_acceleration": 3, "rapid_deceleration": 4}
```
### History, with a live frame in the middle
| # | Encrypted frame | Plaintext | Expected |
|---|---|---|---|
| 1 | `46f6b39dec35c6faa5a92df16e2b7026` | `d155050003…` | nothing |
| 2 | `0c3f001bd08004a48534d5831b249422` | `00000000 50055194 00030004 4f050140` | nothing |
| 3 | `6adc3339e32094bf1b6e3837daf1ee04` | `d155070019005505080000000000…` | `LiveReading` with `status: 0`, `rapid_acceleration: 0`, `rapid_deceleration: 0`, otherwise as above |
| 4 | `18e26d814ecb10ef977633c9fa77808b` | `4e0000aa fffefe00…` | block below |
```json
{"records": [
{"time": "2023-11-14T22:09:00Z", "voltage": 12.48, "soc": 0, "temperature": -10, "mark": "start"},
{"time": "2023-11-14T22:11:00Z", "voltage": 12.64, "soc": 80, "temperature": 20, "mark": null},
{"time": "2023-11-14T22:13:00Z", "voltage": 12.80, "soc": 85, "temperature": 25, "mark": "stop"}
]}
```
### Base time on an even minute
`received_at = 1700000040` (22:14:00Z).
| # | Encrypted frame | Plaintext | Expected |
|---|---|---|---|
| 1 | `2e6a4e19b27a6a3332696e9d43e6a916` | `d155050001…` | nothing |
| 2 | `44b1770f090e4520f8e324ed87101c66` | `50055190 fffefe00…` | one record, `time: 2023-11-14T22:13:00Z`, `voltage: 12.80`, `soc: 85`, `temperature: 25`, `mark: null` |
### `FEFEFE` frame is discarded
`received_at = 1700000000`.
| # | Encrypted frame | Plaintext | Expected |
|---|---|---|---|
| 1 | `2e6a4e19b27a6a3332696e9d43e6a916` | `d155050001…` | nothing |
| 2 | `7e5cb931800dbc0c789ccd531167dc82` | `fefefe00…` | nothing |
| 3 | `44b1770f090e4520f8e324ed87101c66` | `50055190 fffefe00…` | one record, same values as the previous case |
### Session order
A compliant session writes exactly this to FFF3 when `since` is null. Replying to each write with a scripted fake tag is an easy test:
```
VERSION, ACCEPT, HISTORY(10), HISTORY(22320), LIVE
```
## Unverified
Check these on the first hardware session, logging raw frames alongside decoded values:
1. **Whether the skipped steps matter.** The tag may require the battery-type write or the crank read before it streams live frames. If `LIVE` gets no data, add `D15503` first, then look at `D15508`.
2. **Whether `ACCEPT` is needed.** It is included because the app sends it when offline.
3. **History transfer speed and depth**, and whether this tag accepts 51840.
4. **Live frame rate.** The app doesn't show it. The 120 s silence timeout is conservative for that reason.
5. **Status values other than 2**, and the temperature unit.
6. **Single connection.** Assume the tag serves one central at a time, so close the vendor app during testing.

379323
findings/disassembly/app.txt Normal file

File diff suppressed because it is too large Load Diff

8216
findings/disassembly/ble.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,460 @@
classes2.dex Lcom/dc/bm6_ancel/R$anim;
classes2.dex Lcom/dc/bm6_ancel/R$array;
classes2.dex Lcom/dc/bm6_ancel/R$attr;
classes2.dex Lcom/dc/bm6_ancel/R$color;
classes2.dex Lcom/dc/bm6_ancel/R$drawable;
classes2.dex Lcom/dc/bm6_ancel/R$id;
classes2.dex Lcom/dc/bm6_ancel/R$layout;
classes2.dex Lcom/dc/bm6_ancel/R$mipmap;
classes2.dex Lcom/dc/bm6_ancel/R$raw;
classes2.dex Lcom/dc/bm6_ancel/R$string;
classes2.dex Lcom/dc/bm6_ancel/R$style;
classes2.dex Lcom/dc/bm6_ancel/R$styleable;
classes2.dex Lcom/dc/bm6_ancel/app/AlarmReceiver;
classes2.dex Lcom/dc/bm6_ancel/app/IBeaconReceiver$a;
classes2.dex Lcom/dc/bm6_ancel/app/IBeaconReceiver$b;
classes2.dex Lcom/dc/bm6_ancel/app/IBeaconReceiver;
classes2.dex Lcom/dc/bm6_ancel/app/LanguageReceiver;
classes2.dex Lcom/dc/bm6_ancel/app/MyApp$a;
classes2.dex Lcom/dc/bm6_ancel/app/MyApp$b;
classes2.dex Lcom/dc/bm6_ancel/app/MyApp$c;
classes2.dex Lcom/dc/bm6_ancel/app/MyApp$d;
classes2.dex Lcom/dc/bm6_ancel/app/MyApp$e;
classes2.dex Lcom/dc/bm6_ancel/app/MyApp;
classes2.dex Lcom/dc/bm6_ancel/ble/BleService$4;
classes2.dex Lcom/dc/bm6_ancel/ble/BleService$a;
classes2.dex Lcom/dc/bm6_ancel/ble/BleService$b;
classes2.dex Lcom/dc/bm6_ancel/ble/BleService$c;
classes2.dex Lcom/dc/bm6_ancel/ble/BleService$d;
classes2.dex Lcom/dc/bm6_ancel/ble/BleService$e;
classes2.dex Lcom/dc/bm6_ancel/ble/BleService$f;
classes2.dex Lcom/dc/bm6_ancel/ble/BleService$g;
classes2.dex Lcom/dc/bm6_ancel/ble/BleService$h;
classes2.dex Lcom/dc/bm6_ancel/ble/BleService$i;
classes2.dex Lcom/dc/bm6_ancel/ble/BleService$j;
classes2.dex Lcom/dc/bm6_ancel/ble/BleService$k;
classes2.dex Lcom/dc/bm6_ancel/ble/BleService$l;
classes2.dex Lcom/dc/bm6_ancel/ble/BleService;
classes2.dex Lcom/dc/bm6_ancel/ble/a$a;
classes2.dex Lcom/dc/bm6_ancel/ble/a$b;
classes2.dex Lcom/dc/bm6_ancel/ble/a$c;
classes2.dex Lcom/dc/bm6_ancel/ble/a$d;
classes2.dex Lcom/dc/bm6_ancel/ble/a$e;
classes2.dex Lcom/dc/bm6_ancel/ble/a;
classes2.dex Lcom/dc/bm6_ancel/ble/b;
classes2.dex Lcom/dc/bm6_ancel/ble/c;
classes2.dex Lcom/dc/bm6_ancel/ble/d;
classes2.dex Lcom/dc/bm6_ancel/ble/e;
classes2.dex Lcom/dc/bm6_ancel/ble/f;
classes2.dex Lcom/dc/bm6_ancel/ble/g;
classes2.dex Lcom/dc/bm6_ancel/ble/h$a;
classes2.dex Lcom/dc/bm6_ancel/ble/h;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation$9;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation$a;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation$b;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation$c;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation$d;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation$e;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation$f;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation$g;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation$h;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation$i$a$a;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation$i$a;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation$i;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation$j$a;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation$j;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/BleDataOperation;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/a;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/b;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/c;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/d;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/e;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/f;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/g;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/h;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/i;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/j;
classes2.dex Lcom/dc/bm6_ancel/ble/operation/k;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityAboutBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityAddBatteryBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityAddNoBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityBatteryBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityBleTipsBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityChargeBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityChargeDesBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityCodeBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityCrankBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityCrankDesBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityFeedBackBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityFlashBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityHistoryBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityHistoryDesBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityLanguageBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityLoginBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityMainBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivitySeekDetailBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityShareBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityStepBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityTripDetailBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityTripLogBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityUpgradeBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityUpgradeSingleBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ActivityWebBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/AddDialogItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/AddDialogLayoutBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/AlarmFragment1RecyclerItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/AlarmLoadingLayoutBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/AlarmUpLayoutBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/BaseActivityBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/BaseFragmentBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/BaseToolbarBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/BatteryItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/BleTipsItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/BluetoothTipsBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/DayItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/DialogDayBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/DialogMonthBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/DialogTipBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/DialogWaitBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/EmptyData1Binding;
classes2.dex Lcom/dc/bm6_ancel/databinding/EmptyDataBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/EmptyEquipmentBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/EmptyNoLoginBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/EquipmentItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ExportDeviceItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FeedSubmitLayoutBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FirstEditParameterLayoutBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentAlarm1Binding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentAlarm2Binding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentBatteryBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentBatteryMuchBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentSeekBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentSettingBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentSocBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentStep1Binding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentStep2Binding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentStep3Binding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentSuperBatteryBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentSuperChargeBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentSuperCrankBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentSuperSeekBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentSuperTripBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentTempBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentTripBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/FragmentVoltageBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/HistoryDataPopItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/HistoryPopBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/HistoryPopItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/LanguageItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/LastVersionBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/LastVersionSingleBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/LockDialogLayoutBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/LoginLayoutBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/MainPopupItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/MonthItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/NewVersionBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/NewVersionSingleBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/NoDataViewBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/NoDeviceLayoutBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/OilTipsLayoutBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ParameterItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/PermissionAccountTipsBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/PermissionAddBatterTipsBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/PermissionBluetoothTipsBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/PermissionLocationTipsBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/PopRecycleItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/PrivacyLayoutBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/RestartBtTipsBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/RestartPhoneTipsBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/SearchAlarmAlarmBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/SearchEquipmentBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/SeekPopBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/SuggestionImagesLayoutBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/TabcustomlayoutBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/TagItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/TripItemBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/TripItemHeaderBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/UpgradeDialogLayoutBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/UpgradeViewBinding;
classes2.dex Lcom/dc/bm6_ancel/databinding/ViewVoltChartBinding;
classes2.dex Lcom/dc/bm6_ancel/mvp/base/BaseActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/base/BaseFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/base/MvpActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/base/MvpFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/FlashActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/FlashActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/FlashActivity$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/FlashActivity$d;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/FlashActivity$e;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/FlashActivity$f;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/FlashActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$d;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$e;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$f;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$g$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$g;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$h$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$h;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$i;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$j;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$k;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$l;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$m;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$n;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity$o;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/activity/MainActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/main/adapt/MyPagerAdapt;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/AlarmTime;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/BUPoint;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/BatteryInfo$1;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/BatteryInfo$2;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/BatteryInfo;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/ChargeBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/CrankVoltBean$1;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/CrankVoltBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/CurveDataBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/DayItem;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/DayItemStatus;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/Events;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/FtpBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/GPSBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/HistoricalVolt;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/HistoryBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/HistoryBeanNet$1;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/HistoryBeanNet;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/HistorySyncTime;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/HttpResponse;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/LanguageBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/MonthItem;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/MonthItemStatus;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/MsgEvent;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/NearByDevice;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/ParkBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/RealTimeBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/ResultLatLngBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/SP_Con;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/ShareData;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/ShowBean$DeviceBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/ShowBean$SyncBean$1;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/ShowBean$SyncBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/ShowBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/TagBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/TipsBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/TravelBean$1;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/TravelBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/TripListAllBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/TripPageBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/TripPointBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/TripResult;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/UpgradeBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/UploadHistory;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/UserInfo$GrantsBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/UserInfo;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/VersionBean;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/VersionNet;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/VoltPoint;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/YSFUser;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/GsonBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/AddBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/AddShowBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/AllDataBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/BaseBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/ChargeBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/CodeEmailBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/CrankBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/EditBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/EmailBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/GuestBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/HistoryBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/HistoryQueryBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/LoginBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/MacBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/MacYearBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/ParkBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/ParkClockBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/ParkIdBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/RealBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/SerialNoBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/SoCBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/ThirdLoginBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/TripBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/TripIdBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/TripPageBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/UpdateTripBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/UploadCrankBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/UploadHistoryBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/UploadRealBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/model/body/UploadTripBody;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddBatteryActivity$MyParameterAdapt$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddBatteryActivity$MyParameterAdapt$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddBatteryActivity$MyParameterAdapt;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddBatteryActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddBatteryActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddBatteryActivity$g;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddBatteryActivity$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddBatteryActivity$d;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddBatteryActivity$e;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddBatteryActivity$f;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddBatteryActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddNoActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddNoActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddNoActivity$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddNoActivity$d;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddNoActivity$e;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddNoActivity$f;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddNoActivity$g;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/AddNoActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/BatteryActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/BleTipsActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/BleTipsActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/BleTipsActivity$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/BleTipsActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/ChargeActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/ChargeActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/ChargeActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/ChargeDesActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/CrankActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/CrankActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/CrankActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/CrankDesActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/HistoryActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/HistoryActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/HistoryActivity$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/HistoryActivity$d;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/HistoryActivity$e;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/HistoryActivity$f;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/HistoryActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/HistoryDesActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/activity/StepActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/adapt/BatterySelectAdapt;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/BatteryFragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/BatteryFragment$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/BatteryFragment$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/BatteryFragment$d;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/BatteryFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/BatteryMuchFragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/BatteryMuchFragment$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/BatteryMuchFragment$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/BatteryMuchFragment$d;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/BatteryMuchFragment$e;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/BatteryMuchFragment$f;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/BatteryMuchFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/SuperBatteryFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/SuperChargeFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/SuperCrankFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/charge/Step1Fragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/charge/Step1Fragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/charge/Step2Fragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/charge/Step2Fragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/charge/Step3Fragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/charge/Step3Fragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/history/SocFragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/history/SocFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/history/TempFragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/history/TempFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/history/VoltageFragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/battery/frag/history/VoltageFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/login/LoginActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/activity/SeekDetailActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/Alarm1Fragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/Alarm1Fragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/Alarm2Fragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/AlarmDialogFragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/AlarmDialogFragment$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/AlarmDialogFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/AlarmTimeLoadingDialogFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/AlarmTimeUpDialogFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/SeekFragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/SeekFragment$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/SeekFragment$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/SeekFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/SuperSeekFragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/SuperSeekFragment$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/seek/frag/SuperSeekFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/AboutActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/AboutActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/AboutActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/FeedBackActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/FeedBackActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/FeedBackActivity$c$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/FeedBackActivity$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/FeedBackActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/LanguageActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/LanguageActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/LanguageActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/ShareActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/ShareActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/ShareActivity$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/ShareActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/TripLogActivity;
classes2.dex Lcom/dc/bm6_ancel/util/upgrade/UpgradeLayout$e;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/UpgradeActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/UpgradeSingleActivity$a$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/UpgradeSingleActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/UpgradeSingleActivity$b$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/UpgradeSingleActivity$b$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/UpgradeSingleActivity$b$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/UpgradeSingleActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/UpgradeSingleActivity$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/UpgradeSingleActivity$d;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/UpgradeSingleActivity$e;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/activity/UpgradeSingleActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/fragment/SettingFragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/fragment/SettingFragment$b;
classes2.dex Lcom/dc/bm6_ancel/util/alarm/AlarmView$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/setting/fragment/SettingFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/trip/activity/TripDetailActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/trip/activity/TripDetailActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/trip/activity/TripDetailActivity$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/trip/activity/TripDetailActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/trip/frag/SuperTripFragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/trip/frag/SuperTripFragment$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/trip/frag/SuperTripFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/trip/frag/TripFragment$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/trip/frag/TripFragment$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/trip/frag/TripFragment$c;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/trip/frag/TripFragment$d;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/trip/frag/TripFragment;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/web/CodeActivity;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/web/WebActivity$a;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/web/WebActivity$b;
classes2.dex Lcom/dc/bm6_ancel/mvp/view/web/WebActivity;
classes2.dex Lcom/dc/bm6_ancel/util/MyLinearLayout;
classes2.dex Lcom/dc/bm6_ancel/util/alarm/AlarmView$a;
classes2.dex Lcom/dc/bm6_ancel/util/alarm/AlarmView;
classes2.dex Lcom/dc/bm6_ancel/util/alarm/TextThumbSeekBar;
classes2.dex Lcom/dc/bm6_ancel/util/chart/BTChart;
classes2.dex Lcom/dc/bm6_ancel/util/chart/ChartView;
classes2.dex Lcom/dc/bm6_ancel/util/chart/CurveSurfaceView;
classes2.dex Lcom/dc/bm6_ancel/util/chart/MuchCurveSurfaceView;
classes2.dex Lcom/dc/bm6_ancel/util/chart/StripeProgress$a;
classes2.dex Lcom/dc/bm6_ancel/util/chart/StripeProgress$b;
classes2.dex Lcom/dc/bm6_ancel/util/chart/StripeProgress;
classes2.dex Lcom/dc/bm6_ancel/util/chart/VoltChart;
classes2.dex Lcom/dc/bm6_ancel/util/chart/VoltChartBg;
classes2.dex Lcom/dc/bm6_ancel/util/chart/VoltCurve$a;
classes2.dex Lcom/dc/bm6_ancel/util/chart/VoltCurve;
classes2.dex Lcom/dc/bm6_ancel/util/glide/MyAppGlideModule;
classes2.dex Lcom/dc/bm6_ancel/util/recycler/CenterLayoutManager$a;
classes2.dex Lcom/dc/bm6_ancel/util/recycler/CenterLayoutManager;
classes2.dex Lcom/dc/bm6_ancel/util/recycler/DeviceItemDecoration;
classes2.dex Lcom/dc/bm6_ancel/util/recycler/FloatingItemDecoration;
classes2.dex Lcom/dc/bm6_ancel/util/recycler/GridItemDecoration;
classes2.dex Lcom/dc/bm6_ancel/util/recycler/LineItemDecoration2;
classes2.dex Lcom/dc/bm6_ancel/util/recycler/LineItemDecoration;
classes2.dex Lcom/dc/bm6_ancel/util/recycler/MarginDecoration;
classes2.dex Lcom/dc/bm6_ancel/util/recycler/SpaceItemDecoration;
classes2.dex Lcom/dc/bm6_ancel/util/textview/MarqueeTextView;
classes2.dex Lcom/dc/bm6_ancel/util/upgrade/UpgradeLayout$a$a;
classes2.dex Lcom/dc/bm6_ancel/util/upgrade/UpgradeLayout$a;
classes2.dex Lcom/dc/bm6_ancel/util/upgrade/UpgradeLayout$b$a;
classes2.dex Lcom/dc/bm6_ancel/util/upgrade/UpgradeLayout$b$b;
classes2.dex Lcom/dc/bm6_ancel/util/upgrade/UpgradeLayout$b$c;
classes2.dex Lcom/dc/bm6_ancel/util/upgrade/UpgradeLayout$b;
classes2.dex Lcom/dc/bm6_ancel/util/upgrade/UpgradeLayout$c;
classes2.dex Lcom/dc/bm6_ancel/util/upgrade/UpgradeLayout$d;
classes2.dex Lcom/dc/bm6_ancel/util/upgrade/UpgradeLayout;
classes2.dex Lcom/dc/bm6_ancel/wxapi/WXEntryActivity;

View File

@@ -0,0 +1,87 @@
classes.dex 'http://'
classes.dex 'http://dashif.org/guidelines/last-segment-number'
classes.dex 'http://dashif.org/guidelines/thumbnail_tile'
classes.dex 'http://dashif.org/guidelines/trickmode'
classes.dex 'http://dashif.org/thumbnail_tile'
classes.dex 'http://ns.adobe.com/xap/1.0/'
classes.dex 'http://ns.adobe.com/xap/1.0/<2F><>'
classes.dex 'http://schemas.android.com/apk/res/android'
classes.dex 'http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense'
classes.dex 'http://www.w3.org/ns/ttml#parameter'
classes.dex 'https://'
classes.dex 'https://aomedia.org/emsg/ID3'
classes.dex 'https://default.url'
classes.dex 'https://developer.apple.com/streaming/emsg-id3'
classes.dex 'https://x'
classes2.dex '86b99e87879824543197c5bd7c349c3e'
classes2.dex 'LEAGEND2018!'
classes2.dex 'LEAGEND2018VERYG'
classes2.dex 'http://'
classes2.dex 'http://schemas.android.com/apk/res-auto'
classes2.dex 'http://schemas.android.com/apk/res/android'
classes2.dex 'https://'
classes2.dex 'https://accounts.google.com'
classes2.dex 'https://accounts.google.com/o/oauth2/revoke?token='
classes2.dex 'https://doc.quicklynks.com/bm200/privacy-policy/index.html'
classes2.dex 'https://doc.quicklynks.com/bm200/terms-of-service/index.html'
classes2.dex 'https://erp.quicklynks.com/dacheng-erp/faq_co/list/075caef9dce37db6/'
classes2.dex 'https://login.live.com'
classes2.dex 'https://login.yahoo.com'
classes2.dex 'https://plus.google.com/'
classes2.dex 'https://twitter.com'
classes2.dex 'https://www.facebook.com'
classes2.dex 'https://www.googleapis.com/auth/appstate'
classes2.dex 'https://www.googleapis.com/auth/datastoremobile'
classes2.dex 'https://www.googleapis.com/auth/drive'
classes2.dex 'https://www.googleapis.com/auth/drive.appdata'
classes2.dex 'https://www.googleapis.com/auth/drive.apps'
classes2.dex 'https://www.googleapis.com/auth/drive.file'
classes2.dex 'https://www.googleapis.com/auth/games'
classes2.dex 'https://www.googleapis.com/auth/games.firstparty'
classes2.dex 'https://www.googleapis.com/auth/games_lite'
classes2.dex 'https://www.googleapis.com/auth/plus.login'
classes2.dex 'https://www.googleapis.com/auth/plus.me'
classes2.dex 'https://www.googleapis.com/auth/userinfo.email'
classes2.dex 'https://www.googleapis.com/auth/userinfo.profile'
classes2.dex 'https://www.linkedin.com'
classes2.dex 'https://www.paypal.com'
classes3.dex 'http://'
classes3.dex 'http://localhost/'
classes3.dex 'http://pingma.qq.com:80/mstat/report'
classes3.dex 'https://'
classes3.dex 'https://alogsus.umeng.com'
classes3.dex 'https://alogus.umeng.com'
classes3.dex 'https://aspect-upush.umeng.com/occa/v1/event/report'
classes3.dex 'https://audid.umeng.com/v3/a/audid/req'
classes3.dex 'https://ccs.umeng.com/aa'
classes3.dex 'https://cnlogs.umeng.com/common_inout_logs'
classes3.dex 'https://cnlogs.umeng.com/ext_event'
classes3.dex 'https://cnlogs.umeng.com/uapp_ekverr_logs'
classes3.dex 'https://developer.umeng.com/docs/66632/detail/'
classes3.dex 'https://dsp.ads.umeng.com/api/v2/evt'
classes3.dex 'https://dsp.ads.umeng.com/api/v2/rq'
classes3.dex 'https://long.open.weixin.qq.com/connect/l/qrconnect?f=json&uuid=%s'
classes3.dex 'https://msg.umengcloud.com/admsg/v3/getmsg'
classes3.dex 'https://msg.umengcloud.com/admsg/v3/launch'
classes3.dex 'https://msg.umengcloud.com/admsg/v3/stats'
classes3.dex 'https://msg.umengcloud.com/v3/alias'
classes3.dex 'https://msg.umengcloud.com/v3/alias/delete'
classes3.dex 'https://msg.umengcloud.com/v3/alias/set'
classes3.dex 'https://msg.umengcloud.com/v3/tag/add'
classes3.dex 'https://msg.umengcloud.com/v3/tag/delete'
classes3.dex 'https://msg.umengcloud.com/v3/tag/get'
classes3.dex 'https://offmsg.umeng.com/log/switch'
classes3.dex 'https://offmsg.umeng.com/log/upload'
classes3.dex 'https://one.umops.com/api/deferredLink'
classes3.dex 'https://open.weixin.qq.com/connect/sdk/qrconnect?appid=%s&noncestr=%s&timestamp=%s&scope=%s&signature=%s'
classes3.dex 'https://preulogs.umeng.com/push_cloud_activation'
classes3.dex 'https://pslog.umeng.com'
classes3.dex 'https://pslog.umeng.com/'
classes3.dex 'https://resolve.umeng.com/resolve'
classes3.dex 'https://sss.umeng.com/api/v2/al'
classes3.dex 'https://ucc.umeng.com/v2/inn/fetch'
classes3.dex 'https://ulogs.umeng.com'
classes3.dex 'https://ulogs.umeng.com/push_cloud_activation'
classes3.dex 'https://ulogs.umengcloud.com'
classes3.dex 'https://utoken.umeng.com'
classes3.dex 'https://yumao.puata.info/cc_info'

View File

@@ -0,0 +1,258 @@
"""ANCEL BM200 (BM6 protocol family), from the com.dc.bm6_ancel 2.0.6 APK.
BM200Decoder is transport-free: feed it raw FFF4 notifications from any relay.
BM200Client does the GATT session (bleak needed only for this part).
"""
import asyncio
import logging
import struct
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable, List, Optional, Type, TypeVar, Union
from bm2.encryption import decrypt, encrypt
logger = logging.getLogger("bm200")
BM200_KEY = b"leagend\xff\xfe0100009"
UUID_NOTIFY = "0000fff4-0000-1000-8000-00805f9b34fb"
UUID_WRITE = "0000fff3-0000-1000-8000-00805f9b34fb"
CMD_VERSION = bytes.fromhex("d15501")
CMD_ACCEPT = bytes.fromhex("d1550200") # never send d1550211: the app's "not genuine" verdict
CMD_LIVE = bytes.fromhex("d15507")
RECORD_INTERVAL = 120 # seconds between history records
MAX_RECORDS_31D = 22320 # app's cap; it uses 51840 (72 days) only for "bm6intact" devices
def history_command(count: int) -> bytes:
return bytes.fromhex("d15505000000") + struct.pack(">L", count)[1:]
@dataclass
class Version:
version: float
@dataclass
class AcceptAck:
result: int
@dataclass
class LiveReading:
time: datetime
voltage: float
soc: int
temperature: int
status: int # raw; 2 = engine running (app treats 2 -> other as engine off)
rapid_acceleration: int
rapid_deceleration: int
@dataclass
class HistoryRecord:
time: datetime
voltage: float
soc: int
temperature: int
mark: Optional[str] # "start" / "stop" of the engine
@dataclass
class HistoryBlock:
records: List[HistoryRecord] # oldest first
Event = Union[Version, AcceptAck, LiveReading, HistoryBlock]
def _history_base(received_at: float) -> float:
# Same as the app: floor to the minute, then step back onto an odd local minute.
base = int(received_at) // 60 * 60
return base - 60 if datetime.fromtimestamp(base).minute % 2 == 0 else base
def parse_history(data: str, base: float) -> List[HistoryRecord]:
"""data: hex up to the FFFEFE marker. Record 0 is the newest, 2 minutes apart."""
# ponytail: strips all leading zero records; the app's loop also skips some records after them
while data.startswith("00000000"):
data = data[8:]
records = []
pos = index = 0
while pos + 8 <= len(data):
item = data[pos:pos + 8]
pos += 8
flags = int(item[7], 16)
mark = {0b010: "start", 0b100: "stop"}.get(flags & 0b111)
if mark == "stop":
pos += 8 # stop records carry 4 more bytes: rapid acceleration/deceleration counts
voltage = int(item[:3], 16) / 100
if voltage != 0: # zero-voltage slots are dropped but still take up a time slot
temperature = int(item[5:7], 16)
records.append(HistoryRecord(time=datetime.fromtimestamp(base - index * RECORD_INTERVAL, timezone.utc),
voltage=voltage,
soc=int(item[3:5], 16),
temperature=-temperature if flags & 0b1000 else temperature,
mark=mark))
index += 1
records.reverse()
return records
class BM200Decoder:
def __init__(self) -> None:
self._history: Optional[str] = None
self._history_base = 0.0
def feed(self, frame: Union[bytes, bytearray], received_at: Optional[float] = None) -> List[Event]:
"""Raises ValueError if frame is not whole AES blocks."""
if not frame or len(frame) % 16:
raise ValueError(f"bad frame length {len(frame)}")
now = time.time() if received_at is None else received_at
h = decrypt(frame, BM200_KEY).hex().upper()
# Live frames interleave with history and must not end up in it.
if h.startswith("D15507"):
return [] if h.startswith("D15507FF") else [self._live(h, now)]
if self._history is not None:
return self._history_frame(h)
if h.startswith("D15501"):
return [Version(int(h[6:8], 16) + int(h[8:10], 16) / 10)]
if h.startswith("D15502"):
return [AcceptAck(int(h[6:8], 16))]
if h.startswith("D15505"):
if int(h[6:10], 16) == 0:
return [HistoryBlock([])]
self._history = ""
self._history_base = _history_base(now)
return []
logger.debug("ignored frame %s", h)
return []
def _history_frame(self, h: str) -> List[Event]:
assert self._history is not None
if "FEFEFE" in h: # app: "empty history marker, discard"
return []
self._history += h
end = self._history.find("FFFEFE")
if end < 0:
return []
data, self._history = self._history[:end], None
return [HistoryBlock(parse_history(data, self._history_base))]
@staticmethod
def _live(h: str, now: float) -> LiveReading:
temperature = int(h[8:10], 16)
return LiveReading(time=datetime.fromtimestamp(now, timezone.utc),
voltage=int(h[14:18], 16) / 100,
soc=int(h[12:14], 16),
temperature=-temperature if int(h[6:8], 16) == 1 else temperature,
status=int(h[10:12], 16),
rapid_acceleration=int(h[18:22], 16),
rapid_deceleration=int(h[22:26], 16))
T = TypeVar("T")
class BM200Client:
def __init__(self, mac: str, since: Optional[datetime] = None, max_records: int = MAX_RECORDS_31D,
history_timeout: float = 600, live_timeout: float = 120):
self._mac = mac
self.since = since # newest history record delivered so far
self._max_records = max_records
self._history_timeout = history_timeout
self._live_timeout = live_timeout
def history_count(self) -> int:
if self.since is None:
return self._max_records
missing = (datetime.now(timezone.utc) - self.since).total_seconds() / RECORD_INTERVAL
return min(max(int(missing), 0) + 10, self._max_records) # +10 overlap, as in the app
async def run(self,
on_reading: Callable[[Union[LiveReading, HistoryRecord]], None],
on_history_done: Optional[Callable[[datetime], None]] = None,
on_frame: Optional[Callable[[bytes], None]] = None) -> None:
"""Connect forever: on each connection backfill records newer than since, then stream live."""
import bleak.exc
from bleak import BleakClient
while True:
try:
async with BleakClient(self._mac) as client:
logger.info("connected")
await self._session(client, on_reading, on_history_done, on_frame)
except (bleak.exc.BleakError, OSError, asyncio.TimeoutError) as e:
logger.warning("session ended: %r", e)
await asyncio.sleep(5)
async def _session(self, client: Any, on_reading: Callable[..., None],
on_history_done: Optional[Callable[[datetime], None]],
on_frame: Optional[Callable[[bytes], None]]) -> None:
decoder = BM200Decoder()
queue: "asyncio.Queue[Event]" = asyncio.Queue()
last_frame = time.monotonic()
def handler(_: Any, data: bytearray) -> None:
nonlocal last_frame
last_frame = time.monotonic()
if on_frame:
on_frame(bytes(data))
try:
events = decoder.feed(data)
except ValueError as e:
logger.warning("%s", e)
return
for event in events:
if isinstance(event, LiveReading):
on_reading(event)
else:
queue.put_nowait(event)
async def send(cmd: bytes) -> None:
await client.write_gatt_char(UUID_WRITE, encrypt(cmd, BM200_KEY))
async def expect(kind: Type[T], timeout: float = 10) -> T:
while True:
event = await asyncio.wait_for(queue.get(), timeout)
if isinstance(event, kind):
return event
await client.start_notify(UUID_NOTIFY, handler)
# Same order as the app without its cloud check. Its battery-type write (D15508) is
# deliberately left out: it changes tag configuration.
await send(CMD_VERSION)
logger.info("firmware %s", (await expect(Version)).version)
await send(CMD_ACCEPT)
await expect(AcceptAck)
# The app always reads and discards 10 records before the real read.
await send(history_command(10))
await expect(HistoryBlock)
await send(history_command(self.history_count()))
block = await expect(HistoryBlock, self._history_timeout)
for record in block.records:
if self.since is None or record.time > self.since:
on_reading(record)
self.since = record.time
if self.since is not None and on_history_done:
on_history_done(self.since)
await send(CMD_LIVE)
while client.is_connected:
if time.monotonic() - last_frame > self._live_timeout:
raise asyncio.TimeoutError("no frames")
await asyncio.sleep(1)
__all__ = ["BM200Client", "BM200Decoder", "HistoryBlock", "HistoryRecord", "LiveReading", "parse_history"]

View File

@@ -0,0 +1,24 @@
from typing import Any, cast, Union
from Crypto.Cipher import AES
BM2_ENCRYPTION_KEY = b"\x6c\x65\x61\x67\x65\x6e\x64\xff\xfe\x31\x38\x38\x32\x34\x36\x36"
AesBlockSize = 16
def create_aes(key: bytes = BM2_ENCRYPTION_KEY) -> Any:
return AES.new(key, AES.MODE_CBC, bytes([0] * AesBlockSize))
def pad_to_block(x: bytes) -> bytes:
required_size = (len(x) + (AesBlockSize - 1)) // AesBlockSize * AesBlockSize
return x.ljust(required_size, b"\x00")
def encrypt(data: bytes, key: bytes = BM2_ENCRYPTION_KEY) -> bytes:
return cast(bytes, create_aes(key).encrypt(pad_to_block(data)))
def decrypt(encrypted_data: Union[bytes, bytearray], key: bytes = BM2_ENCRYPTION_KEY) -> bytes:
return cast(bytes, b''.join(create_aes(key).decrypt(bytes(encrypted_data[i:i + AesBlockSize])) for i in range(0, len(encrypted_data), AesBlockSize)))

154
findings/mac_test/probe.py Normal file
View File

@@ -0,0 +1,154 @@
"""BM200 hardware probe. Test bench only, not the product.
python probe.py scan
python probe.py run <id> # handshake, history(10), live
python probe.py run <id> --no-accept # is ACCEPT needed?
python probe.py run <id> --crank # try this if LIVE stays silent
python probe.py run <id> --history 360 # time a larger read (12 hours)
Every frame goes to probe-<time>.jsonl. Never sends D15508 (battery type) or D1550211.
"""
import argparse
import asyncio
import dataclasses
import json
import sys
import time
from bleak import BleakClient, BleakScanner
from bm2.bm200 import BM200_KEY, UUID_NOTIFY, UUID_WRITE, CMD_ACCEPT, CMD_LIVE, CMD_VERSION, BM200Decoder, HistoryBlock, LiveReading, history_command
from bm2.encryption import decrypt, encrypt
CMD_CRANK = bytes.fromhex("d15503")
NAMES = ("bm6", "battery guard")
async def scan(seconds):
found = await BleakScanner.discover(timeout=seconds, return_adv=True)
matches = 0
for device, adv in found.values():
name = adv.local_name or device.name or ""
if name.lower() in NAMES:
matches += 1
print(f"{device.address} {name!r} rssi={adv.rssi}")
if not matches:
sys.exit(f"no tag among {len(found)} devices. Check Bluetooth permission, and disconnect the vendor app.")
def jsonable(event):
return {"type": type(event).__name__, **dataclasses.asdict(event)}
async def run(args):
log = open(f"probe-{int(time.time())}.jsonl", "w")
decoder = BM200Decoder()
frames, live_times = [], []
history_done = asyncio.Event()
got_frame = asyncio.Event()
def record(direction, data, plain, events=()):
log.write(json.dumps({"t": time.time(), "dir": direction, "cipher": data.hex(), "plain": plain.hex(),
"events": [jsonable(e) for e in events]}, default=str) + "\n")
log.flush()
def handler(_, data):
data = bytes(data)
frames.append(time.monotonic())
got_frame.set()
try:
plain, events = decrypt(data, BM200_KEY), decoder.feed(data)
except ValueError as e:
print(f" rx BAD {data.hex()}: {e}")
return
record("rx", data, plain, events)
if not events:
print(f" rx {plain.hex()}")
for event in events:
if isinstance(event, LiveReading):
live_times.append(time.monotonic())
print(f" live {event.voltage} V, soc {event.soc}, temp {event.temperature}, status {event.status}")
elif isinstance(event, HistoryBlock):
print(f" history: {len(event.records)} records")
for r in event.records[-3:]:
print(f" {r.time.isoformat()} {r.voltage} V, soc {r.soc}, temp {r.temperature}, {r.mark}")
history_done.set()
else:
print(f" {event}")
print(f"connecting to {args.id}")
async with BleakClient(args.id, timeout=30) as client:
for service in client.services:
for char in service.characteristics:
print(f" {service.uuid[4:8]}/{char.uuid[4:8]} {char.properties}")
write_char = client.services.get_characteristic(UUID_WRITE)
if write_char is None:
sys.exit("no FFF3 characteristic: this is not the expected tag")
with_response = "write" in write_char.properties
async def send(name, cmd, wait=5.0):
print(f"> {name}")
data = encrypt(cmd, BM200_KEY)
record("tx", data, cmd.ljust(16, b"\0"))
got_frame.clear()
await client.write_gatt_char(UUID_WRITE, data, response=with_response)
try:
await asyncio.wait_for(got_frame.wait(), wait)
except asyncio.TimeoutError:
print(f" no reply within {wait:.0f}s")
async def read_history(count, timeout):
history_done.clear()
started = time.monotonic()
await send(f"HISTORY({count})", history_command(count))
try:
await asyncio.wait_for(history_done.wait(), timeout)
print(f" took {time.monotonic() - started:.1f}s")
except asyncio.TimeoutError:
print(f" history did not finish within {timeout:.0f}s")
await client.start_notify(UUID_NOTIFY, handler)
print("> listening before any command, 5s")
await asyncio.sleep(5)
print(f" {len(frames)} unsolicited frames")
await send("VERSION", CMD_VERSION)
if not args.no_accept:
await send("ACCEPT", CMD_ACCEPT)
await read_history(10, 30)
if args.history:
await read_history(args.history, args.history_timeout)
if args.crank:
await send("CRANK", CMD_CRANK)
await asyncio.sleep(3)
await send("LIVE", CMD_LIVE)
print(f"> collecting live frames, {args.live_seconds}s")
await asyncio.sleep(args.live_seconds)
gaps = [b - a for a, b in zip(live_times, live_times[1:])]
print(f"\nlive frames: {len(live_times)}")
if gaps:
print(f"interval: avg {sum(gaps) / len(gaps):.1f}s, min {min(gaps):.1f}s, max {max(gaps):.1f}s")
print(f"log: {log.name}")
log.close()
def main():
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("scan").add_argument("--seconds", type=float, default=10)
r = sub.add_parser("run")
r.add_argument("id", help="address from scan (a UUID on macOS)")
r.add_argument("--no-accept", action="store_true")
r.add_argument("--crank", action="store_true")
r.add_argument("--history", type=int, metavar="COUNT", help="extra history read after the first 10 records")
r.add_argument("--history-timeout", type=float, default=600)
r.add_argument("--live-seconds", type=float, default=120)
args = parser.parse_args()
asyncio.run(scan(args.seconds) if args.command == "scan" else run(args))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,2 @@
bleak
pycryptodome

View File

@@ -0,0 +1,258 @@
"""ANCEL BM200 (BM6 protocol family), from the com.dc.bm6_ancel 2.0.6 APK.
BM200Decoder is transport-free: feed it raw FFF4 notifications from any relay.
BM200Client does the GATT session (bleak needed only for this part).
"""
import asyncio
import logging
import struct
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable, List, Optional, Type, TypeVar, Union
from bm2.encryption import decrypt, encrypt
logger = logging.getLogger("bm200")
BM200_KEY = b"leagend\xff\xfe0100009"
UUID_NOTIFY = "0000fff4-0000-1000-8000-00805f9b34fb"
UUID_WRITE = "0000fff3-0000-1000-8000-00805f9b34fb"
CMD_VERSION = bytes.fromhex("d15501")
CMD_ACCEPT = bytes.fromhex("d1550200") # never send d1550211: the app's "not genuine" verdict
CMD_LIVE = bytes.fromhex("d15507")
RECORD_INTERVAL = 120 # seconds between history records
MAX_RECORDS_31D = 22320 # app's cap; it uses 51840 (72 days) only for "bm6intact" devices
def history_command(count: int) -> bytes:
return bytes.fromhex("d15505000000") + struct.pack(">L", count)[1:]
@dataclass
class Version:
version: float
@dataclass
class AcceptAck:
result: int
@dataclass
class LiveReading:
time: datetime
voltage: float
soc: int
temperature: int
status: int # raw; 2 = engine running (app treats 2 -> other as engine off)
rapid_acceleration: int
rapid_deceleration: int
@dataclass
class HistoryRecord:
time: datetime
voltage: float
soc: int
temperature: int
mark: Optional[str] # "start" / "stop" of the engine
@dataclass
class HistoryBlock:
records: List[HistoryRecord] # oldest first
Event = Union[Version, AcceptAck, LiveReading, HistoryBlock]
def _history_base(received_at: float) -> float:
# Same as the app: floor to the minute, then step back onto an odd local minute.
base = int(received_at) // 60 * 60
return base - 60 if datetime.fromtimestamp(base).minute % 2 == 0 else base
def parse_history(data: str, base: float) -> List[HistoryRecord]:
"""data: hex up to the FFFEFE marker. Record 0 is the newest, 2 minutes apart."""
# ponytail: strips all leading zero records; the app's loop also skips some records after them
while data.startswith("00000000"):
data = data[8:]
records = []
pos = index = 0
while pos + 8 <= len(data):
item = data[pos:pos + 8]
pos += 8
flags = int(item[7], 16)
mark = {0b010: "start", 0b100: "stop"}.get(flags & 0b111)
if mark == "stop":
pos += 8 # stop records carry 4 more bytes: rapid acceleration/deceleration counts
voltage = int(item[:3], 16) / 100
if voltage != 0: # zero-voltage slots are dropped but still take up a time slot
temperature = int(item[5:7], 16)
records.append(HistoryRecord(time=datetime.fromtimestamp(base - index * RECORD_INTERVAL, timezone.utc),
voltage=voltage,
soc=int(item[3:5], 16),
temperature=-temperature if flags & 0b1000 else temperature,
mark=mark))
index += 1
records.reverse()
return records
class BM200Decoder:
def __init__(self) -> None:
self._history: Optional[str] = None
self._history_base = 0.0
def feed(self, frame: Union[bytes, bytearray], received_at: Optional[float] = None) -> List[Event]:
"""Raises ValueError if frame is not whole AES blocks."""
if not frame or len(frame) % 16:
raise ValueError(f"bad frame length {len(frame)}")
now = time.time() if received_at is None else received_at
h = decrypt(frame, BM200_KEY).hex().upper()
# Live frames interleave with history and must not end up in it.
if h.startswith("D15507"):
return [] if h.startswith("D15507FF") else [self._live(h, now)]
if self._history is not None:
return self._history_frame(h)
if h.startswith("D15501"):
return [Version(int(h[6:8], 16) + int(h[8:10], 16) / 10)]
if h.startswith("D15502"):
return [AcceptAck(int(h[6:8], 16))]
if h.startswith("D15505"):
if int(h[6:10], 16) == 0:
return [HistoryBlock([])]
self._history = ""
self._history_base = _history_base(now)
return []
logger.debug("ignored frame %s", h)
return []
def _history_frame(self, h: str) -> List[Event]:
assert self._history is not None
if "FEFEFE" in h: # app: "empty history marker, discard"
return []
self._history += h
end = self._history.find("FFFEFE")
if end < 0:
return []
data, self._history = self._history[:end], None
return [HistoryBlock(parse_history(data, self._history_base))]
@staticmethod
def _live(h: str, now: float) -> LiveReading:
temperature = int(h[8:10], 16)
return LiveReading(time=datetime.fromtimestamp(now, timezone.utc),
voltage=int(h[14:18], 16) / 100,
soc=int(h[12:14], 16),
temperature=-temperature if int(h[6:8], 16) == 1 else temperature,
status=int(h[10:12], 16),
rapid_acceleration=int(h[18:22], 16),
rapid_deceleration=int(h[22:26], 16))
T = TypeVar("T")
class BM200Client:
def __init__(self, mac: str, since: Optional[datetime] = None, max_records: int = MAX_RECORDS_31D,
history_timeout: float = 600, live_timeout: float = 120):
self._mac = mac
self.since = since # newest history record delivered so far
self._max_records = max_records
self._history_timeout = history_timeout
self._live_timeout = live_timeout
def history_count(self) -> int:
if self.since is None:
return self._max_records
missing = (datetime.now(timezone.utc) - self.since).total_seconds() / RECORD_INTERVAL
return min(max(int(missing), 0) + 10, self._max_records) # +10 overlap, as in the app
async def run(self,
on_reading: Callable[[Union[LiveReading, HistoryRecord]], None],
on_history_done: Optional[Callable[[datetime], None]] = None,
on_frame: Optional[Callable[[bytes], None]] = None) -> None:
"""Connect forever: on each connection backfill records newer than since, then stream live."""
import bleak.exc
from bleak import BleakClient
while True:
try:
async with BleakClient(self._mac) as client:
logger.info("connected")
await self._session(client, on_reading, on_history_done, on_frame)
except (bleak.exc.BleakError, OSError, asyncio.TimeoutError) as e:
logger.warning("session ended: %r", e)
await asyncio.sleep(5)
async def _session(self, client: Any, on_reading: Callable[..., None],
on_history_done: Optional[Callable[[datetime], None]],
on_frame: Optional[Callable[[bytes], None]]) -> None:
decoder = BM200Decoder()
queue: "asyncio.Queue[Event]" = asyncio.Queue()
last_frame = time.monotonic()
def handler(_: Any, data: bytearray) -> None:
nonlocal last_frame
last_frame = time.monotonic()
if on_frame:
on_frame(bytes(data))
try:
events = decoder.feed(data)
except ValueError as e:
logger.warning("%s", e)
return
for event in events:
if isinstance(event, LiveReading):
on_reading(event)
else:
queue.put_nowait(event)
async def send(cmd: bytes) -> None:
await client.write_gatt_char(UUID_WRITE, encrypt(cmd, BM200_KEY))
async def expect(kind: Type[T], timeout: float = 10) -> T:
while True:
event = await asyncio.wait_for(queue.get(), timeout)
if isinstance(event, kind):
return event
await client.start_notify(UUID_NOTIFY, handler)
# Same order as the app without its cloud check. Its battery-type write (D15508) is
# deliberately left out: it changes tag configuration.
await send(CMD_VERSION)
logger.info("firmware %s", (await expect(Version)).version)
await send(CMD_ACCEPT)
await expect(AcceptAck)
# The app always reads and discards 10 records before the real read.
await send(history_command(10))
await expect(HistoryBlock)
await send(history_command(self.history_count()))
block = await expect(HistoryBlock, self._history_timeout)
for record in block.records:
if self.since is None or record.time > self.since:
on_reading(record)
self.since = record.time
if self.since is not None and on_history_done:
on_history_done(self.since)
await send(CMD_LIVE)
while client.is_connected:
if time.monotonic() - last_frame > self._live_timeout:
raise asyncio.TimeoutError("no frames")
await asyncio.sleep(1)
__all__ = ["BM200Client", "BM200Decoder", "HistoryBlock", "HistoryRecord", "LiveReading", "parse_history"]

View File

@@ -0,0 +1,58 @@
# ANCEL BM200: live voltage only. History needs bm2.bm200 (reassembly across frames).
# The writes are AES-encrypted D15501, D1550200, D15507; without the last one the tag sends no live frames.
esp32_ble_tracker:
ble_client:
- mac_address: !secret bm2_mac
id: bm2_battery_meter
on_connect:
then:
- delay: 1s
- ble_client.ble_write:
id: bm2_battery_meter
service_uuid: 'fff0'
characteristic_uuid: 'fff3'
value: [0x1a, 0xd6, 0xf0, 0x5e, 0x49, 0x90, 0xab, 0x24, 0xb8, 0x4d, 0x57, 0xdf, 0x81, 0xd8, 0x76, 0x6e]
- delay: 500ms
- ble_client.ble_write:
id: bm2_battery_meter
service_uuid: 'fff0'
characteristic_uuid: 'fff3'
value: [0x9c, 0xb1, 0x80, 0x29, 0x77, 0x4c, 0x5c, 0xfc, 0x57, 0xc9, 0xf1, 0x14, 0xd4, 0x0d, 0xb4, 0xe8]
- delay: 500ms
- ble_client.ble_write:
id: bm2_battery_meter
service_uuid: 'fff0'
characteristic_uuid: 'fff3'
value: [0x69, 0x7e, 0xa0, 0xb5, 0xd5, 0x4c, 0xf0, 0x24, 0xe7, 0x94, 0x77, 0x23, 0x55, 0x55, 0x41, 0x14]
sensor:
- platform: ble_client
type: characteristic
ble_client_id: bm2_battery_meter
name: Voltage
service_uuid: 'fff0'
characteristic_uuid: 'fff4'
unit_of_measurement: 'V'
accuracy_decimals: 2
state_class: measurement
device_class: voltage
force_update: true
expire_after: 5min
notify: true
lambda: |-
if (x.size() != 16) return NAN;
mbedtls_aes_context aes;
mbedtls_aes_init(&aes);
unsigned char output[16];
unsigned char key[16] = { 108, 101, 97, 103, 101, 110, 100, 255, 254, 48, 49, 48, 48, 48, 48, 57 };
unsigned char iv[16] = {};
mbedtls_aes_setkey_dec(&aes, key, 128);
mbedtls_aes_crypt_cbc(&aes, MBEDTLS_AES_DECRYPT, 16, iv, (uint8_t*)&x[0], output);
mbedtls_aes_free(&aes);
// D1 55 07 only; 0xFF in byte 3 is the command ack
if (output[0] != 0xD1 || output[1] != 0x55 || output[2] != 0x07 || output[3] == 0xFF) return NAN;
return ((output[7] << 8) | output[8]) / 100.0f;
filters:
- filter_out: nan

View File

@@ -0,0 +1,71 @@
"""Writes BM200 readings to stdout as JSON lines; pipe that into your logging system.
Direct BLE: bm200_logger.py --bm200-addr B0:B1:xx:xx:xx:xx --state-file bm200.state
From relay: relay-feed | bm200_logger.py --stdin
(one raw FFF4 notification per line: "<hex>" or "<epoch> <hex>")
"""
import argparse
import asyncio
import dataclasses
import json
import logging
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
from bm2.bm200 import BM200Client, BM200Decoder, HistoryBlock, LiveReading
logging.basicConfig(level=logging.INFO, stream=sys.stderr, format="[%(asctime)s] %(message)s")
def emit(reading: Any) -> None:
row = dataclasses.asdict(reading)
row["type"] = "live" if isinstance(reading, LiveReading) else "history"
row["time"] = reading.time.isoformat()
print(json.dumps(row), flush=True)
def from_stdin() -> None:
decoder = BM200Decoder()
for line in sys.stdin:
parts = line.split()
if not parts:
continue
try:
events = decoder.feed(bytes.fromhex(parts[-1]), float(parts[0]) if len(parts) > 1 else None)
except ValueError as e: # relay input: skip it, keep logging
logging.warning("bad line %r: %s", line, e)
continue
for event in events:
if isinstance(event, LiveReading):
emit(event)
elif isinstance(event, HistoryBlock):
for record in event.records:
emit(record)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--bm200-addr", metavar="ADDR")
parser.add_argument("--state-file", type=Path, help="remembers the newest history record between runs")
parser.add_argument("--stdin", action="store_true")
args = parser.parse_args()
if args.stdin:
return from_stdin()
if not args.bm200_addr:
parser.error("--bm200-addr or --stdin required")
since = None
if args.state_file and args.state_file.exists():
since = datetime.fromisoformat(args.state_file.read_text().strip())
def save(newest: datetime) -> None:
if args.state_file:
args.state_file.write_text(newest.isoformat())
asyncio.run(BM200Client(args.bm200_addr, since).run(emit, save))
main()

View File

@@ -0,0 +1,24 @@
from typing import Any, cast, Union
from Crypto.Cipher import AES
BM2_ENCRYPTION_KEY = b"\x6c\x65\x61\x67\x65\x6e\x64\xff\xfe\x31\x38\x38\x32\x34\x36\x36"
AesBlockSize = 16
def create_aes(key: bytes = BM2_ENCRYPTION_KEY) -> Any:
return AES.new(key, AES.MODE_CBC, bytes([0] * AesBlockSize))
def pad_to_block(x: bytes) -> bytes:
required_size = (len(x) + (AesBlockSize - 1)) // AesBlockSize * AesBlockSize
return x.ljust(required_size, b"\x00")
def encrypt(data: bytes, key: bytes = BM2_ENCRYPTION_KEY) -> bytes:
return cast(bytes, create_aes(key).encrypt(pad_to_block(data)))
def decrypt(encrypted_data: Union[bytes, bytearray], key: bytes = BM2_ENCRYPTION_KEY) -> bytes:
return cast(bytes, b''.join(create_aes(key).decrypt(bytes(encrypted_data[i:i + AesBlockSize])) for i in range(0, len(encrypted_data), AesBlockSize)))

View File

@@ -0,0 +1,58 @@
"""Run: PYTHONPATH=src python test_bm200.py
Frames here are built from the APK's parser, not captured from a tag.
"""
from datetime import datetime, timezone
from bm2.bm200 import BM200_KEY, BM200Decoder, HistoryBlock, LiveReading, Version, _history_base, history_command
from bm2.encryption import encrypt
def enc(hex_str: str) -> bytes:
return encrypt(bytes.fromhex(hex_str), BM200_KEY)
def test() -> None:
d = BM200Decoder()
now = 1_700_000_000.0
assert d.feed(enc("d15501010700"))[0] == Version(1.7)
assert history_command(0xCA80).hex() == "d1550500000000ca80"
# temp sign=1 temp=5 status=2 soc=0x55 volt=0x0508 acc=3 dec=4
live = d.feed(enc("d155070105025505080003000400"), now)[0]
assert isinstance(live, LiveReading)
assert (live.temperature, live.status, live.soc, live.voltage) == (-5, 2, 0x55, 12.88)
assert (live.rapid_acceleration, live.rapid_deceleration) == (3, 4)
assert d.feed(enc("d15507ff")) == []
assert d.feed(enc("d155050003"), now) == []
# newest first: leading zeros, stop record + 4 trailer bytes, plain record
assert d.feed(enc("00000000" + "50055194" + "00030004" + "4f050140"), now) == []
# a live frame in the middle of a transfer stays out of the history
assert isinstance(d.feed(enc("d155070005005505080000000000"), now)[0], LiveReading)
# negative-temperature start record, then the end marker
block = d.feed(enc("4e0000aa" + "fffefe00"), now)[0]
assert isinstance(block, HistoryBlock)
r = block.records
assert [x.voltage for x in r] == [12.48, 12.64, 12.80] # oldest first
assert [x.mark for x in r] == ["start", None, "stop"]
assert (r[2].soc, r[2].temperature) == (0x55, 0x19)
assert (r[0].soc, r[0].temperature) == (0, -0x0a)
base = _history_base(now)
assert datetime.fromtimestamp(base).minute % 2 == 1
assert r[2].time == datetime.fromtimestamp(base, timezone.utc)
assert (r[2].time - r[0].time).total_seconds() == 240
# back to normal once the block ended
assert d.feed(enc("d155050000"), now)[0] == HistoryBlock([])
try:
d.feed(b"\x00" * 15)
raise AssertionError("short frame accepted")
except ValueError:
pass
test()
print("ok")

192
findings/tools/dex.py Normal file
View File

@@ -0,0 +1,192 @@
"""Minimal read-only DEX parser + Dalvik disassembler (stdlib only).
usage: dex.py strings <apk> <regex> | classes <apk> <regex> | dis <apk> <class-regex> [method-regex]
"""
import re, struct, sys, zipfile
def uleb(b, o):
r = s = 0
while True:
c = b[o]; o += 1
r |= (c & 0x7f) << s; s += 7
if not c & 0x80:
return r, o
class Dex:
def __init__(s, b):
s.b = b
u = lambda off: struct.unpack_from('<I', b, off)[0]
s.nstr, s.ostr = u(0x38), u(0x3c)
s.ntyp, s.otyp = u(0x40), u(0x44)
s.oproto = u(0x4c)
s.nfld, s.ofld = u(0x50), u(0x54)
s.nmth, s.omth = u(0x58), u(0x5c)
s.ncls, s.ocls = u(0x60), u(0x64)
def str(s, i):
o = struct.unpack_from('<I', s.b, s.ostr + i * 4)[0]
_, o = uleb(s.b, o)
e = s.b.index(0, o)
return s.b[o:e].decode('utf-8', 'replace')
def typ(s, i):
return s.str(struct.unpack_from('<I', s.b, s.otyp + i * 4)[0])
def fld(s, i):
c, t, n = struct.unpack_from('<HHI', s.b, s.ofld + i * 8)
return f'{s.typ(c)}->{s.str(n)}:{s.typ(t)}'
def mth(s, i):
c, p, n = struct.unpack_from('<HHI', s.b, s.omth + i * 8)
return f'{s.typ(c)}->{s.str(n)}'
def classes(s):
for i in range(s.ncls):
yield struct.unpack_from('<8I', s.b, s.ocls + i * 32)
def methods(s, cd):
off = cd[6]
if not off:
return
b = s.b
sf, o = uleb(b, off); inf, o = uleb(b, o); dm, o = uleb(b, o); vm, o = uleb(b, o)
for _ in range(sf + inf):
_, o = uleb(b, o); _, o = uleb(b, o)
for n in (dm, vm):
idx = 0
for _ in range(n):
d, o = uleb(b, o); _, o = uleb(b, o); code, o = uleb(b, o)
idx += d
yield idx, code
def dis(s, code):
b = s.b
insns = struct.unpack_from('<I', b, code + 12)[0]
u = struct.unpack_from(f'<{insns}H', b, code + 16)
pc, out = 0, []
while pc < insns:
w = u[pc]; op = w & 0xff; aa = w >> 8
if w in (0x100, 0x200, 0x300): # data payloads
if w == 0x100: l = 4 + u[pc + 1] * 2
elif w == 0x200: l = 2 + u[pc + 1] * 4
else:
n = struct.unpack_from('<I', b, code + 16 + (pc + 2) * 2)[0]
width = u[pc + 1]
data = b[code + 16 + (pc + 4) * 2:code + 16 + (pc + 4) * 2 + n * width]
out.append(f'{pc:04x} array-data w={width} {data.hex()}')
l = 4 + (n * width + 1) // 2
pc += l
continue
line, l = s.op(op, aa, u, pc)
out.append(f'{pc:04x} {line}')
pc += l
return out
def op(s, op, aa, u, pc):
a4, b4 = aa & 15, aa >> 4
sx16 = lambda v: v - 65536 if v > 32767 else v
if op == 0: return 'nop', 1
if op in (1, 4, 7): return f'move v{a4},v{b4}', 1
if op in (2, 5, 8): return f'move/from16 v{aa},v{u[pc+1]}', 2
if op in (3, 6, 9): return f'move/16 v{u[pc+1]},v{u[pc+2]}', 3
if 0xa <= op <= 0xd: return f'move-result v{aa}', 1
if op == 0xe: return 'return-void', 1
if 0xf <= op <= 0x11: return f'return v{aa}', 1
if op == 0x12: return f'const/4 v{a4},{(b4 ^ 8) - 8}', 1
if op == 0x13: return f'const/16 v{aa},{sx16(u[pc+1])} ', 2
if op == 0x14: return f'const v{aa},0x{u[pc+1] | u[pc+2] << 16:x}', 3
if op == 0x15: return f'const/high16 v{aa},0x{u[pc+1] << 16:x}', 2
if op == 0x16: return f'const-wide/16 v{aa},{sx16(u[pc+1])}', 2
if op == 0x17: return f'const-wide/32 v{aa},0x{u[pc+1] | u[pc+2] << 16:x}', 3
if op == 0x18: return f'const-wide v{aa},0x{u[pc+1] | u[pc+2] << 16 | u[pc+3] << 32 | u[pc+4] << 48:x}', 5
if op == 0x19: return f'const-wide/high16 v{aa},0x{u[pc+1]:x}<<48', 2
if op == 0x1a: return f'const-string v{aa},{s.str(u[pc+1])!r}', 2
if op == 0x1b: return f'const-string v{aa},{s.str(u[pc+1] | u[pc+2] << 16)!r}', 3
if op == 0x1c: return f'const-class v{aa},{s.typ(u[pc+1])}', 2
if op in (0x1d, 0x1e): return f'monitor v{aa}', 1
if op == 0x1f: return f'check-cast v{aa},{s.typ(u[pc+1])}', 2
if op == 0x20: return f'instance-of v{a4},v{b4},{s.typ(u[pc+1])}', 2
if op == 0x21: return f'array-length v{a4},v{b4}', 1
if op == 0x22: return f'new-instance v{aa},{s.typ(u[pc+1])}', 2
if op == 0x23: return f'new-array v{a4},v{b4},{s.typ(u[pc+1])}', 2
if op == 0x24: return f'filled-new-array {s.typ(u[pc+1])}', 3
if op == 0x25: return f'filled-new-array/range {s.typ(u[pc+1])}', 3
if op == 0x26: return f'fill-array-data v{aa},+{u[pc+1] | u[pc+2] << 16:x}', 3
if op == 0x27: return f'throw v{aa}', 1
if op == 0x28: return f'goto {pc + ((aa ^ 0x80) - 0x80):04x}', 1
if op == 0x29: return f'goto {pc + sx16(u[pc+1]):04x}', 2
if op == 0x2a: return 'goto/32', 3
if op in (0x2b, 0x2c): return f'switch v{aa}', 3
if 0x2d <= op <= 0x31: return f'cmp v{aa},v{u[pc+1] & 255},v{u[pc+1] >> 8}', 2
if 0x32 <= op <= 0x37:
return f"if-{['eq','ne','lt','ge','gt','le'][op-0x32]} v{a4},v{b4},{pc + sx16(u[pc+1]):04x}", 2
if 0x38 <= op <= 0x3d:
return f"if-{['eq','ne','lt','ge','gt','le'][op-0x38]}z v{aa},{pc + sx16(u[pc+1]):04x}", 2
if 0x3e <= op <= 0x43 or 0x73 == op or 0x79 <= op <= 0x7a or 0xe3 <= op: return f'unused {op:x}', 1
if 0x44 <= op <= 0x4a: return f'aget v{aa},v{u[pc+1] & 255},v{u[pc+1] >> 8}', 2
if 0x4b <= op <= 0x51: return f'aput v{aa},v{u[pc+1] & 255},v{u[pc+1] >> 8}', 2
if 0x52 <= op <= 0x58: return f'iget v{a4},v{b4},{s.fld(u[pc+1])}', 2
if 0x59 <= op <= 0x5f: return f'iput v{a4},v{b4},{s.fld(u[pc+1])}', 2
if 0x60 <= op <= 0x66: return f'sget v{aa},{s.fld(u[pc+1])}', 2
if 0x67 <= op <= 0x6d: return f'sput v{aa},{s.fld(u[pc+1])}', 2
if 0x6e <= op <= 0x72:
n = b4; g = a4; w = u[pc+2]
regs = [w & 15, w >> 4 & 15, w >> 8 & 15, w >> 12, g][:n]
return f"invoke {s.mth(u[pc+1])} ({','.join('v%d' % r for r in regs)})", 3
if 0x74 <= op <= 0x78:
return f'invoke/range {s.mth(u[pc+1])} (v{u[pc+2]}..+{aa})', 3
if 0x7b <= op <= 0x8f: return f'unop{op:x} v{a4},v{b4}', 1
bin_ = ['add','sub','mul','div','rem','and','or','xor','shl','shr','ushr']
if 0x90 <= op <= 0xaf:
n = bin_[(op - 0x90) % 11] if op < 0xa6 else 'fp'
return f'{n} v{aa},v{u[pc+1] & 255},v{u[pc+1] >> 8}', 2
if 0xb0 <= op <= 0xcf:
n = bin_[(op - 0xb0) % 11] if op < 0xc6 else 'fp'
return f'{n}/2addr v{a4},v{b4}', 1
if 0xd0 <= op <= 0xd7:
n = ['add','rsub','mul','div','rem','and','or','xor'][op - 0xd0]
return f'{n}/lit16 v{a4},v{b4},{sx16(u[pc+1])}', 2
if 0xd8 <= op <= 0xe2:
n = ['add','rsub','mul','div','rem','and','or','xor','shl','shr','ushr'][op - 0xd8]
lit = u[pc+1] >> 8
return f'{n}/lit8 v{aa},v{u[pc+1] & 255},{(lit ^ 0x80) - 0x80}', 2
return f'?? {op:x}', 1
def dexes(apk):
z = zipfile.ZipFile(apk)
for n in sorted(z.namelist()):
if re.fullmatch(r'classes\d*\.dex', n):
yield n, Dex(z.read(n))
def main():
cmd, apk, rx = sys.argv[1:4]
rx = re.compile(rx)
for name, d in dexes(apk):
if cmd == 'strings':
for i in range(d.nstr):
t = d.str(i)
if rx.search(t) and len(t) < 300:
print(name, repr(t))
elif cmd == 'classes':
for cd in d.classes():
t = d.typ(cd[0])
if rx.search(t):
print(name, t)
elif cmd == 'dis':
mrx = re.compile(sys.argv[4]) if len(sys.argv) > 4 else None
for cd in d.classes():
t = d.typ(cd[0])
if not rx.search(t):
continue
for idx, code in d.methods(cd):
m = d.mth(idx)
if code and (not mrx or mrx.search(m)):
print('\n###', m)
print('\n'.join(d.dis(code)))
if __name__ == '__main__':
main()

26
findings/tools/gen.py Normal file
View File

@@ -0,0 +1,26 @@
import sys,dataclasses,json
sys.path.insert(0,sys.argv[1])
from bm2.bm200 import *
from bm2.bm200 import BM200_KEY,history_command
from bm2.encryption import encrypt
E=lambda h:encrypt(bytes.fromhex(h),BM200_KEY).hex()
print("CMD")
for n,c in [("version","d15501"),("accept","d1550200"),("live","d15507"),("hist10",history_command(10).hex()),("hist22320",history_command(22320).hex()),("hist51840",history_command(51840).hex())]:
print(n,c,E(c))
def show(evs):
for e in evs:
d=dataclasses.asdict(e); print(" ->",type(e).__name__,json.dumps(d,default=lambda x:x.isoformat()))
now=1700000000.0
def run(name,frames,t=now):
print(name); d=BM200Decoder()
for f in frames:
print(" plain",f); print(" cipher",E(f)); show(d.feed(bytes.fromhex(E(f)),t))
run("version",["d15501010700"])
run("ack",["d1550200"])
run("liveack",["d15507ff"])
run("live",["d155070019025505080003000400"])
run("liveneg",["d155070105025505080003000400"])
run("histempty",["d155050000"])
run("hist",["d155050003","00000000"+"50055194"+"00030004"+"4f050140","d155070019005505080000000000","4e0000aa"+"fffefe00"])
run("histeven",["d155050001","50055190fffefe00"],1700000040.0)
run("feskip",["d155050001","fefefe00","50055190fffefe00"])