From 2fc7809873f4d5d647cb3bb6bb5c4744143addf5 Mon Sep 17 00:00:00 2001 From: Kali Agent Date: Tue, 29 Sep 2026 00:02:50 +0800 Subject: [PATCH 1/2] feat(model): per-model board addressing, from m365 Tools reverse engineering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the mechanism that actually differs between scooter models — where a model's boards sit on the wire — and the reverse-engineering record it came from. ## Why `Board::address()` hard-coded `0x20`/`0x22` as *the* ESC/BMS addresses. That is the Xiaomi lineage's send id and nothing more. A second reference implementation, **m365 Tools 1.8.0** (`app.peretti.m365tools`, statically analysed), stores two addresses per board — its `wm` record's `toString()` labels them `mID` and `mReceiveID` — and they differ by family: Xiaomi M365/Pro/Pro2/1S/1S-DE/Lite/Mi3 ESC 0x20->0x23, BMS 0x22->0x25 Ninebot Max G30 ESC 0x20->0x20, BMS 0x22->0x22, BMS2 0x23->0x23 Ninebot ESx ESC 0x20->0x20, BLE 0x21->0x21, BMS2 0x23->0x23 Ninebot F-series ESC 0x20->0x20, BMS 0x22->0x22 Mini / Mark3 / VIO different addresses again Its reverse lookup matches an incoming address against *either* id (`zm.smali:1752-1806`), so a reply can be attributed to its board; a decoder that matched on the send id alone would drop every Xiaomi reply, and one that assumed the Xiaomi `+3` offset would mis-attribute every Ninebot reply. ## Changes - `Board::Bms2` for the second pack; `Board::address()` becomes `xiaomi_legacy_address() -> Option`, named for the family it describes and refusing to answer for a board that family does not have. - `BoardAddress { board, send_id, receive_id }` and `ModelProfile::boards`, with `ModelProfile::board_address()`. An empty table means "not established", which is different from "no boards". - `XIAOMI_BOARDS` populated for all six Xiaomi profiles. - Tests: the `+3` pair for every Xiaomi profile, the `Option` contract on `xiaomi_legacy_address`, and an invariant that no profile may poll or decode a board it cannot address. ## Also - **Mi 3** is grouped with the 1S/Pro2/Lite by m365 Tools *structurally* — same board table, same nine-command set, differing only in one per-model scalar. That is addressing evidence, not register-layout evidence, so its field list stays empty deliberately. - `BleManager` now finds a characteristic across **every** discovered service instead of only `getService(AUTH_SERVICE)`. m365 Tools never calls `getService(UUID)` at all, and a device exposing `00000010`/`00000019` under a different service previously failed silently. The hinted service is tried first, so existing devices behave exactly as before. - `ScooterSettingsWriter.statusWordWrite` and `session::commands` document the divergences from m365 Tools instead of guessing: the `0x7D` write byte order (Scootbatt big-endian vs m365 Tools little-endian — genuinely unresolved), and the missing sync word, checksum and write selector in the legacy Rust path that nothing calls. - `doc/reverse-engineering/m365tools-reports/` — nine reports plus an index. `doc/MODEL_SUPPORT.md` gains the per-model addressing table and a fourth data point on the open `0xB0` offset question (m365 Tools agrees with the Java parser; the offsets are left unchanged pending a capture). ## Verification - `cargo test --manifest-path ninebot-ble/Cargo.toml --no-default-features` — 148 pass, exit 0 (the exact CI command). - `./gradlew :app:testDebugUnitTest` — BUILD SUCCESSFUL, 318 tests, 0 failures. - No behavioural change is intended for an existing Xiaomi M365; the addressing values for every Xiaomi profile are the ones the code already used. **Static analysis only.** No scooter and no phone were attached, and only a Xiaomi M365 is available to this project, so every Ninebot row is documentation-grade. --- .gitignore | 4 + .../java/com/m365bleapp/ble/BleManager.kt | 46 +- .../protocol/ScooterSettingsWriter.kt | 17 +- doc/MODEL_SUPPORT.md | 57 ++ doc/reverse-engineering/README.md | 16 + .../01-scan-and-identification.md | 484 ++++++++++++ .../m365tools-reports/02-gatt-selection.md | 467 ++++++++++++ .../03-handshake-and-auth.md | 629 ++++++++++++++++ .../m365tools-reports/04-register-maps.md | 578 ++++++++++++++ .../m365tools-reports/05-write-commands.md | 391 ++++++++++ .../06-connection-state-machine.md | 712 ++++++++++++++++++ .../07-model-component-addressing.md | 149 ++++ .../08-xiaomi-family-uniformity.md | 77 ++ .../m365tools-reports/09-device-table.md | 41 + .../m365tools-reports/README.md | 66 ++ ninebot-ble/src/model/mod.rs | 146 +++- ninebot-ble/src/session/commands.rs | 36 + 17 files changed, 3899 insertions(+), 17 deletions(-) create mode 100644 doc/reverse-engineering/m365tools-reports/01-scan-and-identification.md create mode 100644 doc/reverse-engineering/m365tools-reports/02-gatt-selection.md create mode 100644 doc/reverse-engineering/m365tools-reports/03-handshake-and-auth.md create mode 100644 doc/reverse-engineering/m365tools-reports/04-register-maps.md create mode 100644 doc/reverse-engineering/m365tools-reports/05-write-commands.md create mode 100644 doc/reverse-engineering/m365tools-reports/06-connection-state-machine.md create mode 100644 doc/reverse-engineering/m365tools-reports/07-model-component-addressing.md create mode 100644 doc/reverse-engineering/m365tools-reports/08-xiaomi-family-uniformity.md create mode 100644 doc/reverse-engineering/m365tools-reports/09-device-table.md create mode 100644 doc/reverse-engineering/m365tools-reports/README.md diff --git a/.gitignore b/.gitignore index 2168a3c..b26857a 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,10 @@ !gradle/wrapper/gradle-wrapper.jar *.kotlin_module +# Kotlin incremental-compilation session files, written by the Kotlin Gradle +# plugin on every build under a randomly named file. +/.kotlin/ + # Rust /ninebot-ffi/target/ /ninebot-ble/target/ diff --git a/app/src/main/java/com/m365bleapp/ble/BleManager.kt b/app/src/main/java/com/m365bleapp/ble/BleManager.kt index c405423..54554dd 100644 --- a/app/src/main/java/com/m365bleapp/ble/BleManager.kt +++ b/app/src/main/java/com/m365bleapp/ble/BleManager.kt @@ -379,8 +379,7 @@ class BleManager(private val context: Context) { } } - val service = gatt.getService(serviceUuid) - val char = service?.getCharacteristic(charUuid) + val char = findCharacteristic(gatt, serviceUuid, charUuid) if (char == null) { if (waitForResponse) writeContinuation = null cont.resume(false) @@ -413,6 +412,46 @@ class BleManager(private val context: Context) { } } + /** + * Finds [charUuid] on [hintedService] if it is there, otherwise on **any** + * discovered service. + * + * ## Why this is not just `gatt.getService(hintedService)` + * + * The auth characteristics (`AUTH_UPNP` `00000010-…`, `AUTH_AVDTP` + * `00000019-…`) are addressed here under [AUTH_SERVICE] (`0000fe95-…`). That + * pairing is an assumption about one vendor's GATT layout, and a wrong + * assumption fails *silently*: `getService` returns `null`, the write or the + * subscription resumes `false`, and the caller sees a handshake that simply + * never completes. + * + * The reference implementation — **m365 Tools** (`app.peretti.m365tools`, + * statically analysed) — never looks a service up by UUID at all. It has zero + * calls to `BluetoothGatt.getService(UUID)`; it addresses characteristics by + * UUID and lets the BLE layer enumerate every service to find them + * (`mb0.smali:42-92`). It also never uses `0000fe95-…` as a *service* — it + * only reads that UUID's advertisement service-data during scanning. + * + * So the hinted service is treated as a hint, not a requirement. Trying it + * first keeps today's behaviour for a device that does expose the expected + * layout, and scanning the rest means a device that exposes the same + * characteristic under a different service now works instead of failing + * silently. See `doc/reverse-engineering/m365tools-reports/02-gatt-selection.md`. + * + * @param gatt the connected GATT client, whose services must be discovered. + * @param hintedService the service the characteristic is expected under. + * @param charUuid the characteristic to find. + * @return the characteristic, or `null` when no service exposes it. + */ + private fun findCharacteristic( + gatt: BluetoothGatt, + hintedService: UUID, + charUuid: UUID, + ): BluetoothGattCharacteristic? { + gatt.getService(hintedService)?.getCharacteristic(charUuid)?.let { return it } + return gatt.services.firstNotNullOfOrNull { it.getCharacteristic(charUuid) } + } + @SuppressLint("MissingPermission") @Suppress("UNUSED_PARAMETER") suspend fun enableNotifications(gatt: BluetoothGatt, serviceUuid: UUID, charUuid: UUID, @Suppress("unused") callback: (ByteArray) -> Unit): Boolean = suspendCancellableCoroutine { cont -> @@ -421,8 +460,7 @@ class BleManager(private val context: Context) { return@suspendCancellableCoroutine } - val service = gatt.getService(serviceUuid) - val char = service?.getCharacteristic(charUuid) + val char = findCharacteristic(gatt, serviceUuid, charUuid) if (char == null) { cont.resume(false) return@suspendCancellableCoroutine diff --git a/app/src/main/java/com/m365bleapp/protocol/ScooterSettingsWriter.kt b/app/src/main/java/com/m365bleapp/protocol/ScooterSettingsWriter.kt index 2534f3c..263160a 100644 --- a/app/src/main/java/com/m365bleapp/protocol/ScooterSettingsWriter.kt +++ b/app/src/main/java/com/m365bleapp/protocol/ScooterSettingsWriter.kt @@ -128,9 +128,24 @@ object ScooterSettingsWriter { * Encodes a status word **big-endian**. * * This is the trap: [EscTelemetryParser.statusBits] reads the same register - * little-endian, and the reference app does exactly this asymmetry. Sending + * little-endian, and this builder deliberately answers big-endian. Sending * little-endian here would set the wrong bit — with two settings sharing the * word, that means silently toggling the other one. + * + * ⚠️ **Two reference implementations disagree here, and it is unresolved.** + * + * | Source | `0x7D` **write** byte order | + * |---|---| + * | Scootbatt 1.9.2 — what this file follows (`doc/reverse-engineering/scootbatt-reports/README.md` §5) | **big-endian** | + * | m365 Tools 1.8.0 — `BaseCommand.smali`, `(IIIISZ)V`: `ByteBuffer.allocate(2).order(LITTLE_ENDIAN).putShort(...)` | **little-endian** | + * + * This file matches Scootbatt, so the asymmetry is deliberate rather than a + * mistake — but it is not *settled*. Both apps read `0x7D` little-endian and + * differ only on the write, and picking wrong flips the neighbouring bit + * silently instead of failing. Resolve against hardware — write a known word, + * read `0x7D` back, and check which order reproduces it — then replace this + * table with the answer. See + * `doc/reverse-engineering/m365tools-reports/05-write-commands.md`. */ fun statusWordWrite(word: Int): Write { val masked = word and 0xFFFF diff --git a/doc/MODEL_SUPPORT.md b/doc/MODEL_SUPPORT.md index 77ce9b6..33a9a7b 100644 --- a/doc/MODEL_SUPPORT.md +++ b/doc/MODEL_SUPPORT.md @@ -130,6 +130,42 @@ lists ESx BLE109/110 and Max BLE110/113/114 as crypto-capable. > ⚠️ **Unverified**: whether a given ESx/Max unit on the shelf today uses > NinebotCrypto or plaintext. **Confirm per unit before shipping.** +### Board addressing is per-model, not per-board + +Read from a second independent implementation — the closed-source **m365 Tools** +(`app.peretti.m365tools`, 1.8.0), statically analysed; see +[`doc/reverse-engineering/m365tools-reports/07-model-component-addressing.md`](reverse-engineering/m365tools-reports/07-model-component-addressing.md). + +Its device table stores, for every model, a list of boards each carrying **two** +addresses, which its own `toString()` labels `mID` and `mReceiveID` — the id a +request is sent to, and the id the reply arrives *from*. They are not always equal: + +| Family | Boards (`send_id` → `receive_id`) | +| --- | --- | +| Xiaomi M365 / Pro / Pro2 / 1S / 1S-DE / Lite / **Mi 3** | ESC `0x20`→`0x23`, BLE `0x21`→`0x24`, BMS `0x22`→`0x25` | +| Ninebot Max G30 | ESC `0x20`→`0x20`, BLE `0x21`→`0x21`, BMS `0x22`→`0x22`, BMS2 `0x23`→`0x23` | +| Ninebot ESx | ESC `0x20`→`0x20`, BLE `0x21`→`0x21`, BMS2 `0x23`→`0x23` | +| Ninebot F-series | ESC `0x20`→`0x20`, BLE `0x21`→`0x21`, BMS `0x22`→`0x22` | +| Ninebot Mini / Nano / Mark2 / Mark3 / VIO | *different addresses again — e.g. Mini ESC `0x0A`→`0x0D`, Mark3 ESC `0x14`→`0x14`* | + +Two consequences for this repository: + +1. **The Xiaomi `+3` convention is a Xiaomi fact, not a protocol fact.** Every + Ninebot family answers on the id it was addressed on, and several sit at + different addresses altogether. `Board::xiaomi_legacy_address()` is therefore + named for the family it describes and returns `Option`, and + `ModelProfile::board_address()` is the per-model accessor. Nothing may read a + bare constant and call it "the ESC address". +2. **`0x3E` is the master/broadcast address.** The reference implementation + synthesises a board record for it on demand rather than storing one. This + repository already agrees from two unrelated directions: `FrameCodec`'s + `DEFAULT_SOURCE` and `PlaintextRegisterSession.DEFAULT_SOURCE` are both `0x3E`. + That agreement is the strongest external corroboration in this whole pass. + +⚠️ **Unverified**: the table above is read from another app's data, not from a +scooter. Only the M365 row can ever be confirmed with the hardware on hand. The +`+3` offset in particular has not been observed on a wire here. + --- ## 3. Against the originally requested targets @@ -347,3 +383,24 @@ for the odometer. Every source agrees, and they match the documented units. see no change; - the remaining question is recorded and asserted by tests rather than papered over, so it cannot be quietly forgotten or "fixed" without evidence. + +### 8.6 A fourth source agrees with the Java parser + +`app.peretti.m365tools` (m365 Tools 1.8.0), statically analysed — see +`doc/reverse-engineering/m365tools-reports/04-register-maps.md`. It keeps one +2-byte slot per register address at `buffer[register × 2]` and reads the same +block as individual registers, which lands battery / speed / average speed / +odometer / temperature at payload bytes **8 / 10 / 12 / 14 / 22 of a `0xB0` +read** — i.e. registers `0xB4` / `0xB5` / `0xB6` / `0xB7–0xB8` / `0xBB`. + +That is the **Java** `MotorInfoParser` layout, not `ninebot-ble`'s, whose +`FieldSpec` offsets for the same fields are `4 / 6 / 8 / 10 / 18` — exactly four +bytes (two registers) lower. The tally is now two implementations for the Java +offsets and one for the Rust ones. + +⚠️ This still does **not** settle it, and nothing was changed here on the strength +of it: m365 Tools is a different app reading the block as separate register +reads, and no byte has been observed on a wire. What it does mean is that the +question now has a fourth independent data point, and that "the documentation +says otherwise" is the weakest of the remaining arguments. §8.3 stands: do not +move these offsets without a capture. diff --git a/doc/reverse-engineering/README.md b/doc/reverse-engineering/README.md index 68637e2..162114f 100644 --- a/doc/reverse-engineering/README.md +++ b/doc/reverse-engineering/README.md @@ -12,6 +12,22 @@ smali cross-check). Documents the Ninebot/M365 ESC BLE protocol that the > **All conclusions are static analysis — none verified against a real > scooter.** See each report's "unverified" section and `doc/IMPROVEMENT_PLAN.md`. +## `m365tools-reports/` +Static-analysis reports on `app.peretti.m365tools` **m365 Tools 1.8.0**, a second +reference implementation covering 29 scooter models. Its string encryption was +broken first, then the connection logic: pre-connect identification from +advertisement data, service-agnostic characteristic lookup, per-model board +addressing, four protocol families, the frame envelope and its checksum, the +write inventory, and the connection lifecycle. Start at `README.md`. + +This is the source of the per-model addressing in `ninebot-ble/src/model/mod.rs` +(`BoardAddress`, `XIAOMI_BOARDS`) and of the open `0x7D` byte-order question +recorded in `ScooterSettingsWriter`. + +> **Static analysis only.** No scooter and no phone were attached, and only a +> Xiaomi M365 is available to this project — so every Ninebot row is +> documentation-grade. + ## `scooterhacking-wiki/` Raw ScooterHacking wiki pages (error codes, BMS guides, model notes) used as source citations for the reports and for `ScooterErrorCodes.kt`. diff --git a/doc/reverse-engineering/m365tools-reports/01-scan-and-identification.md b/doc/reverse-engineering/m365tools-reports/01-scan-and-identification.md new file mode 100644 index 0000000..a299560 --- /dev/null +++ b/doc/reverse-engineering/m365tools-reports/01-scan-and-identification.md @@ -0,0 +1,484 @@ +# AREA 1 — BLE scanning and pre-connection model identification + +**Target:** `app.peretti.m365tools` v1.8.0 (versionCode 180), sha256 +`4451a47c86339b8f88d77bdad7aef77e1bc4f11b7c728a5803f2b1d342a16872`. +**Method:** static only. Smali under `apktool-out/smali/` is the citation of record; +`jadx-out-nodeobf/` was used for readability only. Decrypted strings came from +`tools/m365tools_strings.py` (the `--uses` line numbers it prints are the +`invoke-static vl0->OO00000OOOOOOOO0000O(J)` lines; the `const-wide` line holding the +key is normally 2 lines earlier — both are given where it matters). + +**Citation convention.** Citations are `ClassName.smali:line` relative to +`apktool-out/`, i.e. `apktool-out/smali//.smali`. Resolving the ones used most: + +* `smali/dalvik/O/O/a/b/O/c/b/O/o/` — `op` `hc` `ic` `qo` `iq` `gn` `b11` `eq` `ri` `ti` `zm` `nc` `gq` +* `smali_classes2/dalvik/O/O/a/b/O/c/b/O/o/` — `nj0` `kj0` `lb0` `lj0` `mj0` `zf0` `ih0` +* `smali/app/peretti/m365tools/` — `MainActivity` `ScooterInformationActivity` + `ScooterService` `App` `App$OO00000OOOOOOOO0000O` `ScooterInformationActivity$O00OOO0O00O00OOO0000` + `adapter/ScanResultsAdapter` `model/BaseCommand$OO0O0OOO00O00OOO0000` + `fragments/ScooterBasicInformationFragment` +* `smali/dalvik/O/O/a/b/O/c/b/O/o/tn$OO00000OOOOOOOO0000O.smali` — the scan collector + +Decrypted-string keys are quoted as the `const-wide` literal (e.g. `-0x2c8976b3f29eL`) +so they can be re-checked with `tools/m365tools_strings.py --uses`. + +--- + +## 1. Corrections to the starting brief and to CONTEXT.md + +Three starting facts were wrong or imprecise. They are corrected here because every one of +them changes the conclusion. + +| # | Starting claim | What the code actually does | Evidence | +|---|---|---|---| +| 1 | "`tn.smali` … keeps a scan result only when the advertisement carries service data for `0000fe95-…`" | `tn.smali` is a Kotlin coroutine continuation with no filtering at all. The `fe95` UUID is **read out of** the scan record (a `getServiceData()` map lookup), never used as a `ScanFilter`. The scan-result gate is: **advertised name non-null** (`tn$…:150`), and list membership additionally requires **manufacturer-specific data for company id 0x424E to be present** (`hc.smali:151-198`, `hc.smali:341-361`). | `tn$OO00000OOOOOOOO0000O.smali:119-152` | +| 2 | "The app does not use Nordic UART / no `6e4000xx` UUID appears anywhere" (CONTEXT.md:88) | The NUS UUIDs **are present** but string-encrypted: `6e400001-…` at `ScooterInformationActivity.smali:159`, `6e400002-…` at `:258`, `6e400003-…` at `:263`, and `6e400002/0003` again in `op.smali:140,149`. `00001800-…` / `00002a00-…` are at `ScooterInformationActivity.smali:268,273`. None of them is used for **scanning**; they belong to the post-connect GATT layer. | `--uses '6e4000\|fe95\|0000-1000-8000-00805f9b34fb'` | +| 3 | "`NinebotGetDeviceName` is the advertised-name parser" | `NinebotGetDeviceName` is the label of a **`BaseCommand` subclass sent after connecting** (`BaseCommand$OO0O0OOO00O00OOO0000.smali:19-32` builds command `(0x3e, 0x21, …)`); the string itself is only used inside that command's exception message (`:79`). It is not an advertisement string and does not contain a name pattern. | `BaseCommand$OO0O0OOO00O00OOO0000.smali:19-32,79` | + +Additional correction: the `fe95` UUID literal is **plaintext** in the DEX +(`tn$OO00000OOOOOOOO0000O.smali:125`), which is why `--uses 'fe95'` finds nothing — +it is not in the encrypted table. + +--- + +## 2. Exact scan configuration + +There are exactly **three** call sites of `RxBleClient.scanBleDevices(ScanSettings, ScanFilter…)` +(`eb0` = `com.polidea.rxandroidble2.RxBleClient`, `eb0.smali:64`): + +| # | Site | ScanSettings | ScanFilters | Purpose | +|---|---|---|---|---| +| 1 | `MainActivity.smali:613-739` (`O0OOOO0000O0OO0O00O0`) | `nj0(2, 1, 0L, 1, 3, true)` (`:649-661`) | **one empty filter** (`:665-696`) | the user-visible device list | +| 2 | `ScooterInformationActivity.smali:1289-1376` (inside `O0OO000O0000O0O00O00(Z)`) | same `nj0(2, 1, 0L, 1, 3, true)` (`:1304-1316`) | **zero filters** — `new-array v3, v3, [Lkj0;` with `const/4 v3, 0x0` (`:1318`) | "wait for my scooter to appear / become alive" | +| 3 | `qo.smali:97-173` (retry after GATT status 133) | same `nj0(2, 1, 0L, 1, 3, true)` (`:97-109`) | **one filter on device address only** — `deviceAddress = op.OO0O00O0OO00OOO00O00` (`:132`, validated at `:137`), every other field null, `manufacturerId = -1` (`:129`) | reconnect to a known MAC | + +### 2.1 ScanSettings — what the six constructor arguments mean + +`nj0` is RxAndroidBle's `ScanSettings`; `zf0.smali:288-339` is the class that forwards the +fields to `android.bluetooth.le.ScanSettings.Builder`, which fixes every mapping: + +| `nj0` field | Set to | Forwarded as | Means | Confidence | +|---|---|---|---|---| +| `OO00OOOOOO000O0OO00O` (`:34`) | `2` | `setScanMode` (`zf0.smali:331-334`) | `SCAN_MODE_LOW_LATENCY` (0/1 are special-cased as 500/2500 ms in `ih0.smali:54-89`, `-1` is opportunistic) | read | +| `OOOOO00O0O0OO00OOO0O` (`:36`) | `1` | `setCallbackType` (`zf0.smali:300-303`) | `CALLBACK_TYPE_ALL_MATCHES` | read | +| `OO0O00O0OO00OOO00O00` (`:36`) | `0L` | `setReportDelay` (`zf0.smali:323-326`) | 0 ms — results delivered immediately, not batched | read | +| `OOOOO0000OO0O00000O0` (`:42`) | `1` | `setMatchMode` (`zf0.smali:308-311`) | `MATCH_MODE_AGGRESSIVE` | read | +| `OOOO00O0OOO0OOOOO000` (`:38`) | `3` | `setNumOfMatches` (`zf0.smali:316-319`) | `MATCH_NUM_MAX_ADVERTISEMENT` | read | +| `OOOO00OOO0OO000OOO0O` (`:40`) | `true` | **not forwarded** — no `setLegacy` call exists anywhere in the DEX | a "legacy" flag carried by the settings object but never applied to the framework builder | read (absence) | + +Setting/field order is taken from `nj0.smali:61-83` (constructor) and `:34-44` (field +declarations); the framework mapping from `zf0.smali:288-339`. + +### 2.2 ScanFilters — all three are effectively "match everything" + +`kj0` is RxAndroidBle's `ScanFilter`. Constructor order (`kj0.smali:79`, field map at the +`iput`s in `:85-113`, validated by the `Parcelable` reader's +`checkBluetoothAddress` / `"serviceDataUuid is null"` / `"invalid manufacture id"` checks in +`jadx-out-nodeobf/…/kj0.java`): + +`(deviceName, deviceAddress, serviceUuid, serviceUuidMask, serviceDataUuid, serviceData,` +`serviceDataMask, manufacturerId, manufacturerData, manufacturerDataMask)` + +| Field | Site 1 (`MainActivity.smali:665-692`) | Site 2 (`ScooterInformationActivity.smali:1318`) | Site 3 (`qo.smali:111-166`) | +|---|---|---|---| +| deviceName | `null` | (no filter object) | `null` | +| deviceAddress | `null` | | `op.OO0O00O0OO00OOO00O00` | +| serviceUuid / mask | `null` / `null` | | `null` / `null` | +| serviceDataUuid / data / mask | `null` / `null` / `null` | | `null` / `null` / `null` | +| **manufacturerId** | **`-1`** (unset) | | **`-1`** (unset) | +| manufacturerData / mask | `null` / `null` | | `null` / `null` | + +Site 1 is byte-for-byte the library's own "match anything" constant +`kj0.O0000O00OO00OOOO0000 = new ScanFilter.Builder().build()` (`kj0.smali:29,67`). +Consequently: + +* **No service UUID is used as a scan filter.** `0000fe95-…` is only used to index + `ScanRecord.getServiceData()` (`tn$…:119-137`). +* **Company id 16974 (0x424E) is not a scan filter.** It is used as a `get()` key into + `getManufacturerSpecificData()` (`tn$…:105-113`, `hc.smali:127-135`, `ic.smali:168-262`). +* The framework stack does the "filtering" only at site 3 (address match); sites 1–2 are + filtered entirely in app code. RxAndroidBle's own note for this path is + `"No library side filtering —> debug logs of scanned devices disabled"` (`zf0.smali:154`). + +### 2.3 Which advertisement fields are read at all + +`lj0` = `com.polidea.rxandroidble2.scan.ScanRecord`, `lb0` = `RxBleDevice`, +`mj0` = `ScanResult` (field/type map: `lb0.smali:7-38`, `lj0.smali:7-48`, `mj0.smali:7-19`). + +| Advertisement field | Accessor | Where read | Used for | +|---|---|---|---| +| device name | `lj0->O00OOO0O00O00OOO0000()` / `lb0->getName()` | `tn$…:90`, `hc.smali:88,310`, `ic.smali:322-336`, `ScanResultsAdapter.smali:196-206` | display, non-null/non-empty gate, one model heuristic (§4.4), persisted as `SCOOTER_LAST_NAME` | +| MAC address | `lb0->O00OOO0O00O00OOO0000()` | `tn$…:82`, `hc.smali:64,115` | list identity, `AUTO_CONNECT` pref key, persisted as `SCOOTER_LAST_MAC` | +| RSSI | `mj0->OOOOOOO0OOOOO0O00OO0` | `tn$…:95` | display/sort | +| manufacturer data (all ids) | `lj0->O0OOOOOO00OOOOO000()` (`SparseArray`) | `tn$…:101-113`, `hc.smali:98-135` | 0x424E presence = device-eligibility gate; value stored per MAC | +| company **0x424E** payload | `SparseArray.get(0x424e)` | `tn$…:105`, `hc.smali:127`, `ic.smali:168-262` | bytes 0 and 1 → scooter type + protocol version (§5) | +| service data for **`0000fe95-…`** | `getServiceData().get(ParcelUuid.fromString("0000fe95-0000-1000-8000-00805f9b34fb"))` | `tn$…:119-137` | byte 4 = liveness counter (§6) | +| raw AD bytes | `lj0->OO00000OOOOOOOO0000O()` | `ic.smali:145-161` | **discarded** — `Arrays.copyOfRange(getBytes(), 0x11, len)` is computed and thrown away; no other reader | +| service UUID list | `lj0->OO00O0O0O0000000000O()` | none | never read | + +--- + +## 3. The scan-result pipeline (what reaches the UI) + +`MainActivity.O0OOOO0000O0OO0O00O0()` (`MainActivity.smali:613-786`): + +1. `scanBleDevices(settings, filter)` (`:699`), `subscribeOn(computation)` (`:704-708`), + `…subscribe(onNext = hc, onError = lc, onComplete = empty, onSubscribe = errorMissing)` + (`:721-734`); the `Disposable` is stored in `MainActivity.OOOO00OOO00O000O0OOO` (`:739`). + An Action (`oc`, `oc.smali:25-45`) stops the scan when the observable terminates. +2. `hc` (the `onNext` consumer, `hc.smali:26-390`) maintains + `ScanResultsAdapter.OOOO00O0OOO0OOOOO000` (a `List`) and a + `HashMap` of 0x424E payloads (`ScanResultsAdapter.smali:42-50`): + * name `null` → **drop** (`hc.smali:85-92`); + * `getManufacturerSpecificData().size() > 0` → `map[mac] = data[0x424E]` (may be `null`) + (`hc.smali:95-135`); + * auto-connect branch — fires only when **all four** hold: (a) a one-shot latch is still + clear (`hc.smali:139-141`, latch set at `:198`), (b) this MAC is already present in the + displayed list (`:143-149`), (c) the stored 0x424E array for this MAC is non-null + (`:151-191`), (d) `prefs.getBoolean("AUTO_CONNECT"+mac, false)` was true (`:61-82`, + tested at `:193`; the key is `"AUTO_CONNECT" + mac`, built by `gq.smali:582-597`). It + then schedules a **1000 ms** delayed task (`hc.smali:200-238`, + `0x3e8` at `:210`; the task is `jh.smali:33-50`, which invokes the click callback + `ic.…`) — i.e. a debounce before acting on a device; + * otherwise the row is refreshed in place (`hc.smali:240-303`); + * new row is added only if the name is non-null **and not `""`** (`hc.smali:305-324`) and + `map.get(mac)` is non-null (`hc.smali:327-361`; the `array-length >= 0` test at `:359-361` + is always true, a vestigial check). +3. The same scan feeds `MainViewModel` through `ri` (a `MutableStateFlow<…>`, + `ri.smali:7-16,93-118`), collected in `tn$…` into + `MainViewModel.O000000O000000000OO0` (`tn$…:76-238`); `ri`'s scan is started (inlined) + at `ScooterInformationActivity.smali:1321-1376` and its `Disposable` stored at `:1376`. + That flow is what the connect path observes (`ScooterInformationActivity$O00OOO0O00O00OOO0000.smali:79-285`). +4. Scan failures: `lc.smali:26-…` maps `BleScanException` error codes to a localized string + (`:62-73`, `packed-switch`) and to decrypted messages; nothing is thrown. + +--- + +## 4. Pre-connection identification rules + +### 4.1 Eligibility gate (the only hard pre-connect filter) + +A device is only listed/actionable when its advertisement contains **manufacturer-specific +data with company identifier `0x424E` (= 16974 decimal; the two little-endian bytes are +`4E 42` = ASCII `NB`)**. If the 0x424E entry is absent, the value stored in the map is +`null` and both the display path (`hc.smali:341-361`) and the click path +(`ic.smali:184-210`) bail out. Every other manufacturer id is ignored. + +### 4.2 Scooter type + protocol version come from the 0x424E payload + +The click handler for a scan row is `ic.OO00000OOOOOOOO0000O(ScanResult)` +(`ic.smali:26-398`): + +* `mfr = map[mac]`; `mfr[0]` → `v2` (`ic.smali:235`), `mfr[1]` → `v1` (`ic.smali:262`); +* two developer `SeekBar`s override them when not left at their sentinels + `seekBar_tx_manufacturerId != 0x65` (`:271-293`) and `seekBar_protocolVersion != 5` + (`:295-303`); +* name and MAC are stored globally (`:322-350` → `App.O00OOO0O00O00OOO0000` = *setName*, + `App.OO00O0O0O0000000000O` = *setMac*; `App$OO00000OOOOOOOO0000O.smali:29-70,379-425`); +* **`App.setProtocol(v1)`** (`ic.smali:381-383` → `App$…:454-503`, stores + `App.O0000O00OO00OOOO0000`) and **`App.setType(v2)`** (`ic.smali:384-386` → `App$…:77-294`, + stores `App.OO00O0O0O0000OO00OO0` after a model lookup); +* then it starts `ScooterInformationActivity` (`:388-395`) — **the connection has not been made yet**. + +`App.setType` normalises the byte with `and-int/lit16 … 0xff` (`App$…:87-92`) and then scans +`zm.values()` for the enum whose `OO00OOOOOO000O0OO00O` equals it (`App$…:100-140`), falling +back to `zm.UNKNOWN` (`App$…:139`, `zm.smali:445`). + +### 4.3 Persisted values are the second identification source + +`nc.smali` is the "reconnect to last scooter" click handler (`MainActivity.smali:2160-2169` +installs it on `button_offline_mode`, fed from `SCOOTER_LAST_MAC` / `SCOOTER_LAST_NAME` read +at `MainActivity.smali:2116-2140`). It restores: + +* `SCOOTER_LAST_SCOOTER_TYPE`, default `0` (`nc.smali:86-121`, key literal `:89`) → `App.setType`; +* `SCOOTER_LAST_SCOOTER_PROTOCOL`, default `0` (`nc.smali:122-153`, key literal `:123`) → `App.setProtocol`. + +These two keys are written **only** in `ScooterInformationActivity.onCreate` +(`ScooterInformationActivity.smali:3020-3066`: `SCOOTER_LAST_NAME` `:3020-3031`, +`LAST_SCOOTER_SCOOTER_TYPE` `:3034-3049`, `LAST_SCOOTER_SCOOTER_PROTOCOL` `:3051-3066`, +`SCOOTER_LAST_MAC` `:3004-3012`) from the current `App` globals — i.e. from the advertisement +bytes or the user override, never from a device response. + +### 4.4 The advertised name: displayed verbatim, and one heuristic + +* Display: `ScanResultsAdapter.smali:196-206` shows `scanRecord.getDeviceName()`; the adapter + shows **no model name**. +* The **only** name-based model rule in the app (`App$…:143-201`): if the type code passed in + is `0`, then `name.startsWith(" ")` **or** `name.startsWith("N3M")` + (strings at keys `-0x42b176b3f29e` / `-0x42b376b3f29e`; helper `b11.OOOOO00O0O0OO00OOO0O` + is Kotlin `startsWith(prefix, ignoreCase)`, `b11.smali` — its `requireNonNull` message is + `"prefix"` and it calls `String.startsWith` then a region-match helper) sets the model to + **`zm.O00O00O00O000OO0O0OO` = `MINI`, type code 3** (`zm.smali:520-535`) and logs + `"ScooterType: Mini detected%s"`. + This is the **complete** list of name patterns: `" "` and `"N3M"`. There is no + `"MIScooter"`, `"Ninebot"`, `"Segway"` or `"Xiaomi"` name check anywhere in the app + (`--uses 'Ninebot|Segway|Xiaomi'` matches only `NinebotGetDeviceName`). +* Names are also used for the per-device auto-connect flag key `AUTO_CONNECT + mac` + (`gq.smali:582-597`). + +### 4.5 From "device seen" to "connect": the liveness gate + +`ScooterInformationActivity.O0OO000O0000O0O00O00(Z)` (`:1186-…`) registers an observer on the +scan flow (`ScooterInformationActivity$O00OOO0O00O00OOO0000.smali`, observer added at +`:33-41`). For every result it: + +1. keeps only `ti.macAddress == op.OO0O00O0OO00OOO00O00` (`:117-130`) — the MAC that + `ScooterService.smali:3278` stored as the current target; +2. reads **`getOrNull(serviceDataForFe95, 4)`** of the *current* and of the *previous* result + (`:158-201`; index `4` at `:160`; `eq.OO0OOOOOOOO00000O0O0([BI)Ljava/lang/Byte;` is Kotlin + `ByteArray.getOrNull`, returning `null` out of range — `eq.smali`); +3. if the two byte values are **equal** (including both-`null`), it does nothing + (`:218-226`); if the "already triggered" flag is set, it does nothing (`:229-231`); +4. when they **differ**, it removes the observer, disposes the scan + (`:242-257`, `ri.OO00000OOOOOOOO0000O()` = "disposeScan", `ri.smali:139-165`), logs + `'Connecting'` (key `-0x94f576b3f29e`, `:259-262`) and calls `op.OO00O0O0O0000000000O()` + (`:276`) — the connect state machine. + +So the pre-connect sequence is: *present in the scan list (0x424E present) → type/protocol +taken from the 0x424E payload or the stored prefs → the fe95 service-data byte 4 must change +between two advertisements of the target MAC → stop scanning → connect.* + +--- + +## 5. Byte layout — manufacturer-specific data, company id 16974 (0x424E) + +**Base of offsets:** the Java `byte[]` returned by `SparseArray.get(0x424E)`, which is the +manufacturer-specific payload **after** the 2-byte company identifier. All offsets are +**byte indices into that array**, not AD-structure offsets and not the raw +`ScanRecord.getBytes()` offsets. + +| Offset (bytes) | Size | Meaning as used | Units | Confidence | Evidence | +|---|---|---|---|---|---| +| 0 | 1 | **Scooter type code.** Passed to `App.setType(int)`, normalised `& 0xFF`, compared against `zm` enum type codes; no match ⇒ `zm.UNKNOWN`. Also the initial value of the hidden "manufacturer id" SeekBar (sentinel 101 = "unset"). | byte index, unsigned after `and 0xFF` | **read** (that the app consumes it as a type code) / **inferred** (that Ninebot's on-air field is a "type"/model code rather than a serial fragment) | `ic.smali:235`, `ic.smali:271-293`, `App$…:87-140`, `App$…:199` | +| 1 | 1 | **Protocol version.** Passed to `App.setProtocol(int)`; stored in `App.O0000O00OO00OOOO0000`; read by the command layer (`iq.smali:499`, `iq.smali:1278`) and compared to `1` in `iq.O000OO0OO0OOO00OO000` (`iq.smali:489-545`). Hidden SeekBar default is 5. | byte index, small integer | **read** (that the app consumes it as the protocol version) / **inferred** (the name — the app only labels it `"protocol_version"` at `App$…:471`) | `ic.smali:262`, `ic.smali:295-303`, `ic.smali:381-383`, `App$…:454-503` | +| 2 … n-1 | n-2 | **never read.** No other `aget-byte` or slice of this array exists: the only readers of `ti.OOOO00O0OOO0OOOOO000` are the data class's own `equals`/`hashCode`/`toString` (`ti.smali:209,263`) and the merge logic, which only tests `null`/`array-length` (`tn$…:181-232`, `hc.smali:189`, `ic.smali:208`). | byte index | read (absence) | grep of `ti;->OOOO00O0OOO0OOOOO000` and of the adapter `HashMap` | + +**Not established:** the byte length of the payload, any framing/length field, any MAC or +serial bytes, and whether byte 0/1 are stable per model. The app never validates the length +(no bound check before `aget-byte`), so a 1-byte payload would in principle satisfy the +"0x424E present" gate but would throw on the click path — the click path is not wrapped in +try/catch (`ic.smali:26-398`; the scan loop that calls it is, `hc.smali:51,374-386`). + +### 5.1 The `zm` model table (type codes) — the destination of that byte + +`zm` is a 29-constant enum (`zm.smali:44-104`); each constant is built with +`(name, ordinal, , , , …)` and the field compared against the +advertised type byte is the **second int** (`p5`, assigned at `zm.smali:1697`). Names are +decrypted constants (e.g. `zm.smali:520-535` for `MINI`); codes for the constants not folded +into an anonymous subclass are literal in `zm.smali`'s ``. + +| # | Constant | Type code (byte 0 value) | Confidence | +|---|---|---|---| +| 0 | `UNKNOWN` | 0 | read | +| 1 | `ONE` | 2 | read | +| 2 | `MINI` | 3 | read | +| 3 | `MINI_MAX` | 24 | read | +| 4 | `MARK2` | 19 | read | +| 5 | `ESCOOTER` | 32 | read | +| 6 | `VIO` | 20 | read | +| 7 | `KART` | 48 | read | +| 8 | `KART_PRO` | 49 | read | +| 9 | `KART_PRO_LAMBO` | 50 | read | +| 10 | `NANO` | 22 | read | +| 11 | `MINI_KIDS_MI` | 23 | read | +| 12 | `MINI_KIDS` | 25 | read | +| 13 | `MARK3` | 18 | read | +| 14 | `SCOOTER2` | 33 | read | +| 15 | `SCOOTER2_P` | 39 | read | +| 16 | `MI_SCOOTER_PRO` | 34 | read | +| 17 | `MI_SCOOTER_1S_DE` | 37 | read | +| 18 | `MI_SCOOTER_LITE` | 41 | read | +| 19 | `MI_SCOOTER_1S` | 43 | read | +| 20 | `MI_SCOOTER_PRO2` | 40 | read | +| 21 | `MI_SCOOTER_3` | 46 | read | +| 22 | `WILD_STEEL_DUST` | 74 | read | +| 23 | `SCOOTER_G30` | 36 | read | +| 24 | `STEELDUST` | 64 | read | +| 25 | `MEBIKE` | 65 | read | +| 26 | `SCOOTER_AIR` | 35 | read | +| 27 | `F_Series30` | 44 | read | +| 28 | `F_Series60` | 45 | read | + +Cross-validation for seven of these: `zm.OO0OOOOOOOO00000O0O0()` (`zm.smali:1944-2037`) +returns true only when `String.valueOf(typeCode)` is in `{"32","34","37","40","41","43","46"}` +— exactly ESCOOTER, MI_SCOOTER_PRO, MI_SCOOTER_1S_DE, MI_SCOOTER_PRO2, MI_SCOOTER_LITE, +MI_SCOOTER_1S, MI_SCOOTER_3. That independent list confirms both the extraction method and +the semantics of the field. `ScooterInformationActivity.smali:1195-1211` gates the special +connect dialog on `protocolVersion == 2 && model.OO0OOOOOOOO00000O0O0()`. + +The `zm` constants also carry a per-model name/alias list (`OOOOO00O0O0OO00OOO0O`) and +register-map lists (`qn`/`pn`/`wm`), e.g. `'N4GEA1601C0001'` for `UNKNOWN` +(`zm.smali` ``, decrypted key `-152292941886110`); **no code outside `zm` reads the +alias list** (grep of `zm;->OOOOO00O0O0OO00OOO0O`), so the app does not match those strings +against anything in this version. Register-map semantics belong to the model area, not here. + +--- + +## 6. Byte layout — `0000fe95-…` service data + +**Base of offsets:** the `byte[]` returned by `ScanRecord.getServiceData().get(ParcelUuid)` +for `0000fe95-0000-1000-8000-00805f9b34fb` — i.e. the service-data payload **after** the +16-bit UUID in its AD structure. Offsets are byte indices into that array. + +| Offset (bytes) | Size | Meaning as used | Units | Confidence | Evidence | +|---|---|---|---|---|---| +| 4 | 1 | **Liveness / rolling counter.** Two consecutive advertisements from the target MAC must have *different* values here, else the app keeps scanning. This is the trigger that ends the pre-connect scan. | byte index | **read** (the app's requirement) / **inferred** (the interpretation "counter") | `ScooterInformationActivity$O00OOO0O00O00OOO0000.smali:158-262` (index `4` at `:160`, comparison at `:218-231`, trigger at `:242-276`) | +| 0–3, 5…n-1 | — | **never read.** The only byte-array `getOrNull` call sites in the whole DEX are the two above (`:165`, `:192`). | byte index | read (absence) | grep `eq;->OO0OOOOOOOO00000O0O0([BI)` | + +For orientation only (our prior knowledge of the MiBeacon/Xiaomi family layout, **not** +established by this app): bytes 0–1 frame control, 2–3 product id, 4 frame counter, 5–10 MAC. +The app's use of byte 4 as a changing value is consistent with that, but the app itself +proves only "byte 4 changes". Nothing in the app maps bytes 2–3 (the product id) to a model — +that would have been the natural in-advertisement model discriminator and it is unused. + +--- + +## 7. Pre-connect vs post-connect + +**Everything used to choose behaviour is decided before or at the moment of connection:** + +| Decision | Source | When | Evidence | +|---|---|---|---| +| Is this device a candidate at all? | presence of manufacturer data for 0x424E | during scan | `hc.smali:341-361`, `ic.smali:184-210` | +| Scooter type (model) | `mfr[0]` (or stored `SCOOTER_LAST_SCOOTER_TYPE`, or the hidden SeekBar) | at row click, before `startActivity` | `ic.smali:235,271-293,384-386`; `nc.smali:86-121` | +| Protocol version | `mfr[1]` (or stored `LAST_SCOOTER_SCOOTER_PROTOCOL`, or the hidden SeekBar) | at row click, before `startActivity` | `ic.smali:262,295-303,381-383`; `nc.smali:122-153` | +| Model fallback from name | `startsWith(" ") \|\| startsWith("N3M")` → `MINI` | inside `App.setType`, only when the type code is 0 | `App$…:143-201` | +| Go/no-go on connecting | fe95 service-data byte 4 changed between two ads of the target MAC | immediately before connecting | `ScooterInformationActivity$…:158-276` | + +The writer census proves the direction of travel: `App.O0000O00OO00OOOO0000` (protocol) has +exactly one assignment site (`App$…:500`) reached only from `ic.smali:382` and `nc.smali:153`; +`App.OO00O0O0O0000OO00OO0` (type) likewise (`App$…:92,199` ← `ic.smali:385`, `nc.smali:121`). +Consumers read them from the command/handshake layer (`iq.smali:470,499,1275,1278`; +`ScooterBasicInformationFragment.smali:6125-6609`; `ScooterBasicInformationFragment.smali:6534,6592`). +**No code writes them from a device response**, so there is no post-connect re-identification +in this version: the app commits to a model/protocol dial from the advertisement (or the +user) and never revises it in code. + +What *does* happen after connecting, in the same activity, is the usual command traffic +(`op`'s state machine, `ScooterService`, `gn`'s auth sequence — which is where +`NinebotGetDeviceName` is sent, `gn.smali:1926-1929`) and, on teardown, persisting +`SCOOTER_LAST_*` (`ScooterInformationActivity.smali:3004-3066`). + +Relevant preference keys and their exact roles: + +| Key | Written | Read | Meaning | +|---|---|---|---| +| `SCOOTER_LAST_MAC` | `ScooterInformationActivity.smali:3004-3012` | `gq.smali:509-521` (getter), `MainActivity.smali:2116-2121` | last device address, used to prefill the "reconnect" button | +| `SCOOTER_LAST_NAME` | `ScooterInformationActivity.smali:3020-3031` | `MainActivity.smali:2127-2140` | last advertised name (display only) | +| `LAST_SCOOTER_SCOOTER_TYPE` | `ScooterInformationActivity.smali:3034-3049` | `nc.smali:86-121` | last resolved **model type code** (the `zm` code) | +| `LAST_SCOOTER_SCOOTER_PROTOCOL` | `ScooterInformationActivity.smali:3051-3066` | `nc.smali:122-153` | last **protocol version** | +| `AUTO_CONNECT` + MAC | `ic.smali:139` | `hc.smali:68`, `ic.smali:93` | per-device auto-connect consent; drives the 1 s debounced connect | + +All key *names* live in `gq.smali`'s `` (`:74-88` for these four) and are inlined at +every use site, which is why a single key literal appears in two files. + +--- + +## 8. What lets it fail safely + +1. **Unknown/foreign devices are simply never listed or clicked.** The 0x424E requirement is + a hard gate in both the display path (`hc.smali:341-361`) and the click path + (`ic.smali:184-210`) — a random BLE device cannot be connected to by accident. +2. **Unmatched type code ⇒ `zm.UNKNOWN`, not a crash** (`App$…:139`, `zm.smali:445`). The + fallback constant carries its own default register-map lists (`zm.smali` ``, + first constant), so the app degrades to a generic layout instead of refusing. +3. **Out-of-range advertisement reads yield `null`, not an exception.** + `eq.OO0OOOOOOOO00000O0O0` is `getOrNull`, so a short/absent fe95 payload makes the + comparison "equal" and the scan simply continues (`ScooterInformationActivity$…:162-201`). + A device that never advertises the fe95 service therefore never triggers this + auto-connect path — no exception, no connection. +4. **The scan-result loop is wrapped in try/catch** and reports to Crashlytics instead of + crashing (`hc.smali:51,374-386`). +5. **Connection retry after GATT status 133** (`0x85`): the error consumer `qo` + (`qo.smali:26-236`, registered at `op.smali:2672`) drops the cached `RxBleClient` + (`qo.smali:59-66`), then re-scans **filtered to the known MAC** with a 1-second timeout + (`qo.smali:88-190`) and re-enters the state machine (`qo.smali:183-190,236`) instead of + retrying blindly. `BleAlreadyConnectedException` is special-cased (`qo.smali:59-66`). +6. **Scan failures are surfaced, not thrown** — `BleScanException` error codes map to + localized text (`lc.smali:59-110` + `packed-switch` tables). + +**What it does *not* do for safety:** there is no allow-list or block-list of company ids +beyond 0x424E, no length validation of the 0x424E payload before indexing bytes 0/1 +(`ic.smali:235,262`), and no "model == UNKNOWN ⇒ refuse to send writes" guard anywhere +(`zm.UNKNOWN` is read only as a default at `App.smali:103`, `App$…:139`). Safety comes from +the advertisement gate, not from a post-identification check. + +--- + +## 9. What the app does **not** do (explicit negatives) + +* It does **not** put any `ScanFilter` on a service UUID, a service-data UUID, a + manufacturer id, or a name — sites 1 and 2 filter nothing at all + (`MainActivity.smali:665-699`, `ScooterInformationActivity.smali:1318-1321`). +* It does **not** use `ScanSettings` batching (`reportDelay = 0`, `MainActivity.smali:651`). +* It does **not** call `setLegacy` on the framework settings (no such call exists). +* It does **not** parse the advertised name for a brand: the complete pattern list is + `" "` and `"N3M"`, both `startsWith`, both only when the type code is 0 + (`App$…:143-201`). +* It does **not** read the fe95 service data at any offset other than 4, and does not read + the product-id bytes 2–3 (`ScooterInformationActivity$…:158-192`). +* It does **not** read 0x424E payload bytes beyond 0 and 1 (`ic.smali:235,262`). +* It does **not** derive the model or the protocol from anything the device says after + connecting (writer census in §7). +* It does **not** keep a per-MAC model cache: `SCOOTER_LAST_*` and `LAST_SCOOTER_*` are + global scalars, not keyed by address. +* It does **not** verify that the connected device is the same model it guessed — no + comparison of a post-connect identifier against `App.O0O000O0O0OOO0OO00O0` exists. + +--- + +## 10. Comparison with `/home/kali/ScooterHacking/repo` (M365-Rokid-HUD) + +| Aspect | M365 Tools 1.8.0 (this analysis) | repo | Difference that matters | +|---|---|---|---| +| Scan call | `scanBleDevices(ScanSettings(low-latency, callbackType 1, delay 0, aggressive, 3 matches), [empty filter])` at `MainActivity.smali:649-699` | `scanner.startScan(callback)` — no settings, no filters (`app/src/main/java/com/m365bleapp/ble/BleManager.kt:286-303`) | repo inherits framework defaults (low-power, batched off) and relies entirely on software filtering; M365 Tools explicitly asks for low-latency/aggressive delivery | +| Identity rule | **0x424E manufacturer data must be present**; type/protocol read from its bytes 0/1 (`hc.smali:341-361`, `ic.smali:235,262`) | name prefix `"MIScooter"` **or** service UUID `fe95` (`ninebot-ble/src/identity.rs:65-68`, `:38-47`; `app/src/main/java/com/m365bleapp/protocol/ScooterModelRegistry.kt:4`) | M365 Tools never matches on a name prefix at scan time and never uses `fe95` as an identity signal; the repo never looks at manufacturer data at all — it cannot see a Ninebot that advertises no `MIScooter` name | +| Protocol/model choice | from the advertisement byte (`protocol = mfr[1]`, `type = mfr[0]`), overridable by stored prefs / hidden SeekBars (`ic.smali:262-303,381-386`; `nc.smali:89,123`) | name prefix → `ScooterModel` hint at `Confidence.UNVERIFIED`, manual override wins (`ScooterModelRegistry.kt:293-345`); dialect decided by **probing after connect** (`ninebot-ble/src/identity.rs:57-64`) | the repo's own docs say a scan "cannot establish a model or a protocol"; M365 Tools disagrees and *does* take a protocol dial from the advertisement (byte 1). If byte 1 is a protocol generation, that is a real, cheap signal the repo currently ignores | +| Unknown device | model = `zm.UNKNOWN` (code 0) with a default register map; device is still connectable if 0x424E was present (`App$…:139`) | `ScooterModel.UNKNOWN` + `Identification.isUnknown`, GATT layouts probed and an empty result is left empty rather than guessed (`BleManager.kt:69-84`) | M365 Tools has no "unknown ⇒ no writes" guard; the repo's explicit empty-profile reporting is strictly safer | +| Name heuristic | `startsWith(" ") \|\| startsWith("N3M")` ⇒ `MINI` (type 3) (`App$…:143-201`) | longest-prefix match over `MIScooter`/`Mi Scooter`/`NB`/`Ninebot`/`Segway` (`ScooterModelRegistry.kt:234-250,293-317`) | the repo's pattern list is far richer; M365 Tools' single rule is nearly useless and is not the basis of its identification | +| Retry on failure | re-scan filtered by MAC, 1 s timeout, then re-enter the state machine on GATT 133 (`qo.smali:88-190`) | not present in `identity.rs`/`ScooterModelRegistry.kt`; connection retry policy is in `BleManager.kt` (`onConnectionStateChange`, `:176-229`) and is state-based, not advertisement-based | M365 Tools uses the advertisement as a recovery signal; worth copying for flaky modules | +| NUS | NUS UUIDs exist as encrypted constants but are not used at scan time (`ScooterInformationActivity.smali:159,258,263`; `op.smali:140,149`) | repo's `BleManager` is NUS-first (`BleManager.kt:38-44`) | CONTEXT.md's "the app never uses NUS" was wrong; the app does know NUS, but only in the post-connect GATT layer | + +--- + +## 11. Unverified / needs hardware + +Nothing in this report was observed on a live link. All of the following need a device — +and only a **Xiaomi M365** (MAC `C7:B8:DC:3B:A1:B2`) is available, so most of it is +permanently out of reach for direct confirmation: + +1. **The existence and semantics of the 0x424E manufacturer payload.** Not a single real + advertisement was captured. Whether the M365 (or any Ninebot/Xiaomi scooter) emits + company id 16974 at all, and with what length, is unverified. If the M365 only emits + `fe95` service data and never 0x424E, then M365 Tools would not list it — a testable, + falsifiable prediction from this analysis. +2. **Byte 0 = type code / byte 1 = protocol version.** Verified only as *how the app uses + two bytes*; that the air interface actually encodes a Ninebot type code and a protocol + generation there is inferred. Needs a capture plus a comparison against a known model. +3. **The type-code table in §5.1.** Read from the DEX (`zm` enum construction), but whether + the codes match Ninebot's published model numbering, and whether the app's own + `MI_SCOOTER_*` codes correspond to real advertising bytes, is unverified. +4. **fe95 byte 4 = rolling counter.** The app's requirement (must change) is read; "counter" + is inferred from the MiBeacon family layout. A capture of two consecutive advertisements + from the M365 would settle it and would also show whether the M365 advertises `fe95` + service data at all. +5. **Whether the app's hidden SeekBars (`seekBar_tx_manufacturerId`, + `seekBar_protocolVersion`) exist in the shipped layout** — the field names are read from + `ic.smali:271,295`, but whether the Views are inflated (ids `0x7f…`) and what their max + values are was not checked; the defaults 101/5 are read from the guard constants only. +6. **The protocol version's meaning.** `App.O0000O00OO00OOOO0000` is compared to `1` in + `iq.O000OO0OO0OOO00OO000` and stored/restored as `LAST_SCOOTER_SCOOTER_PROTOCOL`, but no + code path was traced here that maps it onto a concrete handshake; that belongs to the + handshake area and needs confirmation. +7. **The discarded slice `Arrays.copyOfRange(scanRecord.getBytes(), 0x11, len)` + (`ic.smali:145-161`)** may be a leftover or a stripped debug log. What offset 17 was + meant to point at is unknown. +8. **Whether the 0x424E array is the manufacturer payload or the whole AD** — the + `SparseArray` key type makes it the payload after the company id, but this rests on the + RxAndroidBle API contract, not on a capture. +9. **Native code.** `libnative-lib.so` exports `Java_app_peretti_m365tools_ScooterInformationActivity_BluetoothItem` + and `…_calculateK`, both called from smali (`op.smali:426`, `up.smali:38`, `ck.smali:181`, + `op.smali:578,819`). `BluetoothItem()` output is Base64-decoded into a String at + `op.smali:436-448` and `calculateK([B)` is the key-derivation entry point. Neither was + disassembled here; nothing in this report depends on them, but the identity/auth story + will not be complete without them. diff --git a/doc/reverse-engineering/m365tools-reports/02-gatt-selection.md b/doc/reverse-engineering/m365tools-reports/02-gatt-selection.md new file mode 100644 index 0000000..a928191 --- /dev/null +++ b/doc/reverse-engineering/m365tools-reports/02-gatt-selection.md @@ -0,0 +1,467 @@ +# 02 — GATT service/characteristic selection, fallback, and the read/write/notify transport + +**Target.** `app.peretti.m365tools` 1.8.0 (versionCode 180), sha256 +`4451a47c86339b8f88d77bdad7aef77e1bc4f11b7c728a5803f2b1d342a16872`. +**Method.** Static only. Smali under `apktool-out/` is the source of record for every +citation; `jadx-out-nodeobf/` was used only for orientation. Every UUID below was +recovered by decrypting the app's string table with +`tools/m365tools_strings.py` — **no GATT UUID of interest is a plaintext literal.** + +--- + +## 0. Correction to a previously recorded "established fact" + +`CONTEXT.md:88` states "The app does not use Nordic UART. No `6e4000xx` UUID appears +anywhere." **That is false**, and the way it is false matters for anyone reading this +tree: + +| Claim | Reality | +|---|---| +| `grep 6e4000` over the APK finds nothing | true — the literals are encrypted | +| therefore the app does not use NUS | **false** | + +`6e400002-b5a3-f393-e0a9-e50e24dcca9e` and `6e400003-b5a3-f393-e0a9-e50e24dcca9e` are +loaded at `apktool-out/smali/dalvik/O/O/a/b/O/c/b/O/o/op.smali:140` and `:149` in that +class's ``, and the authoritative assignments are `op.smali:450-457` and +`op.smali:459-466`. They are the app's **primary write and notify characteristics**. +`6e400001-b5a3-f393-e0a9-e50e24dcca9e` (the NUS *service* UUID) is present in the +table but **never used** — see §1.1. + +A second trap the same episode exposes: **a decrypted string can have only dead load +sites.** The string obfuscator was applied to the DEX *after* R8, so it rewrote +`const-string` in code R8 had already orphaned, producing `invoke-static …decrypt(key)` +whose result is discarded. `ScooterInformationActivity.` +(`apktool-out/smali/app/peretti/m365tools/ScooterInformationActivity.smali:143-233` +and `:258-276`) is 26 such dead decrypts in a row, including four of the UUIDs below. +`--uses` therefore tells you *where a UUID string is loaded*, not that it is used. +This report labels those rows **dead**. + +--- + +## 1. The full UUID set + +### 1.1 Table + +Roles are as the app uses them. "Where defined" gives the smali that writes the +literal into a field or passes it to `UUID.fromString`. All entries are encrypted +strings unless marked *plaintext*. + +| # | UUID | Role | Where defined | Confidence | +|---|---|---|---|---| +| 1 | `0000fe95-0000-1000-8000-00805f9b34fb` | **Scan filter only.** Read out of the advertisement's service-data map to obtain the model byte. Never used as a GATT service. *(plaintext string literal)* | `dalvik/…/tn$OO00000OOOOOOOO0000O.smali:125` | read | +| 2 | `00002902-0000-1000-8000-00805f9b34fb` | **CCCD**, written by RxAndroidBle when a notification is enabled; the app never names it. *(plaintext)* | `dalvik/…/je0.smali:39`, used `je0.smali:89-111` | read | +| 3 | `6e400001-b5a3-f393-e0a9-e50e24dcca9e` | NUS **service**. **Dead** — its only load site is inside the dead block `ScooterInformationActivity.smali:159`. The app never filters or looks up a service UUID. | `op.smali` table, load site `ScooterInformationActivity.smali:159` | read (never used) | +| 4 | `6e400002-b5a3-f393-e0a9-e50e24dcca9e` | **Write** characteristic. Used by *every* command strategy: `fn`, `gn`, `mn`, `bn`, `nn`, `kn`. | value set `op.smali:140`, field `op.OO00000OOOOOOOO0000O` final `op.smali:450-457`; write sites `mn.smali:842-854`, `gn.smali:972-982`, `fn.smali:1296-1304`, `bn.smali:437-447`, `nn.smali:508-522`, `kn.smali:2367-2377` | read | +| 5 | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` | **Notify** characteristic — the only notification subscription made on every connection, unconditionally. | value set `op.smali:149`, field `op.OOOOOOO0OOOOO0O00OO0` final `op.smali:459-466`; subscribe `po.smali:1288-1296` | read | +| 6 | `00000001-0000-1000-8000-00805f9b34fb` | Xiaomi "classic" **write** (auth handshake) and, on the `protocolVersion == 1` path, **notify**. | `op.smali:167`, field `op.O00OOO0O00O00OOO0000`; write `io.smali:128-134`, `io.smali:292-312`; notify `fo.smali:59-65` | read | +| 7 | `00000002-0000-1000-8000-00805f9b34fb` | Xiaomi classic **read** (only when `protocolVersion == 1`). | `op.smali:176`, field `op.OO00O0O0O0000000000O`; read `po.smali:1415-1421` | read | +| 8 | `00000004-0000-1000-8000-00805f9b34fb` | **Read** — used only by the `kn` strategy. | `op.smali:185`, field `op.OO0O0OOO00O00OOO0000`; read `kn.smali:2709` | read | +| 9 | `00000010-0000-1000-8000-00805f9b34fb` | **Write + notify.** Two separate static fields hold this identical string. `wn` writes a 4-byte heartbeat to it every 3 s; `kn` both subscribes to it and writes an auxiliary frame to it. | field A `op.OO0OOOOOOOO00000O0O0` ← `op.smali:194-201`; field B `op.O0O000OOOO0000OOOO0O` ← `op.smali:213-220`; write `wn.smali:53-61`, `wn.smali:89-97`; notify `kn.smali:895-901`; write `kn.smali:2643` | read | +| 10 | `00000013-0000-1000-8000-00805f9b34fb` | **Dead.** Decrypted twice by `op`'s `` and never stored. | `op.smali:203`, `op.smali:208` | read (dead) | +| 11 | `00000014-0000-1000-8000-00805f9b34fb` | **Read** (only when `protocolVersion == 1`). The live value is delivered at run time by a native call, not by the initialiser. | decoy `op.smali:158-165`; live `op.smali:424-448` ← `ScooterInformationActivity.BluetoothItem()` (native); read `po.smali:1386-1392` | read | +| 12 | `00000019-0000-1000-8000-00805f9b34fb` | **Write + notify** — used only by the `kn` strategy. | `op.smali:222`, field `op.OO00OOOOO00OO00OOOO0`; notify `kn.smali:961-967`; write `kn.smali:1031` | read | +| 13 | `00001800-0000-1000-8000-00805f9b34fb` (GAP) | **Dead** — one dead load site. | `ScooterInformationActivity.smali:268` | read (dead) | +| 14 | `00002a00-0000-1000-8000-00805f9b34fb` (Device Name) | **Dead** — one dead load site. | `ScooterInformationActivity.smali:273` | read (dead) | + +Notes: + +- Rows 10, 13 and 14 are **not used by any live code path.** Rows 3, 6 and 7 are + used only on the hidden `protocolVersion == 1` path (§4). +- There is **no Ninebot/Segway custom service**, no `6e400001-0000-0000-006e-…` + vendor UUID, no HMSoft `ffe0`/`ffe1`, and no `0000fff0`-family UUID anywhere in the + decrypted table. The complete set of UUID-shaped plaintexts in + `analysis/strings.txt:104-112` and `:164-166` is exactly rows 3-14 above. +- There are exactly **two** UUID string literals in the clear in the whole APK + (rows 1 and 2) — confirming the earlier whole-APK pass, and explaining why it + concluded wrongly. + +### 1.2 How `00000014` is hidden + +`op`'s static field `O0OOOOOO00OOOOO000O0` is initialised to `00000014-…` at +`op.smali:158-165`, then **overwritten** at `op.smali:424-448` with + +``` +new String(Base64.decode(ScooterInformationActivity.BluetoothItem(), 2), + Charset.forName()) +``` + +`BluetoothItem()` is a **native** method (`ScooterInformationActivity.smali:417`). +The payload is `libnative-lib.so`: the string +`MDAwMDAwMTQtMDAwMC0xMDAwLTgwMDAtMDA4MDVmOWIzNGZi` sits next to the JNI symbol +`Java_app_peretti_m365tools_ScooterInformationActivity_BluetoothItem`, and base64-decodes +to `00000014-0000-1000-8000-00805f9b34fb` — the same value as the decoy. The native hop +exists to break static extraction, not to change the value. (Verified as a byte string +in `apktool-out/lib/arm64-v8a/libnative-lib.so`; the decode is independently checkable.) + +Also in `op`'s ``, five 4-byte arrays are hex-decoded from encrypted strings +(`op.smali:231-294`): `90CA85DE`, `00BC43CD`, `92ab54fa`, `09acbf93`, `C9589A36`. +Two of them are the **classic-M365 heartbeat packets** written to characteristic +`00000010` every three seconds (`wn.smali:36-61`; interval created at +`yn.smali:137-144` as `Observable.interval(1, 3, SECONDS)`). + +--- + +## 2. How a characteristic is chosen + +**There is no service-UUID selection at all, and no exact-UUID-set requirement.** + +The app never calls `BluetoothGatt.getService(UUID)` (zero occurrences anywhere in the +APK's smali) and never inspects a service UUID. Every GATT access goes through one of +three RxAndroidBle methods on the connection object, each taking only a +*characteristic* UUID: + +| RxAndroidBle interface (`kb0` after R8 shrinking) | Real method | Returns | Declared | +|---|---|---|---| +| `kb0.O0OOOOOO00OOOOO000O0(UUID,[B)` | `writeCharacteristic` | `Single` | `dalvik/…/kb0.smali:15` | +| `kb0.OO00000OOOOOOOO0000O(UUID)` | `setupNotification` | `Observable>` | `kb0.smali:27` | +| `kb0.OOOOOOO0OOOOO0O00OO0(UUID)` | `readCharacteristic` | `Single` | `kb0.smali:40` | + +Resolution happens inside RxAndroidBle, in `mb0.call()` +(`apktool-out/smali_classes2/dalvik/O/O/a/b/O/c/b/O/o/mb0.smali:42-92`), which: + +1. iterates **every** discovered `BluetoothGattService` in + `nb0.OO00000OOOOOOOO0000O` (`mb0.smali:46-52`), +2. calls `service.getCharacteristic(uuid)` on each (`mb0.smali:70`), +3. returns the **first non-null match**, and +4. throws `BleCharacteristicNotFoundException(uuid)` if none matched + (`mb0.smali:80-84`). + +So the app is *characteristic-addressed and service-agnostic*: whatever service a +device happens to expose the characteristic in, the app will find it. (Service +discovery itself is triggered by RxAndroidBle on first use — +`ag0.smali:121` calls `BluetoothGatt.discoverServices()` — and the app never calls it +directly.) + +Before dispatching, RxAndroidBle enforces **properties**: + +- writes require property mask `0x4C` = `WRITE | WRITE_NO_RESPONSE | WRITE_SIGNED` + (`le0$O0OOOOOO00OOOOO000O0.smali:64-68` → `md0` → `xe0.smali:29-41`), so at least + one write property must be present; +- notifications require mask `0x10` = `PROPERTY_NOTIFY` only (`me0.smali:59-61`). + +A violation raises `BleIllegalOperationException` (`md0$OO00000OOOOOOOO0000O.smali:59-80`, +message format `pd0.smali:98`: *"Characteristic %s supports properties: %s (%d) does not +have any property matching %s (%d)"*). + +### 2.1 Fallback: there is none + +Nothing in the app handles `BleCharacteristicNotFoundException` or any other +`com.polidea…` exception: + +- zero `instanceof Lcom/polidea/…Exception` checks outside the library itself; +- zero uses of `onErrorResumeNext` outside RxAndroidBle internals + (`qo0.O00O0O000O0000OO0OOO` is called only from `xg0.smali:54` and + `qi0$OO00000OOOOOOOO0000O.smali:88`, both library code); +- no retry against a different characteristic UUID anywhere. + +What happens instead is a **log line and, at most, a disconnect**: + +| Failing stream | Error consumer | Behaviour | +|---|---|---| +| `setupNotification(6e400003)` (`po.smali:1296,1338`) | `vn` | logs `'Error on subscribeing '` + `Throwable.toString()`; returns (`vn.smali:36-51`) | +| `setupNotification(00000001)` (`fo.smali:65,110`) | `ro` | logs `'Notifications error: '` then calls `op.OO0O00O0OO00OOO00O00()` (`ro.smali:44-56`) | +| write failures on the classic path | `ko` | logs `'Write error: '` (`ko.smali:44-56`) | +| `op.OO0O00O0OO00OOO00O00()` | — | resets handshake state, logs `'TriggerDisconnect'`, reports to Firebase, disposes the connection (`op.smali:3996-4180`, strings at `:4018-4031`) | + +**The "fallback" that does exist is not exception-driven; it is chosen before any +characteristic is touched**, from the model table and the (hidden) protocol-version +setting. See §4. If the chosen variant's characteristic is absent, the connection dies +with a log line — there is no second attempt. + +--- + +## 3. Notifications: notify, never indicate + +Notifications are enabled through one call only — RxAndroidBle +`setupNotification(uuid)` = `kb0.OO00000OOOOOOOO0000O(UUID)`. There are exactly four +subscription sites: + +| Site | Characteristic | Condition | +|---|---|---| +| `po.smali:1288-1296` | `6e400003` | **always**, on every successful connection | +| `kn.smali:895-901` | `00000010` | `kn` strategy only | +| `kn.smali:961-967` | `00000019` | `kn` strategy only | +| `fo.smali:59-65` | `00000001` | `protocolVersion == 1` path only | + +**The app never uses indications.** Evidence: + +1. `kb0` (RxAndroidBle's `RxBleConnection`, R8-shrunk) declares **three** methods + (`kb0.smali:15,27,40`); `setupIndication` is not among them. R8 only removes an + interface method that is never invoked, so no call site exists in the DEX. +2. The notification-setup operation hard-codes `isIndication = false` + (`me0.smali:59-75` passes literal `0` into the `ee0` callable), so the CCCD is + always written with `BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE` + (`ma0.smali:52`); `ENABLE_INDICATION_VALUE` (`la0.smali:52`) is never selected. +3. The property pre-check is `PROPERTY_NOTIFY` (`0x10`) only (`me0.smali:59`), so a + characteristic that supports **only** `PROPERTY_INDICATE` is rejected outright. + +Consequence: the app cannot talk to a device whose notify channel is indicate-only. +The fork's `GattProfileDiscovery` accepts `PROPERTY_NOTIFY or PROPERTY_INDICATE` +(`GattProfileDiscovery.kt:65`, `BleManager.kt:111-114`) but its `BleManager` then writes +`ENABLE_NOTIFICATION_VALUE` unconditionally (`BleManager.kt:444`) — it would advertise +support for an indicate-only channel and then enable it with the notify value, which is +the same bug in a different place. + +CCCD mechanics live entirely inside RxAndroidBle +(`je0.smali:39,89-111`): it takes the CCCD descriptor from the *given* characteristic, +and if the descriptor is missing it fails with +`BleCannotSetCharacteristicNotificationException(chr, 2, null)` (`je0.smali:95-111`). +The app never reads or writes the CCCD itself. + +--- + +## 4. Per-model / per-protocol transport selection + +The transport is **not** one transport. A strategy object +`op.OO0O0O0OO0OOO0OO0000` of abstract type `vm` +(`dalvik/…/vm.smali`, declared `op.smali:74`) is constructed once per connection inside +the connection callback `po.accept(RxBleConnection)` — `po.smali` has exactly one +method, `O00OOO0O0O00OOO0000(Ljava/lang/Object;)V`, entered at `po.smali:36`; the +connection is stored at `po.smali:108`. + +The choice is a two-key dispatch performed at `po.smali:111-183` and its labelled +branches: + +- **key 1** = `App.O00OO00OOOOOO0OOO00O().ordinal()` — the scooter model enum `zm` + (`App.smali:153-165`). +- **key 2** = `App.O000OO0OO0OOO00OO000()` — an app-wide `int` + (`App.smali:139-151`), set only from `MainActivity.seekBar_protocolVersion` + (`ic.smali:295-316,382`) or a stored preference (`nc.smali:120-153`). The SeekBar is + `android:max="5" android:progress="5"` and `android:visibility="gone"` + (`apktool-out/res/layout/activity_main.xml:21`), so the value is **0-5, defaults to + 5, and is not user-reachable in normal use.** +- three Firebase Remote Config flags act as remote switches inside some branches: + `nbsecft`, `nbsecf`, `ecdh_old` (`po.smali:156`, `:447`, `:535`, `:623`, `:734`). + +`zm` ordinals (declaration order; the six Xiaomi entries were extracted and match +report 08): + +| ordinal | model | `po.smali` branch | +|---|---|---| +| 0 | `UNKNOWN` | default | +| 1 | `ONE` | default | +| 2 | `MINI` | `:722` | +| 3 | `MINI_MAX` | default | +| 4 | `MARK2` | default | +| 5 | `ESCOOTER` | `:611` | +| 6 | `VIO` | default | +| 7 | `KART` | default | +| 8 | `KART_PRO` | default | +| 9 | `KART_PRO_LAMBO` | default | +| 10 | `NANO` | default | +| 11 | `MINI_KIDS_MI` | default | +| 12 | `MINI_KIDS` | default | +| 13 | `MARK3` | default | +| 14 | `SCOOTER2` | `:523` | +| 15 | `SCOOTER2_P` | default | +| 16 | `MI_SCOOTER_PRO` | `:255` (`pswitch_1`) | +| 17 | `MI_SCOOTER_1S_DE` | `:186` (`pswitch_0`) | +| 18 | `MI_SCOOTER_LITE` | `:186` | +| 19 | `MI_SCOOTER_1S` | `:186` | +| 20 | `MI_SCOOTER_PRO2` | `:186` | +| 21 | `MI_SCOOTER_3` | `:186` | +| 22 | `WILD_STEEL_DUST` | default | +| 23 | `SCOOTER_G30` | `:435` | +| 24 | `STEELDUST` | default | +| 25 | `MEBIKE` | default | +| 26 | `SCOOTER_AIR` | default | +| 27 | `F_Series30` | default | +| 28 | `F_Series60` | default | + +Ordinals 0-5, 7, 9, 11, 12, 24, 25, 26 were read directly from the `` +constructions; 6, 8, 10, 13-23, 27, 28 are **inferred** from declaration order, which +the thirteen directly-read values confirm is ordinal order. + +Dispatch result, by model group and `protocolVersion` (`pv`): + +| model group | `pv == 2` | `pv == 1` | any other `pv` (incl. default 5) | +|---|---|---|---| +| `MINI` (2) — `po.smali:722-832` | RC `nbsecf == 1` → `fn`, else `gn` | `mn` | `nn` | +| `ESCOOTER` (5) — `po.smali:611-719` | RC `ecdh_old == 1` → `kn`, else `gn` | `mn` | `nn` | +| `SCOOTER2` (14) — `po.smali:523-608` | RC `nbsecf == 1` → `fn`, else `gn` | `bn` | `bn` | +| `MI_SCOOTER_PRO` (16) — `po.smali:255-318` | RC `ecdh_old == 1` → `gn`, else `kn` | `mn` | `nn` | +| `MI_SCOOTER_1S_DE/LITE/1S/PRO2/3` (17-21) — `po.smali:186-252` | `kn` | `mn` | `nn` | +| `SCOOTER_G30` (23) — `po.smali:435-520` | RC `nbsecf == 1` → `fn`, else `gn` | `bn` | `bn` | +| every other ordinal — `po.smali:142-183`, `:345-432` | RC `nbsecft == 1` → `gn`, else `fn` | `mn` | `bn` | +| *no model* (device object null) — `po.smali:2697-2702` | logs `'mbledevice null'` and returns | | | + +Each strategy is a `vm` subclass constructed with the same +`RxBleConnection` + two `ConcurrentLinkedQueue`s: + +| strategy | class | framing (cross-check, see report 03/05) | +|---|---|---| +| `fn` | `dalvik/…/fn.smali` (extends `vm`, `fn.smali:2-3`) | `5A A5` (`fn.smali:135`, `:1241`) | +| `gn` | `dalvik/…/gn.smali` (`gn.smali:2-3`) | `5A A5` (`gn.smali:155`, `:221`) | +| `mn` | `dalvik/…/mn.smali` (`mn.smali:2-3`) | `55 AB` (`mn.smali:768`) | +| `bn` | `dalvik/…/bn.smali` (`bn.smali:2-3`) | `5A A5` (`bn.smali:373`) | +| `nn` | `dalvik/…/nn.smali` (`nn.smali:2-3`) | `55 AA` (`nn.smali:450`) | +| `kn` | `dalvik/…/kn.smali` (`kn.smali:2-3`) | no `55`/`5A` magic; separate AES path; ctor constants `0x20,0x23,0x21,0x24,0x22,0x25` at `kn.smali:88-116` | + +**Transport versus framing.** Framing varies by strategy; the *GATT* transport does +not, with one exception: + +- `fn`, `gn`, `mn`, `bn`, `nn` — write `6e400002`, notify `6e400003`, nothing else. +- `kn` — write `6e400002` for the main command path (`kn.smali:2377`), plus + **notify `00000010` and `00000019`** (`kn.smali:895-901`, `:961-967`) and a + **read of `00000004`** (`kn.smali:2709`), plus auxiliary writes to `00000019` + (`kn.smali:1031`) and `00000010` (`kn.smali:2643`). `kn` is the only strategy that + overrides `vm.OOOOOOO0OOOOO0O00OO0` (read-a-register-and-callback; the base class + throws `NotImplementedException`, `vm.smali:167-177`), and the only caller is + `po.smali:1375`, gated on `protocolVersion == 0`. +- Additionally, **`protocolVersion == 1` leaves NUS entirely**: `po.smali:1383-1442` + reads `00000014` then `00000002`, and the continuation `fo` subscribes to + **notify `00000001`** and starts a 3-second heartbeat that writes the 4-byte + constants `90CA85DE` / `00BC43CD` to **`00000010`** (`fo.smali:52-115`, + `yn.smali:120-190`, `wn.smali:36-160`). The auth handshake on that path writes its + 16-byte token to `00000001` (`io.smali:126-134`, `:289-312`). + +So: **two GATT layouts exist in the app — NUS (`6e400002`/`6e400003`) and the Xiaomi +classic layout (`00000001`/`00000002`/`00000004`/`00000010`/`00000014`/`00000019`) — +and which one is used is decided by the model ordinal plus the hidden protocol-version +integer, not by what the device advertises.** + +--- + +## 5. MTU and write fragmentation + +**The app never negotiates an MTU and never learns one.** + +- `requestMtu` occurs **zero times** in the entire decoded APK (app and library). +- `onMtuChanged` occurs once, as RxAndroidBle's pass-through override + (`oe0$OOOOOOO0OOOOO0O00OO0.smali:739-756`), whose body is a logging call + (`af0.OO0O0OOO00O00OOO0000`) followed by `invoke-super`; the value is not stored. +- `RxBleConnection`'s interface `kb0` has no `requestMtu` and no `observeMtuChanges` + method (`kb0.smali:15,27,40`), so there is no call site for either. +- No string in the decrypted table contains "MTU". + +**Chunk size is a hard-coded 20 bytes.** Every command strategy ends with the same +loop; `mn.smali:826-864` is the clearest instance and reads, in Java terms: + +```java +byte[] data = out.toByteArray(); +queue.clear(); +int remaining = data.length, off = 0; +while (remaining > 0) { + int n = Math.min(remaining, 0x14); // 20 bytes, literal + connection.writeCharacteristic( + UUID.fromString(op.OO00000OOOOOOOO0000O), // 6e400002 + Arrays.copyOfRange(data, off, off + n)) + .subscribe(); // fire-and-forget, result discarded + remaining -= n; off += n; +} +``` + +Identical loops: `fn.smali:1280-1310`, `gn.smali:960-990`, `bn.smali:415-451`, +`nn.smali:490-525`, `kn.smali:2353-2377`. There is **no** `MTU − 3` arithmetic +anywhere: `0x14` is written as a literal and is never derived from anything. + +Two further details that a reimplementation must not assume away: + +- **Every write is a write-with-response.** There is no `setWriteType` call anywhere + in the APK and no use of `WRITE_TYPE_DEFAULT` / `WRITE_TYPE_NO_RESPONSE`. RxAndroidBle's + write operation only does `setValue(bytes)` then `BluetoothGatt.writeCharacteristic(…)` + (`ef0.smali:114-119`), so Android's constructor default `WRITE_TYPE_DEFAULT` + (ATT_WRITE_REQ, acknowledged) applies to all traffic. The app's own + `WRITE` / `WRITE_NR` command-type enum + (`app/peretti/m365tools/model/BaseCommand$O0OOOOOO00OOOOO000O0.smali:84,98`) is a + protocol-level notion, **not** a GATT write type, and does not survive to the radio. +- **Chunks are submitted without waiting.** `.subscribe()` is called and the returned + `Disposable` is dropped (`mn.smali:858`), so the next chunk is enqueued immediately; + ordering relies entirely on RxAndroidBle's internal operation queue. + +--- + +## 6. Comparison with `/home/kali/ScooterHacking/repo` + +### 6.1 Where the fork is stricter (and correct where M365 Tools is not) + +| Topic | M365 Tools 1.8.0 | Fork | Assessment | +|---|---|---|---| +| Characteristic lookup | enumerate **all** services, take the first `getCharacteristic(uuid)` hit — no service UUID checked (`mb0.smali:46-84`) | `gatt.getService(serviceUuid)?.getCharacteristic(charUuid)` — requires the service UUID too (`BleManager.kt:382-383`, `:424-425`) | **Fork is stricter.** M365 Tools matches a characteristic in *any* service, so it is immune to a vendor moving a characteristic between services; the fork returns `false` on a service-UUID mismatch even when the characteristic is present elsewhere. For maximum compatibility, iterate services like `mb0` does and treat a missing service UUID as non-fatal. | +| MTU | never requested, never read; 20-byte literal chunk (`mn.smali:832-835`) | records granted MTU (`BleManager.kt:159-173`) and sizes chunks `MTU − 3`, clamped to 255 (`MtuFragmenter.kt:30-66`) | **Fork is better**, and M365 Tools confirms the failure mode the fork documents: the reference app is permanently pinned to 20-byte writes even on a link that granted 512. | +| Write type | implicit `WRITE_TYPE_DEFAULT` always (`ef0.smali:114-119`) | explicit per call (`BleManager.kt:390-400`) | Fork better; also means M365 Tools' `WRITE_NR` command class never actually becomes a no-response ATT write. | +| Write acknowledgement | fire-and-forget `.subscribe()`, no completion wait (`mn.smali:858`) | awaits `onCharacteristicWrite` when `waitForResponse` (`BleManager.kt:365-413`) | Fork better; but note M365 Tools' model is *deliberately* pipelined — it never blocks on a chunk. | +| Notify vs indicate | notify only, hard-wired (`me0.smali:59`, `ma0.smali:52`); indicate-only devices rejected | discovery accepts `NOTIFY or INDICATE` (`GattProfileDiscovery.kt:65`, `BleManager.kt:111-114`) but enablement writes `ENABLE_NOTIFICATION_VALUE` only (`BleManager.kt:444`) | **Fork has a latent bug**: it will select an indicate-only channel and then arm it with the notify value. Either accept only `PROPERTY_NOTIFY`, or branch the descriptor value on `PROPERTY_INDICATE`. M365 Tools avoids the problem by refusing indicate-only outright. | +| Missing characteristic | `BleCharacteristicNotFoundException` → log (or disconnect); **no retry with another UUID** (`vn.smali:36-51`, `ro.smali:44-56`) | `cont.resume(false)`; caller decides (`BleManager.kt:384-388`) | Equivalent honesty; neither falls back. | + +### 6.2 Where M365 Tools does something the fork does not + +| Topic | M365 Tools | Fork | What is missing in the fork | +|---|---|---|---| +| **Second GATT layout** | Full Xiaomi classic layout: write/notify `00000001`, read `00000002` and `00000014`, read `00000004`, write+notify `00000010`, write+notify `00000019` (§1.1, §4) | Only NUS is implemented; `AUTH_UPNP = 00000010` and `AUTH_AVDTP = 00000019` exist as constants (`BleManager.kt:42-43`) and are used against service `0000fe95` (`ScooterRepository.kt:826,830,888,…`), but `00000001`, `00000002`, `00000004` and `00000014` appear nowhere | The fork's auth handshake writes `00000010`/`00000019` **inside the `0000fe95` service**. M365 Tools, by contrast, never uses `0000fe95` as a GATT service at all — it only reads the `0000fe95` *service-data* field from the advertisement (`tn$OO00000OOOOOOOO0000O.smali:120-130`) — and addresses `00000010`/`00000019` service-agnostically. If the fork's `getService(0000fe95)` returns null on a device that exposes those characteristics under another service, the fork's handshake silently no-ops. **This is the single most actionable difference in this report.** | +| **Model-selected transport** | Strategy chosen from `zm.ordinal()` × hidden `protocolVersion` before any GATT call (§4) | Protocol selected from observed behaviour (`detectedProtocolOrPlaintext()`, e.g. `ScooterRepository.kt:757-760`) | M365 Tools shows the vendor itself needs per-generation framing; the fork's behavioural detection is arguably better (it does not rely on a model table), but it has no equivalent of the `pv == 1` classic path. | +| **GAP / Device Name** | Present in the string table (`00001800`, `00002a00`) but dead — the app never reads the Device Name characteristic | — | Nothing to adopt; recorded so the next reader does not chase it. | +| **Heartbeat** | 3-second `interval(1, 3, SECONDS)` writing `90CA85DE` / `00BC43CD` to `00000010` while the handshake is unfinished (`yn.smali:137-144`, `wn.smali:36-61`, `op.smali:231-255`) | none | Only relevant if the fork ever implements the classic layout. | + +### 6.3 Where the two agree + +- Both use NUS `6e400002` (write) / `6e400003` (notify) as the primary data plane. +- Both pin the default chunk to 20 bytes. +- Neither implements a fallback from a missing characteristic to an alternative UUID. +- Both treat "no reply" as a protocol failure rather than an empty read. + +--- + +## 7. What the app does **not** do + +Stated explicitly, because several of these are easy to assume: + +1. It does **not** require, filter on, or even mention a service UUID when connecting. + No `getService(UUID)`. `0000fe95` is a *scan* filter only; `6e400001` (NUS service) + is present in the string table and dead. +2. It does **not** enumerate services and pick by predicate. It delegates to + RxAndroidBle's `getCharacteristic(uuid)`, which scans every discovered service for + that one characteristic UUID and returns the first hit (`mb0.smali:46-84`). The + *predicate* is "characteristic UUID equals X"; the service is never part of it. +3. It does **not** fall back when the expected characteristic is absent — no + exception-type check, no `onErrorResumeNext`, no retry against a different UUID. + The variant is picked ahead of time and a miss is terminal for the session. +4. It **never uses indications**, and cannot use an indicate-only characteristic. +5. It **never negotiates an MTU** and **never records** the one it is given. +6. It **never writes without response** at the ATT level, despite having a `WRITE_NR` + protocol command class. +7. It **never writes the CCCD itself** — that is entirely inside RxAndroidBle. +8. It never performs a `UUID(long,long)` construction or a 16-bit-short-to-base-UUID + expansion in app code. The only such code in the APK is RxAndroidBle's own + deprecated `UUIDUtil` (`gj0.smali:40-219`), which the app does not call; all app + UUIDs come from `UUID.fromString` on decrypted full-length strings. +9. It does not read the Device Name characteristic (`00002a00`) or the GAP service + (`00001800`) on any live path. + +--- + +## 8. Unverified / needs hardware + +No scooter and no phone are attached. Everything here is read from the DEX, the +resources and one native library. Specifically unverified: + +- **No frame has been exchanged with any scooter.** The claim that a given model + *actually* exposes the characteristics in §1.1 under the service layout the app + assumes is an inference from the code, not an observation. In particular: whether a + Xiaomi M365 exposes `00000001`/`00000002`/`00000010`/`00000014` in a service other + than `0000fe95` — which is what the fork's design assumes it does *not* — cannot be + settled without a GATT dump. +- **The `protocolVersion` semantics are unknown.** It is an `int` 0-5 with default 5, + set from a hidden SeekBar and from a stored preference; nothing read here says what + the numbers *mean* or which one a real scooter needs. It is **not** claimed to be a + protocol version in the scooter's own sense. +- **The Remote Config flags (`nbsecft`, `nbsecf`, `ecdh_old`) are unread at run time.** + They are `FirebaseRemoteConfig.getLong(key)` values whose live settings are decided + server-side. The branch tables in §4 therefore describe the *code paths*, not which + path a shipping install takes today. +- **`zm` ordinals 6, 8, 10, 13-23, 27 and 28 are inferred** from declaration order + (directly-read ordinals 0-5, 7, 9, 11, 12, 24, 25, 26 agree with that ordering). + A single reordering in the enum would shift the whole `po` dispatch table. +- **The `pv == 1` classic path is entirely unexercised** by any reading here. It is + reachable only when `App.O0000O00OO00OOOO0000 == 1`, which the visible UI cannot set. +- **Whether the 20-byte chunk is sufficient on real hardware is untested.** 20 is the + ATT-default payload size, so it is safe on an un-negotiated 23-byte link — but the + app never checks, and if a scooter's ATT_MTU were ever below 23 (not permitted by the + spec, but see the fork's `MtuFragmenter.kt:63-66` guard) the fragments would be + oversized and silently discarded. +- **`libnative-lib.so` was read as a byte string, not disassembled.** The base64 + constant decoding to `00000014-…` is verifiable independently; that + `BluetoothItem()` returns *that* constant — rather than assembling it or returning + something else — is inferred from the constant's presence next to the JNI symbol. +- Only a Xiaomi M365 (MAC `C7:B8:DC:3B:A1:B2`) is available for eventual verification. + Every Ninebot/Segway conclusion in this report is documentation-grade at best. diff --git a/doc/reverse-engineering/m365tools-reports/03-handshake-and-auth.md b/doc/reverse-engineering/m365tools-reports/03-handshake-and-auth.md new file mode 100644 index 0000000..943fe09 --- /dev/null +++ b/doc/reverse-engineering/m365tools-reports/03-handshake-and-auth.md @@ -0,0 +1,629 @@ +# AREA 3 — Handshake and authentication flows, per protocol family + +**Target:** `app.peretti.m365tools` 1.8.0 (versionCode 180), sha256 `4451a47c…a16872`. +**Method:** static analysis of the apktool smali only. **No scooter, no phone** — see +*Unverified / needs hardware* at the end. + +## Citation conventions + +To keep citations readable, `S/` expands to +`apktool-out/smali/dalvik/O/O/a/b/O/c/b/O/o/` (the R8-repackaged app classes). +Paths for the non-obfuscated app package are given in full as +`apktool-out/smali/app/peretti/m365tools/…`. + +**Operational note for other areas:** the APK has **two smali roots** — +`apktool-out/smali/` and `apktool-out/smali_classes2/`. A `grep -r` restricted to +`smali/` silently misses classes such as `S/lb0.smali` +(`= apktool-out/smali_classes2/dalvik/O/O/a/b/O/c/b/O/o/lb0.smali`, the RxBleDevice +abstraction) and all of Google Tink. Every "nothing references X" claim must cover both. + +## 0. Headline findings + +1. **There are four handshake families, and the app never reads a version or serial + before starting one.** The family is chosen from *(a)* the detected model enum + ordinal, *(b)* a per-device **protocol version int** (0/1/2) loaded from + preferences, and *(c)* three Firebase Remote Config flags (`nbsecft`, `nbsecf`, + `ecdh_old`). The branch table is §2. +2. **The app does contain a real ECDH handshake** — NIST **P-256**, ephemeral + keypair, HKDF-SHA256 with info **`mible-setup-info`**, then a **Xiaomi "mible" + login** with info `mible-login-info`. It lives in class `S/kn.smali`, into which + R8 merged the original `app.peretti.m365tools.model.XiaomiECDH`. +3. **`ecdh_old` does not select "ECDH vs. not-ECDH".** `ecdh_old == 1` selects a + **static-key** transport (`gn`), and `ecdh_old != 1` selects the ECDH transport + (`kn`). The flag is a kill-switch, not a description (§2.3). +4. **`decrypt2` (MainActivity.smali:1421) has nothing to do with BLE.** It is a + Kotlin null-check message inside the **PiracyChecker** licence-key base64 decode + in `onCreate` (§9). Treat it as a false lead. +5. The app's own session crypto uses exactly **two** `Cipher` modes: + `AES/CCM/NoPadding` (`S/kn.smali:1787`) and `AES/ECB/NoPadding` + (`S/an.smali:124`), plus **RC4** by hand (`S/vp$OO00000OOOOOOOO0000O.smali`). + There is **no** AES-CTR / CBC-MAC "Encryption2" anywhere in the app's protocol + classes (§8.4). +6. **`AuthToken` is client-computable**: it is `SHA-256()[0..15]` + — MAC confirmed in §7.1. + +--- + +## 1. State sequence from GATT-connected to "telemetry flows" + +Entry point: `S/op.smali:2397` `op.OO00O0O0O0000000000O()V` (the connect routine). +It calls `establishConnection` on the device handle (`S/op.smali:2650`, +`lb0.OO00000OOOOOOOO0000O(Z)` — `lb0` = RxBleDevice), then subscribes with +**onNext = `po`** (`S/op.smali:2683`, error handler `lo` at `:2688`). + +``` + 1. GATT connected → po.S/po.smali:36 receives the RxBleConnection handle (kb0). + Gate: the Observable emits a kb0. No cryptographic work happens before this. + + 2. po computes the device token: + token = HexDecode(HexEncode(SHA-256(App.OO0OOOOOOOO00000O0O0() UTF-8)))[0..16] + S/po.smali:41-93 (MessageDigest "SHA-256" S/po.smali:56, copyOf(16) S/po.smali:89, + single writer of op.O00000OO0OOOOO00OOOO at S/po.smali:93). + App.OO0OOOOOOOO00000O0O0() is the BLE MAC address string (§7.1). + Gate: none — always computed, even on paths that never use it. + + 3. po stores the connection handle (S/po.smali:108) and resets the frame counter + (S/po.smali:101-105). + + 4. po SELECTS THE PROTOCOL FAMILY (§2) and stores the chosen session object in + op.OO0O0O0OO0OOO0OO0000 (S/po.smali:181,204,229,245,292,310,340,356,379,409, + 425,474,492,513,562,580,601,648,666,696,712,761,779,809,825). + Branch condition: model enum ordinal × protocol version int × Remote Config flag. + + 5. po subscribes to notifications on characteristic 6e400003-… (NUS TX): + S/po.smali:1288-1296 (UUID string op.OOOOOOO0OOOOO0O00OO0, set at S/op.smali:156), + filter/subscribe chain S/po.smali:1300-1355, Disposable stored in + op.OOO00O0OO00OOO0OOOO0 (S/po.smali:1355). + Gate: always executed, before any handshake byte is sent. + + 6a. IF protocol version == 2 (S/po.smali:1358-1375): + session.OOOOOOO0OOOOO0O00OO0(deviceName, callback) — the register/login entry. + For the ECDH transport kn that entry is S/kn.smali:2689, which first READS + characteristic 00000004-… (op.OO0O0OOO00O00OOO0000, S/op.smali:192) + and only then runs the handshake of §5. + → §5 (ECDH) or §6 (chained AES), depending on the family chosen in step 4. + + 6b. ELSE IF protocol version == 1 (S/po.smali:1379-1443): + read 00000014-… → consumer up (S/po.smali:1392; UUID from + S/op.smali:165, overridden at + S/op.smali:426-448 by a Base64 literal) + read 00000002-… → BiConsumer fo (S/po.smali:1421, UUID S/op.smali:183) + fo (S/fo.smali:36) stores the reply in op.OOO0O0OO0000O0O0000O (S/fo.smali:54) + and then READS 00000001-… (S/fo.smali:57-65), piping the result into the + RC4 AuthToken state machine io (S/fo.smali:108) → §4. + + 6c. ELSE (protocol version ∉ {1,2}): + no handshake at all; the family selected in step 4 has already set the + "session ready" flag (§3), so telemetry starts immediately. + + 7. Telemetry tick: ScooterService$O0OOOOOO00OOOOO000O0.run() + (apktool-out/smali/app/peretti/m365tools/ScooterService$O0OOOOOO00OOOOO000O0.smali:46-60) + Gate: `op.O0OO0O00O0OOOO0OO0OO.get()` must be TRUE, else nothing is sent. + When true it calls op.O000000O000000000OO0(), op.O000OO0OO0OOO00OO000() each tick + and op.OOOOOOO00OOO0O00OOOO() every 16th tick; the individual commands are + additionally phase-gated on `System.currentTimeMillis() % 3` (S/op.smali:497-515) + and on the protocol version (S/op.smali:518-534). +``` + +**What is *not* read before the handshake:** no BLE/ESC/BMS version register read, +no serial read. The BLE firmware identity is exchanged *inside* the handshake +(the app compares/echoes `blt.4.17`, S/kn.smali / `S/hm.smali:592`), and the +scooter UID arrives in the handshake reply (§6). + +### 1.1 The "session ready" flag + +`op.O0OO0O00O0OOOO0OO0OO` (AtomicBoolean, initialised **false**, S/op.smali:355-359) +is the single gate between "connected" and "telemetry": + +| Event | Effect | Citation | +|---|---|---| +| Family chosen is a no-auth one (`nn`,`bn`) | set **true** immediately | S/po.smali:248,361,717,830,425,513,601 | +| Family `gn` chosen | set **true** immediately | S/po.smali:315,384,497,585,671,784 | +| `io` handshake confirmed (ver==1) | set **true** | S/io.smali:211,280 | +| `kn` ECDH/login succeeded | set **true** | S/hm.smali:1522,1593; S/tp.smali:41 | +| disconnect / reset | set **false**, vp state → 0 | S/op.smali:4007-4015 | + +--- + +## 2. The selector — which family applies + +### 2.1 Inputs + +| Input | Source | Citation | +|---|---|---| +| Model enum ordinal (`zm`) | `App.O00OO00OOOOOO0OOO00O()` | apktool-out/smali/app/peretti/m365tools/App.smali:153-165 | +| Protocol version int | `App.O000OO0OO0OOO00OO000()`; set from pref `LAST_SCOOTER_SCOOTER_PROTOCOL` | App.smali:139-151; S/ic.smali:382; S/nc.smali:123-153 | +| Remote Config longs | `nbsecft`, `nbsecf`, `ecdh_old` | S/po.smali:154,447,535,623,734 | + +`getLong` returns 0 for a missing key, so **the defaults below are what a device +with no Remote Config override uses** (inferred, marked in §2.3). + +### 2.2 Branch table (read) + +Ordinals are `zm.ordinal()`. The switch is `S/po.smali:110-150`. + +| ordinal(s) | ver==2, flag unset | ver==2, flag set (=1) | ver==1 | ver ∉ {1,2} | +|---|---|---|---|---| +| 2 | `fn` (key) S/po.smali:748-761 | `gn` (key)+ready S/po.smali:766-784 | `mn` (key) S/po.smali:797-809 | `nn`+ready S/po.smali:815-830 | +| 5 | `kn` (no key) S/po.smali:637-648 | `gn`+ready (`ecdh_old`) S/po.smali:653-671 | `mn` S/po.smali:684-696 | `nn`+ready S/po.smali:702-717 | +| 14 | `fn` (key) S/po.smali:549-562 | `gn`+ready (`nbsecf`) S/po.smali:567-585 | `bn`+ready S/po.smali:591-606 | `bn`+ready S/po.smali:591-606 | +| 16 | `kn` (no key) S/po.smali:281-292 | `gn`+ready (`ecdh_old`) S/po.smali:297-315 | `mn` S/po.smali:328-340 | `nn`+ready S/po.smali:346-361 | +| 17,18,19,20,21 | `kn` (no key) S/po.smali:194-204 | — (no flag consulted) | `mn` S/po.smali:217-229 | `nn`+ready S/po.smali:235-250 | +| 23 | `fn` (key) S/po.smali:461-474 | `gn`+ready (`nbsecf`) S/po.smali:480-497 | `bn`+ready S/po.smali:503-518 | `bn`+ready S/po.smali:503-518 | +| everything else | `fn` (key) S/po.smali:169-181 (`nbsecft`) | `gn`+ready S/po.smali:366-384 | `mn` S/po.smali:397-409 | `bn`+ready S/po.smali:415-430 | + +Family meanings: `kn` = Xiaomi mible ECDH (§5); `fn`/`gn` = NinebotSecureCryptor +chained AES, four-argument constructor receives the SHA-256(MAC) token (§4); +`mn` = RC4 session keyed by that token (§3.5); `nn`/`bn` = **no cryptography and +no handshake at all** (three-argument constructors, §8.1). + +### 2.3 The `ecdh_old` / `nbsecf` / `nbsecft` polarity (read — the headline) + +`cmp-long` is followed by `if-nez … :cond_X`, i.e. the **"set" branch is taken when +the flag equals 1**: + +* ordinal 16, ver==2, `ecdh_old` (`S/po.smali:267-315`): flag **== 1 → `gn`** + (`S/po.smali:298-310`); flag **!= 1 → `kn`** (`S/po.smali:282-292`). +* ordinal 5, ver==2, same key (`S/po.smali:623-666`): same polarity. +* ordinals 2/14/23/default, ver==2, `nbsecf`/`nbsecft`: flag **== 1 → `gn`**; + flag **!= 1 → `fn`**. + +So the flag's name is misleading in both directions: `ecdh_old = 1` **removes** the +ECDH transport in favour of a static-key one. Because Remote Config is absent on a +stock install, the **default for the ECDH-capable ordinals is the ECDH transport +`kn`** (inferred from `getLong` defaulting to 0 plus the polarity above — the +inference is the only unread step here). + +### 2.4 What the app does *not* select on + +There is no negotiation: the app never asks the scooter which protocol it speaks +before choosing. The version int is taken from a **preference that the app itself +wrote** (`LAST_SCOOTER_SCOOTER_PROTOCOL`, S/ic.smali:382, S/nc.smali:123-153), i.e. +the family is remembered from a previous session or from the scan-time model +detection. If neither is set, the value is 0 and the app takes the **no-auth** +`bn`/`nn` path. + +--- + +## 3. Protocol version 1 — the RC4 "AuthToken" handshake (`io` / `yn` / `vp` / `fo`) + +Class roles: `fo` = read chaining + token store; `yn` = per-device token setup; +`io` = the 3-state machine; `vp`/`vp$…` = the cipher; `mn` = the frame codec. + +### 3.1 Cipher — it is RC4, not AES (read) + +`S/vp$OO00000OOOOOOOO0000O.smali` is a textbook RC4: + +* S-box init and KSA: `S/vp$OO00000OOOOOOOO0000O.smali:26-147` (i↔j swap at + `:110-119`, `j = (j + S[i] + K[i mod keylen]) & 0xff`). +* PRGA / `crypt`: `S/vp$OO00000OOOOOOOO0000O.smali:223-293` + (`i=(i+1)&0xff`, `j=(j+S[i])&0xff`, swap, `out = in ^ S[(S[i]+S[j])&0xff]`). +* Key length guard `1..256` with the message "key must be between 1 and 256 bytes" + (`S/vp$OO00000OOOOOOOO0000O.smali:32-53,130-138`). +* A second, non-cryptographic helper `OOOOOOO0OOOOO0O00OO0([B,[B)[B` + (`S/vp$OO00000OOOOOOOO0000O.smali:149-219`) builds a 12-byte value: + `key[0..4] XOR other[0..4] ‖ key[4..12]`. + +### 3.2 The two 8-byte tokens + +`vp.(byte[] key, byte[] p2, byte[] p3)` (`S/vp.smali:29-176`) derives: + +| field | value | citation | +|---|---|---| +| `OO0O0OOO00O00OOO0000` | the 12-byte session key (arg 1) | S/vp.smali:58 | +| `O0OOOOOO00OOOOO000O0` (token 1) | `p2[0],p2[2],p2[5],p3[0],p3[0],p2[4],p2[5],p2[1]` | S/vp.smali:64-120 | +| `O00OOO0O00O00OOO0000` (token 2) | `p2[0],p2[2],p2[5],p3[1],p2[4],p2[0],p2[5],p3[0]` | S/vp.smali:123-173 | + +`p2`/`p3` are the "LoginConfirmationKey"/"AuthToken" material; they are assembled in +`yn` (§3.3). Index pattern (0,2,5,4,1) plus one or two bytes of `p3` is exactly the +Ninebot ESx-style byte-scrambled token derivation. + +### 3.3 `yn` — where the session key and the tokens come from + +`S/yn.smali:36-195`, invoked from `fo` (`S/fo.smali:69-72`): + +1. Gate: `op.O0O000OOOO0000OOOO0O()` must be true **and** + `op.OOO0O0OO0000O0O0000O` (the byte[] stored by `fo` from the 00000002 read) + must be non-null; otherwise the handshake is skipped entirely + (`S/yn.smali:50-61`). +2. `App.OO0OOOOOOOO00000O0O0()` (= MAC address) → `.replace(":","")` → **hex-decoded + to 6 bytes** → **reversed** (`S/yn.smali:65-117`; hex decoder `S/v3.smali` + `OOO00O0OO00OOO0OOOO0(String)[B`). +3. `new vp(Arrays.copyOf(op.O00000OO0OOOOO00OOOO, 12) /* session key */, + reversedMacBytes /* 6 B */, op.OOO0O0OO0000O0O0000O /* char 00000002 reply */)` + (`S/yn.smali:121-135`). + +So the 12-byte RC4 session key is the **first 12 bytes of SHA-256(MAC)**, the token +scramble uses the **colon-stripped, byte-reversed MAC**, and the third input is the +2+-byte value read from characteristic 00000002. + +### 3.4 The 3-state machine `io` + +`S/io.smali:36-336`, state in `vp.OO00000OOOOOOOO0000O`: + +| state | what is sent / checked | citation | +|---|---|---| +| 0 | computes `stored = RC4(sessionKey, first 4 notification bytes)` and saves it in `vp.OO00O0O0O0000000000O`; builds `mix = sessionKey[0..4] XOR stored[0..4] ‖ sessionKey[4..12]`; **writes `RC4(mix,"09acbf93")` to 00000001**; state → 1; log `Write LoginConfirmationKey: ` | S/io.smali:70-134; magic S/op.smali:270-281; mix S/vp$OO00000OOOOOOOO0000O.smali:149-219 | +| 1 | success iff `notification[0..4] == RC4(mix,"C9589A36")` → `vp.OOOOOOO0OOOOO0O00OO0=true`, ready=true, log **`Handshake set`** | S/io.smali:154-221; magic S/op.smali:283-294 | +| 1 fail | state → 2; `op.OO00OOOOO00OO00OOOO0(true)` (retry/reset) | S/io.smali:224-234 | +| 2 | success iff `RC4(token1, sessionKey)` equals `RC4(token2, notification)` → ready=true, log **`Write AuthToken: `**, then **write `RC4(sessionKey,"92ab54fa")` to 00000001** | S/io.smali:237-332; magic S/op.smali:257-268 | + +`op.OO00OOOOO00OO00OOOO0(false)` (S/io.smali:331) is the retry/reset call +(`S/op.smali:3767`). Three 4-byte magic constants are consumed — +`09acbf93` (write, state 0), `C9589A36` (expected, state 1) and `92ab54fa` (write, +state 2); two further constants in the same table, `90CA85DE` (S/op.smali:231-242) +and `00BC43CD` (S/op.smali:244-255), are loaded but not used by `io`. + +So in this family the app sends **two** 4-byte magic constants — `09acbf93` +(LoginConfirmationKey) and `92ab54fa` (AuthToken) — each RC4-encrypted under a key +derived from the MAC, and validates the scooter's reply against its own derivation. +Note the "AuthToken" here is a *4-byte command*, not the 16-byte SHA-256 value of §7. + +### 3.5 `mn` — the version-1 frame codec + +`mn` is a 4-argument transport that receives the 16-byte SHA-256(MAC) token +(`S/po.smali:217-229` etc.) and stores it at `S/mn.smali:39`. It uses it as an +**RC4 key**: a fresh `vp$OO00000OOOOOOOO0000O` is constructed **per frame** and the +payload is streamed through it — `S/mn.smali:702-712` (send) and `S/mn.smali:232` +(receive). Because the RC4 keystream is restarted for each frame, identical command +payloads produce identical ciphertext on the wire (a real weakness; read). + +> **Correction to reports/05-write-commands.md:** the token in `mn` is an **RC4** +> key (`vp$OO00000OOOOOOOO0000O` = RC4, `S/vp$OO00000OOOOOOOO0000O.smali:223-293`), +> not an AES key, and the helper involved is *not* the XOR helper +> (`vm.OO0O0OOO00O00OOO0000`, `S/vm.smali:91-126`). + +--- + +## 4. Protocol version 2, static-key family — NinebotSecureCryptor (`fn`/`gn` + `an`) + +`an` (= "NinebotSecureCryptor", `toString()` at `S/an.smali:335-384`) is the +chained-AES engine: + +| Item | Value | Citation | +|---|---|---| +| Fixed key (constant across devices) | `97 CF B8 02 84 41 43 DE 56 00 2B 3B 34 78 0A 5D` | S/an.smali:33-40 | +| Cipher | `AES/ECB/NoPadding`, `Cipher.init(mode, SecretKeySpec(key,"AES"))` | S/an.smali:124-143 | +| Per-state key derivation | `key = HexDecode(HexEncode(SHA-1(state32 ‖ fixedKey16)))[0..16]` | S/an.smali:89-143 (SHA-1 at S/jn.smali:111-152) | +| Chain states | three 32-byte buffers + a state index 0/1/2 | S/an.smali:52-67, 194-259 | +| Encryption | `Cipher.doFinal(data)` (ECB, no IV) | S/an.smali:260-270 | +| Sizing | one 28-byte buffer printed as "ESC SN" | S/an.smali:74-79, 365-375 | + +The three states are selected by `an.O0OOOOOO00OOOOO000O0(I)` +(set by `cm`/`dm`/`em`/`yl`: `S/cm.smali:68`, `S/dm.smali:56,151`, `S/em.smali:38`, +`S/yl.smali:56`) and consumed by `an.OO00000OOOOOOOO0000O([B,I)` +(`S/an.smali:194-270`), which is called only from `fn` and `gn` +(e.g. `S/fn.smali:282,471,585,904,…`; `S/gn.smali:323,515,635,…`). + +`fn` and `gn` are structurally identical (same five methods, same cryptor); the +difference is the reactive plumbing (`gn` additionally uses `lw0`/Completable, +`S/gn.smali:859`). Both override the register entry +(`S/fn.smali:1525`, `S/gn.smali:1827`). + +`fn`'s register method writes the 30-byte handshake reply into the cryptor's +primary chain buffer (`S/fn.smali:1584`, `S/gn.smali:1904`), matching +reports/05's finding that the reply to command **0x5B** on BLE address **0x21** is +30 bytes = 16-byte key + 14-byte UID. The four-argument constructor's token +(SHA-256(MAC)[0..16]) is stored at `S/fn.smali:59`; **it is never read again inside +`fn`** — the chained-AES key comes from the reply, not from the token (read). + +This is the family the fork calls the `0x5B`/`0x5C`/`0x5D` pairing: the app's +`an` states plus `fn` are that state machine (§8.2). + +--- + +## 5. Protocol version 2, ECDH family — Xiaomi "mible" (`kn`) + +### 5.1 Where the ECDH code lives + +`S/ln.smali:10-17` carries the Kotlin metadata +`c = "app.peretti.m365tools.model.XiaomiECDH$performHandshake$timedObservable$2$job$1"`, +and its only instantiator is `S/hm.smali:997`, whose `this$0` field is typed `kn` +(`S/hm.smali:39`, created at `S/rm.smali:83`). R8 therefore merged the class +`XiaomiECDH` **into `kn`**: the whole ECDH handshake is `S/hm.smali`, backed by +`kn`'s crypto helpers. `kn` is selected for model ordinals 5, 16, 17–21 when +protocol version == 2 and `ecdh_old != 1` (§2.2). + +### 5.2 Ordered handshake (read) + +Entry: `S/kn.smali:2689-2753` reads characteristic **00000004** and dispatches the +coroutine `hm`. + +``` + A. kn.O000000O000000000OO0()Z — S/kn.smali:146-222 (fast path / "login") + A0. read pref "devToken"+; absent → return false → full pairing runs. + A1. write "24000000" to char 00000010 :120 + A2. write "0000000b0100" to char 00000019; read 2 B (0.2 s) == "0101" :121-128 + A3. 16 random bytes; write "0100"‖rand16; read 2 B == "0100"; + read 4 B == "000d0100" :129-136 + A4. write "00000101"; read 16 B (2.0 s) = scooter random :137-140 + A5. write "00000100"; read 4 B == "000c0200" :141-146 + A6. HKDF(ikm = devToken, salt = rand16‖scooterRand16, + info = "mible-login-info", 64 B) :147 + → [0:16) sessionScooterKey [16:32) sessionLocalKey + [32:36) sessionScooterIV [36:40) sessionLocalIV :149-152 + A7. localHMAC = HMAC-SHA256(sessionLocalKey, rand16‖scooterRand16) :177-186 + scooterHMAC = HMAC-SHA256(sessionScooterKey, scooterRand16‖rand16) :187-196 + A8. write "00000101"; read 32 B (0.3 s) and compare with scooterHMAC :197-205 + MISMATCH → DELETE the "devToken" preference and return false :203 + A9. write "00000100", "0000000a0200"; read 2 B == "0101" :206-214 + A10.write "0100"‖localHMAC[0:18], "0200"‖localHMAC[18:32] :215-220 + A11.read 2 B == "0100" and 4 B == "21000000" :221 + Success → ready flag set (S/tp.smali:41, S/hm.smali:74-79). + + B. Full pairing (only if A returned false) — S/hm.smali:83-1531 + B1. KeyPairGenerator "EC" + ECGenParameterSpec("secp256r1"); generateKeyPair + :86-108 + B2. x = ECPoint.affineX.toByteArray() trimmed to 32 B (drop BigInteger sign + byte via copyOfRange(1..len)); same for y; clientPub = x‖y (64 B) :144-311 + B3. write "a2000000" :325-336 + B4. write "00000101"; read 4 B (0.2 s) ∈ {"00000200","00000100"} :338-393 + B5. write "0100"‖pub[0:18], "0200"‖pub[18:36], "0300"‖pub[36:54], + "0400"‖pub[54:64] :462-471 + B6. read 2 B (0.2 s) == "0100"; start the 500 ms ticker ln (S/ln.smali:87-159) + ; read 4 B (2.4 s) == "00030400" :473-475 + B7. write "00000101"; sleep 100 ms; read 64 B (0.1 s) = scooter x‖y :476-482 + B8. peerPub = 0x04‖those 64 B; length check 65; ECPublicKeySpec(pt, params) + from KeyFactory "EC"; KeyAgreement.getInstance("ECDH"); init(our priv); + doPhase(peerPub,true); generateSecret("ECDH") = 32 B shared secret + :492-515 + B9. HKDF(ikm = sharedSecret, salt = 16 zero bytes, + info = "mible-setup-info", 64 B) :517-519 + → [0:12) devToken [12:28) unused/login key [28:44) devID key + :520-522 + B10.AES-CCM(key = [28:44), nonce = 101112131415161718191a1b, + data = device-info blob (e.g. "blt.4.17"+0x00+11 random chars), + aad = "devID", tag arg = 0x20) :528-531 + B11.write "000000000200"; read 2 B (0.2 s) == "0101" :532-537 + B12.write "0100"‖ct[0:18], "0200"‖ct[18:] :538-543 + B13.read 2 B (0.3 s) == "0100" :544-546 + B14.write "13000000"; read 4 B (0.3 s) == "11000000" :547-551 + B15.STORE pref ("devToken"+suffix) = hex(devToken 12 B) :552 + B16.re-run A to verify; success → ready flag, else sleep 2000 ms and + report failure to the UI callback :553-561, 1590-1610 +``` + +Helpers: HKDF = `S/kn.smali:2490-2567` (`hkdf.extractAndExpand(salt, ikm, info, 64)` +via `uq`/HmacSHA256); AES-CCM encrypt = `S/kn.smali:1771-1844`; the +`read(len, seconds)` primitive = `S/kn.smali:2415-2488` (polls the receive buffer +`kn.OOOOO00O0O0OO00OOO0O`, returns `byte[0]` on timeout); frame builder = +`S/kn.smali:1046`; command send = `S/kn.smali:1846`. + +### 5.3 Session key material in the ECDH family + +| Derived item | Bytes | Role | Citation | +|---|---|---|---| +| `devToken` | HKDF[0:12] | persisted per device; sole input to the login HKDF | S/kn.smali:520,552 | +| devID key | HKDF[28:44] | AES-CCM key that encrypts the device-info blob | S/kn.smali:522,530 | +| (unused) | HKDF[12:28], [44:64] | logged but not used by this class | S/kn.smali:521,523-526 | +| sessionScooterKey / sessionLocalKey | login HKDF[0:16) / [16:32) | HMAC-SHA256 mutual auth | S/kn.smali:149-150 | +| sessionScooterIV / sessionLocalIV | login HKDF[32:36) / [36:40) | 4-byte IV component of the per-direction frame nonce | S/kn.smali:151-152 | + +--- + +## 6. Cryptographic parameters + +| Parameter | Value | Where | Confidence | +|---|---|---|---| +| Curve | NIST P-256 (`secp256r1`), via JCA `EC` | S/hm.smali:86-102 | read | +| Ephemeral keypair | generated per handshake, not stored | S/hm.smali:108 | read | +| Client public key wire form | 64 B = affine X ‖ Y, each trimmed to 32 B (leading sign byte removed); sent in four 18/18/18/10-byte chunks prefixed `0100`,`0200`,`0300`,`0400` | S/hm.smali:210-311, 462-471 | read | +| Peer public key wire form | 64 B read in one 64-byte read; `0x04` prepended locally → 65 B uncompressed point | S/hm.smali:481-499 | read | +| Key agreement | `KeyAgreement.getInstance("ECDH")`, `doPhase(peer,true)`, `generateSecret("ECDH")` | S/hm.smali:1208-1225 | read | +| Shared secret | 32 B (`sharedKey.encoded`) | S/hm.smali:1238 | read | +| KDF | HKDF-HMAC-SHA256, extract-and-expand, 64 B output | S/kn.smali:2490-2567 | read | +| KDF info (pairing) | `mible-setup-info` | S/hm.smali:1256 | read | +| KDF info (login) | `mible-login-info` | S/kn.smali:147 | read | +| KDF salt (pairing) | 16 zero bytes `00000000000000000000000000000000` | S/hm.smali:1246 | read | +| KDF salt (login) | `rand16 ‖ scooterRand16` (32 B) | S/kn.smali:147 | read | +| Session AEAD | `AES/CCM/NoPadding` | S/kn.smali:1787 | read | +| AEAD key length | 16 B used here (HKDF slices) | S/hm.smali:522, S/kn.smali:149-152 | read | +| AEAD tag length argument | `0x20` passed as `GCMParameterSpec(tLen, nonce)` tLen | S/kn.smali:1803-1807, S/hm.smali:1335 | read (value) / **inferred** (meaning) | +| AEAD tag length, effective | 4 bytes — if Conscrypt reads tLen in bits, 0x20 = 32 bits; the fork independently uses a 4-byte tag (§8.3) | S/kn.smali:1805 vs repo `ninebot-ble/src/mi_crypto.rs:14` | inferred | +| Pairing AEAD nonce | fixed `10 11 12 13 14 15 16 17 18 19 1A 1B` | S/hm.smali:1312 | read | +| Pairing AEAD AAD | ASCII `devID` | S/hm.smali:1325 | read | +| Frame AEAD nonce (per direction) | 4-byte IV from login HKDF (sessionScooterIV / sessionLocalIV) + a counter; counter width/increment not re-derived here | S/kn.smali:151-152 (IVs) | inferred | +| Legacy chained-AES cipher | `AES/ECB/NoPadding`, no IV | S/an.smali:124-143 | read | +| Legacy fixed key | `97CFB802844143DE56002B3B34780A5D` | S/an.smali:33 | read | +| Legacy per-state key | first 16 **ASCII hex characters** of `SHA-1(state32 ‖ fixedKey16)` | S/an.smali:106-122, S/jn.smali:111-152 | read | +| Legacy chain states | 3 × 32-byte buffers, index 0/1/2 | S/an.smali:52-67, 194-259 | read | +| Version-1 stream cipher | RC4 (KSA + PRGA), key 1–256 B | S/vp$OO00000OOOOOOOO0000O.smali:26-147,223-293 | read | +| Version-1 session key | `SHA-256(MAC)[0..12]` | S/po.smali:41-93, S/yn.smali:123-133 | read | +| Version-1 token inputs | `p2` = MAC with `:` removed, hex-decoded (6 B), reversed; `p3` = bytes read from char 00000002 | S/yn.smali:65-135, S/fo.smali:54 | read | +| AuthToken (16 B, static families) | `SHA-256()[0..15]` | S/po.smali:41-93 | read (input) / see §7.1 for the exact string form | +| Checksum | 16-bit one's-complement of the byte sum (`sum(data) & 0xffff`, XOR `0xffff`) | S/vm.smali:56-89 | read | + +--- + +## 7. Token and registration semantics + +### 7.1 `AuthToken` — the 16-byte static token + +* Produced exactly once, at `S/po.smali:93`, from + `SHA-256(App.OO0OOOOOOOO00000O0O0())`, hex-encoded and re-decoded, truncated to 16 B. +* `App.OO0OOOOOOOO00000O0O0()` returns the field `App.O00000OOO0O0O00O0O0O` + (`apktool-out/smali/app/peretti/m365tools/App.smali:280-292`), default `""` + (App.smali:84-91), written **only** by + `App$OO00000OOOOOOOO0000O.OO00O0O0O0000000000O(String)` + (`App$OO00000OOOOOOOO0000O.smali:379-391`) whose parameter is asserted under the + name **`macAddress`** (`:382`). +* Its two callers pass the connected device's MAC: + `S/ic.smali:371-379` (Kotlin assertion string `result.bleDevice.macAddress`) and + `S/nc.smali:75-84` (local name `mDeviceAddress`). The value originates in + `S/tn$OO00000OOOOOOOO0000O.smali:83-86` as `lb0.O00OOO0O00O00OOO0000()` = + `RxBleDevice.getMacAddress()` = `BluetoothDevice.getAddress()` + (implementation in `apktool-out/smali_classes2/`, e.g. `S/ac0.smali:91`). +* **No transformation** (no case change, no separator strip) happens between + `getAddress()` and the digest input, so the input is the platform form + `AA:BB:CC:DD:EE:FF`. *Inferred*: the app itself does not normalise, but the exact + byte string is unverified against hardware. +* Scope: it is **per device but not per pairing** — anyone who knows the MAC can + compute it. It is *not* stored anywhere (only recomputed) and *not* sent as such; + it is used as key material for `fn`/`gn` (§4) and as the RC4 key in `mn` (§3.5). +* In the ECDH family the analogous persisted secret is the 12-byte `devToken` + (preference key `"devToken" + suffix`, suffix `op.OO0O00O0OO00OOO00O00`, + S/kn.smali:552; deleted on auth mismatch at S/kn.smali:203). + +### 7.2 `SCOOTER_PIN_CODE` + +* The literal exists in the decrypted string table and appears **only** in + `S/gq.smali:22-24`, inside the preference-key holder class `gq` (whose getters + return keys such as `SCOOTER_LAST_MAC` at S/gq.smali:512, `AUTO_CONNECT` at + S/gq.smali:582, `NOTIFICATION_FLAGS` at S/gq.smali:493). It is part of the app's + preference-name inventory. +* **No handshake path reads it.** The version-1 handshake's token material comes + from the MAC and a value read from the scooter (char 00000002), not from a stored + PIN, and the ECDH path uses `devToken`. The PIN-facing UI strings + (`mPinCode`, `Pincode read from Preferences is:`, + `apktool-out/smali/app/peretti/m365tools/ScooterInformationActivity.smali:2578,6047`) + are in the activity layer. +* **Conclusion (read):** `SCOOTER_PIN_CODE` is not part of any authentication flow + analysed here; treating it as the protocol secret would be a mistake. + +### 7.3 What happens on a mismatch + +* ECDH login mismatch → the `devToken` preference is **deleted** + (`S/kn.smali:202-205`) and full pairing (button press) is required again. +* Version-1 mismatch → the state machine advances to state 2 and only then writes an + AuthToken (`S/io.smali:224-234`); there is no retry counter in `io` — the retry + helper is `op.OO00OOOOO00OO00OOOO0(Z)` (`S/op.smali:3767`). +* Nothing is persisted for `fn`/`gn`; their session lives entirely in the `an` + instance (`S/fn.smali:16-18`), so a failed chain simply restarts from state 0. + +--- + +## 8. What is skipped, and what is absent + +### 8.1 Plaintext / no-auth paths exist (read) + +`nn` and `bn` are three-argument transports with **no key material and no crypto +import**. They are selected for several model/version combinations, and in those +cases `po` sets the ready flag at selection time: `S/po.smali:248`(nn), +`:361`(nn), `:717`(nn), `:830`(nn), `:425`(bn), `:513`(bn), `:601`(bn). +The handshake block in `po` is never entered for them (§1 step 6c), so **those +models get no authentication whatsoever**, and telemetry starts immediately. + +### 8.2 The `0x5B`/`0x5C`/`0x5D` chained pairing is present (read) + +Yes — as `an` (NinebotSecureCryptor) + `fn`/`gn` + the `cm`/`dm`/`em`/`yl` helpers. +The decision to use it is the model-ordinal/protocol-version/flag combination of +§2.2, and the 30-byte reply to `0x5B` on BLE address `0x21` fills the state-0 chain +buffer (`S/fn.smali:1584`, `S/an.smali:52-67`). The app never uses literal +`0x5B`/`0x5C`/`0x5D` byte constants in the protocol classes — they arrive as part +of the frame built by `fn` (consistent with reports/05). + +### 8.3 There is no "Encryption2" + +No AES-CTR / CBC-MAC code exists in the app's protocol classes. The evidence is the +`Cipher.getInstance` census: the only strings under `dalvik/O/O/a/b/O/c/b/O/o/` are +`AES/CCM/NoPadding` (`S/kn.smali:1787`) and `AES/ECB/NoPadding` (`S/an.smali:124`); +`AES/CTR/...`, `AES/GCM/...`, `AES256_CMAC`, `AES128_CTR_HMAC_SHA256`, etc. occur +only inside `com/google/android/gms/internal/firebase-auth-api/**` (Google Tink) and +AndroidX (`apktool-out/smali_classes2/`), and the app's only AES primitive outside +`kn` is `an`'s ECB `doFinal` (`S/an.smali:260-270`), which is reached exclusively +from `fn`/`gn` (§4). The fork's `0x5A 0xA5` sync constant has no counterpart in the +app's handshake classes either. *Caveat:* this is an absence-of-evidence argument +from a call-site census; a hand-rolled CTR/CBC-MAC using `an`'s ECB block mode +cannot be excluded with certainty without tracing every `fn`/`gn` frame, which was +not done here. + +### 8.4 Skipped steps + +* No handshake for `nn`/`bn` models (§8.1). +* For protocol version 1, `po` does **not** call the session object's register entry + — the `if (version == 2)` guard at `S/po.smali:1358-1362` is the only caller, and + `vm.OOOOOOO0OOOOO0O00OO0` throws `NotImplementedException` by default + (`S/vm.smali:167-178`). +* The version-2 register entry is likewise never called for version 0. +* `kn`'s fast path skips the whole ECDH when a valid `devToken` exists + (`S/hm.smali:63-81` → `goto_a`) — the common case on reconnection. +* The app never sends a "which protocol do you speak?" probe before choosing; the + choice is made from remembered state only (§2.4). + +--- + +## 9. `decrypt2` is a false lead + +`apktool-out/smali/app/peretti/m365tools/MainActivity.smali:1421` loads the string +`decrypt2` as the **Intrinsics null-check message** for a +`new String(byte[], charset)` inside the **PiracyChecker** licence decode: +`PiracyChecker` is constructed at `MainActivity.smali:990-1005`, its callbacks are +installed at `:1011-1015`, and the base64 licence/signature blobs are decoded at +`:1394-1430` (the same basic block that contains `:1421`). It has no relationship to +BLE, the handshake, or any protocol cipher. Nothing in the app's protocol classes +references it. + +--- + +## 10. Comparison with M365-Rokid-HUD + +The fork is **ahead of this report** in two places and **behind** in one. Details: + +| Aspect | M365 Tools | Fork | Verdict | +|---|---|---|---| +| Xiaomi mible ECDH | P-256 ECDH + HKDF-SHA256 (`mible-setup-info`) + AES-CCM, token = HKDF[0:12], devID key = HKDF[28:44], AAD `devID`, nonce `101112131415161718191a1b` (S/hm.smali:1256-1335) | Identical: `mi_crypto.rs:14` `Ccm`, `:44-47` the same 12-byte nonce, `:49-62` AAD `devID`, `:64-79` both info strings, `:100-140` `token = derived_key[0..12]`, `a = derived_key[28..44]` | **Agreement, byte for byte** | +| mible login | `mible-login-info` HKDF → 2 keys + 2 × 4-byte IVs, HMAC-SHA256 both ways, devToken deleted on mismatch (S/kn.smali:146-222) | `mi_crypto.rs:140` `calc_login_did`, `LoginKeychain`, `hash()` HMAC-SHA256 (`:81-90`) | **Agreement** | +| CCM tag length | passes `0x20` as the flag's tLen (S/hm.smali:1335) | fixed 4-byte tag (`mi_crypto.rs:14` `U4`) | **Consistent only if 0x20 means 32 bits**; must be confirmed on hardware | +| Frame layout / checksum | checksum = one's complement of the 16-bit byte sum (`S/vm.smali:56-89`); counter/IV model consistent with 2-byte counter + 4-byte IV | `HEADER = [0x55,0xab]`, size byte, 2-byte LE counter, ciphertext+tag, crc16 (`mi_crypto.rs:205-298`); nonce = iv(4)‖0‖counter(2)‖0 (`:233-240`) | **Agreement** | +| Chained-AES NinebotSecureCryptor | `an`: fixed key `97CFB802844143DE56002B3B34780A5D`, `AES/ECB/NoPadding`, key = first 16 ASCII hex chars of SHA-1(state32‖fixedKey) (`S/an.smali:33,124,106-122`) | `ninebot_legacy.rs:52-56` `FW_DATA` = the same 16 bytes; `NinebotCryptoCipher.kt:117-135` `deriveSha1Key`; `:143-149` `aesEcbEncryptBlock`; `:47-71` SYNC `5A A5`, nonce tags `0x01`/`0x59` | **Agreement** | +| 0x5B/0x5C/0x5D state machine | `fn`/`gn` + `an` chain states 0/1/2, 30-byte 0x5B reply → state-0 buffer (`S/fn.smali:1584`) | `NinebotHandshake.kt:53-83` `ADDRESS_BLE 0x21`, `ACTION_PRE_COMM 0x5B`, `SET_PWD 0x5C`, `AUTH 0x5D`, reply length 30, `UID_OFFSET 16 / LEN 14`, `BLE_DATA_OFFSET 7 / LEN 16` | **Agreement** | +| RC4 version-1 flow (`io`/`vp`/`mn`) | present, with two magic commands `09acbf93` / `92ab54fa` and MAC-derived key (S/io.smali, S/vp$…, S/mn.smali:702-712) | **Not present** in the files listed for this comparison (no RC4 module, no `09acbf98`/`92ab54fa`) | **Fork gap** | +| Plaintext path | `nn`/`bn`: no crypto, handshake skipped, ready flag set at selection (S/po.smali:248,425) | `PlaintextRegisterSession.kt` exists, but it is a *Kotlin* register session with `source=0x3E`, `destination=0x20` and `FrameCodec` 55AA/5AA5 framing, i.e. it frames plaintext reads itself | **Different mechanism**: the app's plaintext classes reuse the same per-model frame builders, the fork uses a generic register codec | +| Family selection | model ordinal × protocol-version int × Firebase flags, per §2.2 | `ProtocolProbe.kt`/`ScooterModelRegistry.kt` decide at runtime (not reviewed here) | **Selection logic lives elsewhere in the fork**; the app's per-ordinal table is the missing artefact | +| `AuthToken` semantics | SHA-256(MAC)[0..15], per device, recomputed, never stored; RC4 key in `mn`, KDF input in `fn`/`gn` (S/po.smali:93) | `mi_crypto.rs`'s `AuthToken` is a **different** thing: 12 bytes, HKDF[0..12] from the ECDH secret (`mi_crypto.rs:90,100-140`) | **Name collision — do not merge these two concepts in the fork** | +| Encryption2 (AES-CTR + CBC-MAC) | absent from the app (§8.3) | implemented (`encryption2.rs:57-175`) | Fork exceeds the app — fine, but it is *not* derived from M365 Tools | + +Two differences worth acting on: + +1. The app's **version-1 RC4 family is unimplemented in the fork**, yet it is the + family the app uses for protocol version 1 devices. +2. The fork's `AuthToken` type name collides with the app's `AuthToken` string + (S/io.smali:285). They are unrelated values. + +--- + +## 11. Unverified / needs hardware + +No scooter and no phone are attached. **Nothing in this report has been confirmed +against real traffic.** Specifically unverified: + +1. **The exact MAC string fed to SHA-256.** The code path is read (`getAddress()` → + `App.OO0OOOOOOOO00000O0O0()`), but whether Android returns upper-case + colon-separated text on the target device, and whether any OEM/custom scan path + rewrites it, is untested. A wrong case or separator yields a completely + different `AuthToken` and a failed handshake. +2. **The AES-CCM tag length.** The app passes `0x20` into `GCMParameterSpec` + together with `AES/CCM/NoPadding` (S/kn.smali:1803-1807). Conscrypt's CCM + interpretation of that integer (bits vs bytes) is *inferred* to mean 32 bits = + 4-byte tag, matching the fork's `U4`. If it actually means 4 bytes, the two + implementations disagree by 12 bytes of tag. Only a live handshake can settle it. +3. **The per-frame nonce/counter layout for the `kn` session** is inferred from the + 4-byte IV fields (S/kn.smali:151-152) plus the fork's verified model. The app's + own frame builder for `kn` frames was not fully traced; the counter's increment + rule, its endianness, and whether responses use the scooter IV as assumed are + unverified. +4. **`ecdh_old`/`nbsecf` defaults.** The polarity (`== 1` → static-key transport) is + read; that Remote Config is empty on a stock install (making `kn` the default for + ordinals 5/16/17-21) is inferred from `getLong` semantics only. The live values + for these flags today are unknown. +5. **The model-enum ordinal → model-name mapping.** This report uses raw ordinals + (2, 5, 14, 16, 17-21, 23); which scooter each ordinal denotes is AREA 1's + deliverable and is not asserted here. +6. **`fn` vs `gn` equivalence.** They are structurally identical and both use `an`; + whether they differ in wire behaviour (timeouts, retry, MTU, frame ordering) was + not established, so "same protocol, different reactive wrapper" is an inference. +7. **The 0x5B handshake's exact byte sequence** is taken from reports/05 and the + fork's Kotlin, not re-derived here; the cryptor's role (which chain state is used + in which pairing step) is read only at the level of "state 0 is filled from the + 0x5B reply". +8. **`mn`'s per-frame RC4 restart** is read from the code shape (a fresh cipher + object inside the frame builder) but its observable consequence (keystream reuse + on the wire) is not confirmed. +9. **The 00000014/00000002/00000001 characteristic semantics for version 1** are + read as "read → read → read/write handshake"; what the scooter actually returns + and whether 00000002 is a per-device seed is unknown. + +Static-analysis-only scope: **cryptographic constants and protocol structure are +facts and may be reimplemented freely; the app's source, assets and string tables +are not to be copied.** No code from `app.peretti.m365tools` was reproduced here — +only constants, byte layouts, orderings and control-flow facts. diff --git a/doc/reverse-engineering/m365tools-reports/04-register-maps.md b/doc/reverse-engineering/m365tools-reports/04-register-maps.md new file mode 100644 index 0000000..86525a6 --- /dev/null +++ b/doc/reverse-engineering/m365tools-reports/04-register-maps.md @@ -0,0 +1,578 @@ +# AREA 4 — Per-model register / field maps (M365 Tools 1.8.0, `app.peretti.m365tools`) + +Static analysis only. No scooter, no phone. All citations are relative to +`/home/kali/ScooterHacking/re/m365tools/`; `apktool-out/smali/…` is the source of +record. `jadx-out-nodeobf/` is cited only where the Java is materially easier to +read and the smali was checked to agree. `jadx-out/` is never cited. + +--- + +## 0. Executive summary + +1. **`zm` is not the M365 model list.** It is the *Ninebot/Segway* model enum + (UNKNOWN, ONE, MINI, MINI_MAX, MARK2, ESCOOTER, VIO, KART, …, T15, + F_Series30, F_Series60 — 30 constants). Each constant carries a **marketing + name** as its third constructor argument, and that is where `M365`, + `M365PRO`, `M365_1S`, `M365_Lite`, `M365_3`, `M365_PRO2` live. + `zm.smali:1643-1719` (constructor), `zm.smali:152-1514` (``), + `zm$*.smali` (one file per constant that needs a composed payload). +2. There are **four** register buffers, not two: `mDataBytes` (ESC/CTL), + `mBLEDataBytes` (BLE), `mDataBytesBattery` (BMS) and a fourth, unnamed + buffer selected for board type `xm.BMS2` (second/external pack). + All four are `byte[0x200]` (`iq.smali:305-341`). +3. **The unit is the decisive fact.** A reply to "read register R" is stored at + `buffer[R * 2]`, so **buffer byte offset = register address × 2**; each + register owns a 2-byte little-endian slot. Register space is therefore + `0x00..0xFF` and the buffers are 256 × u16 slots. This is *read from the + code*, three independent ways (see §2). +4. Only two commands fill a buffer: `READ` (id `1`) and `RESPONSE_READING` + (id `4`). Anything else is logged to Crashlytics and dropped. +5. The classic **Xiaomi** protocol is codec `mn`, an RC4-encrypted `55 AB` + frame; `M365 Tools` selects it for the models whose display name is `M365`, + `M365PRO`, `M365_1S`, `M365_1S_DE`, `M365_Lite`, `M365_PRO2`, `M365_3` — but + **only when the scanned protocol version is 1** (`po.smali:611-712`). +6. There is **no explicit "second battery present" flag** in the parser. The + external pack is a second `ym` holder whose values are *added* to the main + BMS values (`iq.smali:1729-1733`, `iq.java` line 623). Presence is implied + only by the model's board list containing an `xm.BMS2` entry. + +--- + +## 1. The model enum `zm` — what each constant actually carries + +Constructor (real, 9 args) `zm.smali:1643`: +`(String name, int ordinal, String s, int i2, int i3, ArrayList displayNames, ArrayList boards, ArrayList groups, ArrayList extras)`. +The synthetic 10-arg form at `zm.smali:1516` wraps a bare `String` into the +`displayNames` list; the flags int defaults the `pn` list to `null`. + +Only **`i3`** (the 5th argument) is stored — `zm.smali:1697` `iput p5 → OO00OOOOOO000O0OO00O`. +`i2` (4th argument) is compiled in but never read anywhere in `zm`. The +`displayNames` list is only ever *filled* (`zm.smali:1705-1707`); nothing reads +it back in this class. + +| # | constant name | marketing name (`M365 Tools` UI) | i2 | **i3 (model id)** | citation | +|---|---|---|---|---|---| +| 0 | `UNKNOWN` | `Unknown` | 0 | 0 | `zm.smali:158-170,443` | +| 1 | `ONE` | `One` | 2 | 2 | `zm.smali:450-518` | +| 2 | `MINI` | `Mini` | 3 | 3 | `zm$OO00OOOOO00OO00OOOO0.smali:26-31,175` | +| 3 | `MINI_MAX` | `MiniMax` | 35 | 24 | `zm$O000000O000000000OO0.smali:175` | +| 4 | `MARK2` | `Mark2` | 4 | 19 | `zm$OO0OOOOOOOO00000O0O0.smali:175` | +| 5 | **`ESCOOTER`** | **`M365`** | 5 | **32** | `zm$OOOOOOO0OOOOO0O00OO0.smali:26-31,162-175` | +| 6 | `VIO` | — | 6 | 20 | `zm$OOOOO0000OO0O00000O0.smali` | +| 7 | `KART` | `Kart` | 7 | 48 | `zm$OO00O0O0O0000000000O.smali:70` | +| 8 | `KART_PRO` | `KartPro` | 72 | 49 | `zm$OO0O0OOO00O00OOO0000.smali:57` | +| 9 | `KART_PRO_LAMBO` | `KartProLambo` | 9 | ? | `zm.smali:585` | +| 10 | `NANO` | `MiniLite` | 32 | 22 | `zm$OO0OO000O000OOO0O00O.smali:134` | +| 11 | `MINI_KIDS_MI` | `MiniKidsMi` | 11 | ? | `zm.smali` | +| 12 | `MINI_KIDS` | `MiniKids` | 12 | ? | `zm.smali` | +| 13 | `MARK3` | `Z` | 42 | 18 | `zm$O0O000OOOO0000OOOO0O.smali:162` | +| 14 | `SCOOTER2` | — | 52 | 33 | `zm$OO00OOOOOO000O0OO00O.smali:273` | +| 15 | `SCOOTER2_P` | — | 55 | 39 | `zm$OOOOO00O0O0OO00OOO0O.smali:288` | +| 16 | `MI_SCOOTER_PRO` | **`M365PRO`** | 53 | 34 | `zm$O00OO0OOO0O0OOO0OO0O.smali:175` | +| 17 | `MI_SCOOTER_1S_DE` | **`M365_1S_DE`** | 37 | 37 | `zm$OO00OO0O0OO0O00000OO.smali:210` | +| 18 | `MI_SCOOTER_LITE` | **`M365_Lite`** | 41 | 41 | `zm$OOOOOOO00OOO0O00OOOO.smali:210` | +| 19 | `MI_SCOOTER_1S` | **`M365_1S`** | 43 | 43 | `zm$O000OO0OO0OOO00OO000.smali:210` | +| 20 | `MI_SCOOTER_PRO2` | **`M365_PRO2`** | 40 | 40 | `zm$OOO00O0OO00OOO0OOOO0.smali:210` | +| 21 | `MI_SCOOTER_3` | **`M365_3`** | 46 | 46 | `zm$O00OO00OOOOOO0OOOOO00O.smali:210` | +| 22 | `WILD_STEEL_DUST` | — | ? | ? | `zm$OOOO00O0OOO0OOOOO000.smali` | +| 23 | `SCOOTER_G30` | — | ? | ? | `zm$OO0O00O0OO00OOO00O00.smali` | +| 24 | `STEELDUST` | `Steeldust` | ? | ? | `zm.smali:1099-1117` | +| 25 | `MEBIKE` | `Mebike` | ? | ? | `zm.smali:1140-1148` | +| 26 | `SCOOTER_AIR` | — | ? | ? | `zm.smali:1179` | +| 27 | `T15` | — | 521 | 44 | `zm$O0OOOOOO00OOOOO000O0.smali:222` | +| 28 | `F_Series30` | — | 521 | 45 | `zm$O00OOO0O00O00OOO0000.smali:222` | +| 29 | `F_Series60` | — | ? | ? | `zm.smali:1377` | + +*Ordinal = order of `sput-object` in `` (`zm.smali:445,520,535,550,…`), +cross-checked against each subclass' `sput-object` target field.* +Confidence: **read** for the constant names, the marketing names of the seven +M365 ids, i2/i3 of the rows cited to a `zm$*.smali` file, and ordinal order. +Confidence: **inferred** for `?` rows (my extractor could not resolve the +register holding the literal; those rows are not load-bearing for this report). + +The predicate `zm.OO0OOOOOOOO00000O0O0()` (`zm.smali:1944-2036`) is +`String.valueOf(i3) ∈ {"32","34","37","40","41","43","46"}` — i.e. **"is this a +Xiaomi M365-series model"**. It is used **only in the UI** +(`ScooterInformationActivity.smali:1210,4292`), never in parsing. + +`zm.O00OOO0R0O00OOO0000(int)` (`zm.smali:1752-1817`) resolves a **board id** to a +`wm`: it matches `wm.mID` **or** `wm.mReceiveID`; `62 (0x3E)` synthesises +`wm(xm.HOST,62,62)`; anything else yields `wm(xm.UNKNOWN,0,0)`. +`wm` = `(xm mType, byte mID, byte mReceiveID)` (`wm.smali:7-13,27-31`). + +`xm` (`xm.smali:65-303`, decompiled at `jadx-out-nodeobf/.../xm.java`), ordinals: +`NONE=0, CTL=1, SUB_CTL=2, BLE=3, BMS=4, BMS2=5, BMS3=6, DRIVER=7, AHRS=8, UPC=9, +ANCHOR=10, DBOARD=11, MOTOR1=12, MOTOR2=13, TAG=14, PTZ=15, KART=16, MCU=17, +BFG=18, ECU=19, HOST=20, UNKNOWN=21`. + +--- + +## 2. The four buffers, the command that fills them, and **the units** + +### 2.1 The buffers + +Allocated in `iq.` (`iq.smali:305-341`), all `byte[0x200]`: + +| field | real name (from assert messages) | board (`xm`) that targets it | citation | +|---|---|---|---| +| `OOO000O00000OO000O0O` | **`mDataBytes`** | `CTL` (ord. 1) *and* the default branch | assert `iq.smali:1511`; write `iq.smali:1085,1163` | +| `O00O00O00O000OO0O0OO` | **`mBLEDataBytes`** | `BLE` (ord. 3) | assert `iq.smali:1396`; write `iq.smali:1133` | +| `O0O000O0O0OOO0OO00O0` | **`mDataBytesBattery`** | `BMS` (ord. 4) | assert `iq.smali:2783`; write `iq.smali:1117` | +| `O0000OOO0R0OOOOO0O0O` | *(not named in any assert)* — `mDataBytesBattery2` **inferred** | `BMS2` (ord. 5) | write `iq.smali:1101` | +| `OO00OOOOO00OO00OOOO0`, `O000000O000000000OO0`, `O000OO0OO0OOO00OO000` | three 4-byte fragments of `mDataBytes` | — | `iq.smali:2049-2100` | + +The assert messages are the app's own text and use `…` (U+2026) as a literal +abbreviation, e.g. `copyOfRange(mScooter.mDa…ytes, 0x10 * 2, 0x17 * 2)` is the +full plaintext; it is not a tool truncation. + +### 2.2 Which command fills a buffer + +`iq.OOOOOOO00OOO0O00OOOO(um)` (`iq.smali:867`) accepts a reply only when +`um.command == READ (0x01)` or `um.command == RESPONSE_READING (0x04)` +(`iq.smali:930-947`; ids from `BaseCommand$O0OOOOOO00OOOOO000O0.smali:78,120,335`). +Every other command produces a Crashlytics log and is discarded +(`iq.smali:961-1015`). `um` is `(model, toModel, command, register, payload)` +(`um.smali:27-56`); its `toString` is `"%s%s%s: %02X @%02X %s"` — command then +register (`um.smali:141`). + +### 2.3 Units — this is the load-bearing part + +The write is +```java +wrap.get(dest, umVar.register * 2, umVar.payload.length); // iq.smali:1085/1101/1117/1133/1163 +``` +i.e. **payload byte *k* of the reply to register *R* lands at `buffer[2R + k]`.** + +Three independent confirmations that the unit is *2 bytes per register address*: + +1. The developer's own assert text is compiled to the doubled value: + `copyOfRange(mDataBytes, 0x10 * 2, 0x17 * 2)` ↔ `Arrays.copyOfRange(…, 0x20, 0x2E)` + (`iq.smali:1509-1511` vs `iq.smali:1501-1505`), and + `copyOfRange(mDataBytes, 0xDA * 2, 0xDA * 2 + 0x02 * 2)` ↔ + `Arrays.copyOfRange(…, 0x1B4, 0x1B8)` (`iq.smali:2053-2055` vs `iq.smali:2045-2049`). +2. The buffers are exactly `0x200` = 256 × u16 — the register address is a single + byte on the wire (`mn.smali:322-336` reads it as `v1[2]`). +3. M365 Tools' ESC temperature lives at `byte 124 = 2 × 0x3E`, and two + *independent* codebases put a `/10` temperature at register `0x3E`: + this app at `iq.smali:1754-1763`, and the fork at + `/home/kali/ScooterHacking/repo/app/src/main/java/com/m365bleapp/protocol/EscTelemetryParser.kt:40-41,112-113`. + The same agreement holds for `0x25` (remaining mileage), `0x7C` (cruise == 1) + and `0x7D` bit 1 (tail light) — see §4. + +So every table below has **two** offset columns: the buffer byte offset (what the +code uses) and the register address (offset ÷ 2). + +### 2.4 Re-parse semantics (a real behavioural trap) + +`iq.OOOOOOO00OOO0O00OOOO` parses **all four buffers on every notification** +(`iq.smali:1386-3230`; the method has no early return once past the command +gate). It is a singleton (`iq.smali:20,134`). Consequences: +fields extracted from the BMS section **overwrite** the same field extracted +from the ESC section, and stale data from a buffer that is not being refreshed +persists silently. There is **no length validation per field** — the only guards +are the `assert` statements (which are disabled in a release build) and one +model-conditional re-read (§4.3). + +--- + +## 3. The protocol codecs and the model → codec dispatch + +Six codec classes extend `vm`; each is a `(sender, receiver)` pair: +`bn`, `fn`, `gn`, `kn`, `mn`, `nn` +(`apktool-out/smali/dalvik/O/O/a/b/O/c/b/O/o/{bn,fn,gn,kn,mn,nn}.smali`). + +`po.O00OOO0R0O00OOO0000(Object)` (`po.smali:36`) is the factory. It switches on +`App.O00OO00OOOOOO0OOO00O().ordinal()` (the selected `zm` constant) at +`po.smali:111-140`, then on `App.O000OO0OO0OOO00OO000()` (a protocol-version int +read from the advertisement, `App.smali:139-151`) and on a Firebase RemoteConfig +flag, and stores the codec in `op.OO0O0O0OO0OOO0OO0000`. + +| model ordinal(s) | version == 2 & RC flag | version == 2 | version == 1 | otherwise | +|---|---|---|---|---| +| 2 (`MINI`) | `fn` (`po.smali:749`) | `gn` (`:767`) | `mn` (`:797`) | `nn` (`:815`) | +| 5 (**`ESCOOTER` / "M365"**) | `kn` (`:638`) | `gn` (`:654`) | **`mn`** (`:684`) | `nn` (`:702`) | +| 16 (`MI_SCOOTER_PRO` / "M365PRO") | `kn` (`:282`) | `gn` (`:298`) | `mn` (`:328`) | `nn` (`:346`) | +| 17–21 (`M365_1S_DE`, `M365_Lite`, `M365_1S`, `M365_PRO2`, `M365_3`) | `kn` (`:194`) | `kn` | `mn` (`:217`) | `nn` (`:235`) | +| 14 (`SCOOTER2`), 23 (`SCOOTER_G30`) | `fn` (`:462`/`:550`) | `gn` (`:480`/`:568`) | — | `bn` (`:503`/`:591`) | +| everything else | `fn` (`:169`) | `gn` (`:367`) | `mn` (`:397`) | `bn` (`:415`) | + +RemoteConfig keys: `nbsecft` (`po.smali:154`) on the ordinal-2/5/16/23 paths, +`nbsecf` (`po.smali:447,535,734`) on the ordinal-14/23/default paths, and +`ecdh_old` (`po.smali:267,623`) on the M365/M365PRO paths. + +Confidence: **read** for the class per branch and the ordinal set; the branch +table was extracted from the `if-eq`/`packed-switch` chain at `po.smali:126-140` +plus the `:pswitch_data_0` block at `po.smali:1459-1466` +(`packed-switch 0x10` → 16:`pswitch_1`, 17..21:`pswitch_0`). + +### 3.1 Frame layout per codec (what the buffers are filled from) + +`mn` — the classic Xiaomi protocol, and the one that matters for the only +available hardware: + +| direction | bytes | meaning | citation | +|---|---|---|---| +| TX | `0x55 0xAB` | magic | `mn.smali:768,773` | +| TX | `len` | `data.length + 2` | `mn.java:190,204` | +| TX | RC4(body) | body is `toModel, cmd, register, data…, FF FF FF FF` | `mn.java:192-203`, `mn.smali:230-249` | +| TX | u16 LE | checksum | `mn.java:217-222` | +| RX | `peek[2 : len-2]` | CRC16 range | `mn.java:59` | +| RX | u16 LE `peek[len-2 : len]` | checksum | `mn.java:60-63` | +| RX | RC4 of `peek[3 : len-2]` | decipher key = **SHA-256(connected MAC string) truncated to 16 bytes** | `mn.java:69`, `po.smali:52-93`, `App.smali:139-151` | +| RX → `um` | `decoded[0]`→buffer chooser, `decoded[1]`→command, `decoded[2]`→**register**, `decoded[3 : len-4]`→payload | | `mn.smali:320-336` | + +Buffer chooser in `mn` (`mn.smali:251-308`, table at `:367-375`): + +| `decoded[0]` | `um(model,toModel,…)` | `zm.O00OOO0R0O00OOO0000` resolves to | buffer | +|---|---|---|---| +| `0x01` | `(1, 62)` | no match → `xm.UNKNOWN` | **`mDataBytes`** (default branch) | +| `0x20`,`0x21`,`0x22` | `(62, 32/33/34)` | `wm(xm.HOST,62,62)` | **`mDataBytes`** (default branch) | +| `0x23` | `(32, 62)` | `wm(CTL,32,35)` → `xm.CTL` | **`mDataBytes`** | +| `0x24` | `(33, 62)` | `wm(BLE,33,36)` → `xm.BLE` | **`mBLEDataBytes`** | +| `0x25` | `(34, 62)` | `wm(BMS,34,37)` → `xm.BMS` | **`mDataBytesBattery`** | + +(The `0x20/0x21/0x22` family carries the board in `toModel`, but `iq` looks the +board up from `model`, which is `62 (HOST)` for those — so they all land in +`mDataBytes`. This is read from the code; whether it is *intended* is not +knowable statically.) + +The RC4 cipher is standard: KSA + PRGA (identity permutation mixed with the +key repeated to fill 256 bytes; output byte = `S[(S[i]+S[j]) & 0xFF] ^ in[i]`), +key length 1..256 (`vp$OO00000OOOOOOOO0000O.smali:26-148`, PRGA at `:223-260`). + +Other codecs, `um(...)` arguments read directly: + +| codec | TX magic | RX → `um(model, toModel, command, register, payload)` | citation | +|---|---|---|---| +| `bn` | `0x5A 0xA5` (`bn.smali:373,378`) | `um(p[3], p[4], p[5], p[6], p[7 : len-2])` | `bn.java:56` | +| `fn` | `0x5A 0xA5` (`fn.smali:1241,1246`) | `um(p[1], p[2], p[3], p[4], p[5 : len])` | `fn.smali:702-722` | +| `gn` | `0x5A 0xA5` (`gn.smali:1523,1528`) | `um(p[1], p[2], p[3], p[4], p[5 : len])` | `gn.smali:776-797` | +| `mn` | `0x55 0xAB` (`mn.smali:768,773`) | see above | `mn.smali:320-336` | +| `nn` | `0x55 0xAA` (`nn.smali:450,455`) | `um(p[3], p[4], p[5], **0x3E**, p[6 : len-2])` | `nn.smali:237-264` | +| `kn` | not established | `um(v17, v18, p[v12], p[v11], p[v13 : len-v14])` with `v17 ∈ {0x21,1,0}`, `v18 ∈ {0x20..0x23}` | `kn.smali:1614-1690` | + +`nn` additionally requires the hex form of the frame to start with `"55aa"` +(`nn.smali:104,166`) and rejects `cmd==0x65 && register==0x1A` +(`nn.smali:271-279`). `kn` shares that rejection (`kn.smali:1700-1707`). + +`kn` is the only codec with a byte-field constructor and crypto helpers +(`kn.smali:19-56, 810, 2415, 2490, 2569`); its frame body is **UNRESOLVED** here. + +### 3.2 Checksums and validation — what the app checks + +* **Checksum** = one's complement of the 16-bit sum of the bytes: + `sum(bytes) & 0xFFFF` then `XOR 0xFFFF`, written **little-endian** + (`vm.smali:56-89`; `vm.java` `OO00000OOOOOOOO0000O([B)S`). This is the classic + M365 checksum. +* `bn`, `mn`, `nn` verify it on receive: they compare the computed value against + the trailing LE u16 and **loop until it matches or the timeout expires** + (`bn.java:49-55`, `mn.java:59-65`, `nn.smali:199-225`), throwing + `TimeoutException` / `"Wrong Header"` (`nn.smali:293,307`) on failure. +* `mn` has the string `"Checksum missmatch"` (`mn.smali:223`) on the mismatch + path. +* `fn` and `gn` **do not** appear to verify a checksum on receive: their payload + runs to `len`, not `len-2` (`fn.smali:702-722`, `gn.smali:776-797`). +* **No length validation** is applied to the register payload before parsing. + The only structural guard is the `cmd==0x65 && reg==0x1A` drop in `kn`/`nn`. +* The `assert` messages quoted throughout this report are compiled `assert` + statements, i.e. **inert in a release build** — they document intent, they do + not enforce it. + +--- + +## 4. Field map per buffer + +Colour key: **R** = read directly from the code (offset and width), **I** = the +*identity* of the field is inferred (from `ScooterDAO`'s named constructor, from +cross-agreement with the fork's Scootbatt-derived table, or from the app's own +getters). + +### 4.1 `mDataBytes` — ESC / `xm.CTL` (and the default branch) + +Filled by: a reply whose model byte resolves to `xm.CTL` (ordinal 1) or to +anything that is not `BLE`/`BMS`/`BMS2`, at `register × 2`. + +| reg | buffer off | len | encoding | value | conf | citation | +|---|---|---|---|---|---|---| +| `0x10`–`0x16` | 32–46 | 14 | ASCII (`UTF-8`) | **serial number** (`O0O000OOOO0000OOOO0O`) | R/I | `iq.smali:1499-1539`; assert `:1509-1511` | +| `0x17`–`0x19` | 46–52 | 6 | ASCII | ESC firmware version string — built in `iq` but **discarded** there; used by `ScooterInformationActivity` | R/I | `iq.smali:1544-1548`; assert `:1552-1554`; `ScooterInformationActivity.smali:2589-2615`; assert `ScooterInformationActivity.smali:2599` | +| `0x1A` | 52 | 2 | u16 LE → `String.format("%04x")` | **ESC firmware version** (`O00OO00OOOOOO0OOO00O`) | R | `iq.smali:1583-1622`; format `iq.smali:1588` | +| `0x1B` | 54 | 2 | u16 LE | `O000O0000O00OOOO00O0` | R (offset) / UNRESOLVED (name) | `iq.smali:1625-1629` | +| `0x1C` | 56 | 2 | u16 LE | `OO0O0OO00000O0O00O00` | R / UNRESOLVED | `iq.smali:1632-1636` | +| `0x32`–`0x33` | 100 | 4 | u32 LE | `OO00O0O0O0000000000O` | R / UNRESOLVED | `iq.smali:1641-1651` | +| `0x34`–`0x35` | 104 | 4 | u32 LE | `OO0O0OOO00O00OOO0000` | R / UNRESOLVED | `iq.smali:1656-1661` | +| `0x25` | 74 | 2 | u16 LE via `getChar()` | **remaining range** (`OO0OO000O000OOO0O00O`, `ScooterDAO.RemainingMileage`) | R/I | `iq.smali:1666-1675`; `ScooterDAO.smali:124` | +| `0x26` | 76 | 2 | u16 LE / 10.0 → float | `OOOOOO00O00000OOO0OO`; the speed signal used by the cruise/limit rounding on "forza-like" models | R/I | `iq.smali:1678-1701`, `:2209-2235` | +| `0x2F` | 94 | 2 | u16 LE | `OO0O0O0OO0OOO0OO0000` | R / UNRESOLVED | `iq.smali:1706-1715` | +| `0x3B` | 118 | 2 | u16 LE | `OOOO0OO00OO0O0O00000`; re-read from the same offset if zero | R / UNRESOLVED | `iq.smali:1720-1748` | +| `0x3E` | 124 | 2 | u16 LE / 10.0 | **ESC temperature °C** on "forza-like" models | R/I | `iq.smali:1754-1763`, branch `:2159-2160`; fork `EscTelemetryParser.kt:40-41,112-113` | +| `0x47` | 142 | 8 | 4 × u16 LE (all discarded) | — | R | `iq.smali:1768-1780` | +| `0x68` | 208 | 2 | u16 LE → `"%04x"` | ESC "code" (`OOO00O0OO00OOO0OOOO0`) and int `OOOOOOO00OOO0O00OOOO` | R | `iq.smali:1787-1800`, `:1840` | +| `0x7B` | 246 | 2 | u16 LE | KERS setting (`OOOOO00O0O0OO00OOO0O`) | R/I | `iq.smali:1840-1863`; fork `EscTelemetryParser.kt:173-177` | +| `0x7C` | 248 | 2 | u16 LE, `== 1` | **cruise control engaged** (`OO00OOOOOO000O0OO00O`) | R/I | `iq.smali:1875-1890`; fork `EscTelemetryParser.kt:195-196` | +| `0x7D` | 250 | 2 | u16 LE bitfield | status (`O00OOO0O00O00OOO0000`); **bit 1** = tail light always on (`iq.O00OO00OOOOOO0OOO00O()`) | R/I | `iq.smali:1875-1890`; accessor `iq.smali:547-566`; fork `EscTelemetryParser.kt:225-228` | +| `0x72` | 228 | 2 | u16 LE | `OOOOO0000OO0O00000O0` | R / UNRESOLVED | `iq.smali:1890-1905` | +| `0x74` | 232 | 2 | u16 LE → `(short)(x/10)*10` | `OOOO00O0OOO0OOOOO000` | R / UNRESOLVED | `iq.smali:1905-1949` | +| `0x75` | 234 | 2 | u16 LE | **ride mode** (`OO0O00O0OO00OOO00O00`) | R/I | `iq.smali:1954-1964`; fork `EscTelemetryParser.kt:150-154` | +| `0x80` | 256 | 2 | u16 LE | `OOOOOOO0OOOOO0O00OO0` | R / UNRESOLVED | `iq.smali:1966-1978` | +| `0xB5`–`0xB6` | 362 | 4 | u32 LE | **total mileage** on "forza-like" models | R/I | `iq.smali:1980-1990` | +| `0xB0` | 352 | 2 | u16 LE | `O0OOOO0O000000OO0OO0` (overwritten at `0xB9`) | R | `iq.smali:1994-2004`, `:2324-2328` | +| `0xB1` | 354 | 2 | u16 LE | `OOO0O0OO0000O0O0000O` | R / UNRESOLVED | `iq.smali:2009-2013` | +| `0xB2` | 356 | 2 | u16 LE | `OO0OOOO00OO0O0OOO00O` (overwritten at `0xBA`) | R | `iq.smali:2018-2022`, `:2333-2337` | +| `0xB3` | 358 | 2 | u16 LE | discarded | R | `iq.smali:2027` | +| `0xB4` | 360 | 2 | u16 LE | **battery percentage** (`O00000OO0OOOOO00OOOO`) — ESC estimate | R/I | `iq.smali:2034-2038` | +| `0xDA`,`0xDC`,`0xDE` | 436,440,444 | 4 each | raw | three fragments concatenated by `iq.O0OOOOOO00OOOOO000O0()` into a 12-byte blob; `iq.OO00OO0O0OO0O00000OO()` returns true iff any byte ≠ 0 | R | `iq.smali:2043-2100`; asserts `:2055,2076,2097`; accessors `iq.smali:610-651`, `:742-813` | +| `0xB5` | 362 | 2 | u16 LE | **speed**, raw. Sign-fixed: `if (v < -4000) v &= 0xFFFF`; `if model supports rn.O0O000OO0OOO0OO000O0: v *= 100`. `O0OO000O00O0O00O0O0O = v / 1000.0f` km/h | R/I | `iq.smali:2104-2160`, `:2200-2235` | +| `0xB7`–`0xB8` | 366 | 4 | u32 LE | **total mileage / odometer** (`O0OO000O0000M0O00O00`, `ScooterDAO.TotalMileage`) for non-"forza-like" models | R/I | `iq.smali:2312-2319`; `ScooterDAO.smali:127` | +| `0xB9` | 370 | 2 | u16 LE | `O0OOOO0O000000OO0OO0` | R / UNRESOLVED | `iq.smali:2324-2328` | +| `0xBA` | 372 | 2 | u16 LE | `OO0OOOOOOOO00000O0O0` | R / UNRESOLVED | `iq.smali:2333-2337` | +| `0xBB` | 374 | 2 | u16 LE / 10.0 | **ESC temperature °C** on non-"forza-like" models | R/I | `iq.smali:2342-2346`, `:2126-2160` | + +The "forza-like" predicate is `App.OO00O0R0O0000OO00OO0 ∈ {127,128,129,131,124,125,141}` +(`iq.java:153-157`) — a *different* id space from `zm`'s `i3`, taken from the +advertisement. Which real model each of those seven ids is was **not** +established. + +### 4.2 `mBLEDataBytes` — `xm.BLE` + +| reg | buffer off | len | encoding | value | conf | citation | +|---|---|---|---|---|---|---| +| `0x08`–`0x0B` | 16–24 | 8 | ASCII | BLE firmware version (`O0OO0O00O0OOOO0OO0OO`) | R/I | `iq.smali:1386-1424`; assert `:1396` | +| `0x14` | 40 | 2 | u16 LE | `OO0O0O0OOO0OOO00OO00` | R / UNRESOLVED | `iq.smali:1443` | +| `0x15` | 42 | 2 | u16 LE | `O0000O00OO00OOOO0000` | R / UNRESOLVED | `iq.smali:1455` | +| `0x16` | 44 | 2 | u16 LE | `O00000OOO0R0O00O0O0O` | R / UNRESOLVED | `iq.java:338` | +| `0x18` | 48 | 2 | u16 LE | `OO00O0O0O0000OO00OO0` | R / UNRESOLVED | `iq.smali:1474` | + +### 4.3 `mDataBytesBattery` — `xm.BMS` (main pack) + +| reg | buffer off | len | encoding | value | conf | citation | +|---|---|---|---|---|---|---| +| `0x10`–`0x16` | 32–46 | 14 | ASCII | **BMS serial** (`OO00OO0O0OO0O00000OO`) | R/I | `iq.smali:2763-2793`; assert `:2783` | +| `0x17` | 46 | 2 | u16 LE → `"%04x"` | **BMS firmware version** (`O00OO0OOO0O0OOO0OO0O`) | R | `iq.smali:2800-2845` | +| `0x67` | 206 | 2 | u16 LE → `"%04x"` | BMS firmware version, **re-read from `mDataBytes`** when the `0x17` value equals the literal `"0000"` | R | `iq.smali:2860` (compare), `:2954-2993` (re-read) | +| `0x1B` | 54 | 2 | u16 LE | `OOO0O0OO0O00O00O00O0` | R / UNRESOLVED | `iq.smali:2997-3009` | +| `0x1C` | 56 | 2 | u16 LE | `OOOOO00OOO0O00OO0O0O` | R / UNRESOLVED | `iq.smali:3012-3021` | +| `0x1D` | 58 | 2 | u16 LE | `O00O0O000O0000OO0OOO` | R / UNRESOLVED | `iq.smali:3024-3030` | +| `0x20` | 64 | 2 | packed date | day = `v & 0x1F`, month = `(v >> 5) & 0xF`, year = `(v >> 9) & 0x7F` → `SimpleDateFormat("dd/MM/yyyy")` with `/20` prefix → **production date** | R | `iq.smali:3033-3113`; formats `:2525,2547` | +| `0x31` | 98 | 2 | u16 LE | **BMS remaining capacity** (`O00O0O000OOO0000O0O0`), added to the external pack's capacity by `ScooterDAO` arg 5 | R/I | `iq.smali:3116-3125`; `iq.java:623`; `ScooterDAO.smali:118-121` | +| `0x32` | 100 | 2 | u16 LE | **battery percentage** (`O00000OO0OOOOO00OOOO`) — this value wins over the ESC one (§2.4) | R/I | `iq.smali:3128-3134` | +| `0x33` | 102 | 2 | u16 LE | **current** (`OOOOOOOOOO00O0OOO0OO`), summed with the external pack | R/I | `iq.smali:3137-3144`; sum `iq.smali:591-608` | +| `0x34` | 104 | 2 | u16 LE | **voltage** (`O0O0OO00OOOOOOO0O0OO`, `ScooterDAO.BatteryVoltage`) | R/I | `iq.smali:3145-3152`; `ScooterDAO.smali:113-116` | +| `0x35` | 106 | 1 | i8 − 20 | **BMS temperature °C** (`OO0O00OO000000000OO0`, `ScooterDAO.BatteryTemperature`) | R/I | `iq.smali:3157-3165` | +| `0x36` | 107 | 1 | i8 − 20 | second sensor (`O0OO00000OO000000O00`) | R/I | `iq.smali:3170-3178` | +| `0x3B` | 118 | 2 | u16 LE | `OO0OOO00O0O00O00OO00` | R / UNRESOLVED | `iq.smali:3183-3192` | +| `0x40`… | 128 | 30 | 15 × u16 LE | **cell voltages** (`OOOO00OOO0OO000OOO0O[15]`) | R/I | `iq.smali:3197-3223`; array `iq.smali:305-310` | + +### 4.4 the fourth buffer (`xm.BMS2`) — external / second pack + +Parsed into the `ym` holder (`iq.smali:85`, `ym.smali`), same offsets as the BMS +block. **The values are added to the main pack's, there is no presence flag.** + +| reg | buffer off | len | encoding | value | conf | citation | +|---|---|---|---|---|---|---| +| `0x10`–`0x16` | 32–46 | 14 | ASCII | external-pack serial (`ym.OO00OOOOO00OO00OOOO0`) | R/I | `iq.smali:2395-2400` | +| `0x17` | 46 | 2 | u16 LE → `"%04x"` | external-pack firmware version (`ym.O000000O000000000OO0`) | R | `iq.smali:2413-2470` | +| `0x18` | 48 | 2 | u16 LE | `ym.OOOOOOO00OOO0O00OOOO` + `iq.OOO0O0OO0O00O00O00O0` | R / UNRESOLVED | `iq.smali:2503` | +| `0x1B`,`0x1C` | 54,56 | 2 | u16 LE | `ym.OO00O0O0O0000000000O`, `ym.O00OOO0O00O00OOO0000` | R / UNRESOLVED | `iq.smali:2470-2500` | +| `0x20` | 64 | 2 | packed date | → `ym.OO00OO0O0OO0O00000OO` | R | `iq.smali:2503-2540` | +| `0x31` | 98 | 2 | u16 LE | capacity, added to the main pack's (`ym.OO0O0OOO00O00OOO0000`) | R/I | `iq.smali:2597-2610`; `iq.java:623` | +| `0x32` | 100 | 2 | u16 LE | percentage (`ym.OO00000OOOOOOOO0000O`) — **not** used for the reported percentage | R | `iq.smali:2597-2610` | +| `0x33` | 102 | 2 | u16 LE | current, added to the main pack's (`ym.O000OO0OO0OOO00OO000`) | R/I | `iq.smali:2597-2610`; sum `iq.smali:591-608` | +| `0x34` | 104 | 2 | u16 LE | voltage (`ym.OO0OOOOOOOO00000O0O0`) | R | `iq.smali:2597-2610` | +| `0x35`,`0x36` | 106,107 | 1 | i8 − 20 | `ym.O0OOOOOO00OOOOO000O0`, `ym.O0O000OOOO0000OOOO0O` | R | `iq.smali:2597-2610` | +| `0x3B` | 118 | 2 | u16 LE | `ym.OOOOOO0OOOOO0O00OO00` | R / UNRESOLVED | `iq.smali:2692` | +| `0x40`… | 128 | 30 | 15 × u16 LE | cell voltages (`ym.O00OO00OOOOOO0OOO00O[15]`) | R/I | `iq.smali:2711-2740` | + +### 4.5 Where these values end up (the naming evidence) + +`iq` writes a `ScooterDAO` row at most every 300 ms when battery % > 0 +(`iq.smali:3230-3299`). `ScooterDAO` is an ObjectBox entity whose field names are +**plain, unencrypted strings**, and its constructor `(S I S S S C I S S)` maps +arguments to names in order: + +| arg | type | field | fed from | +|---|---|---|---| +| 1 | short | `Ampere` | `BMS 0x33 + BMS2 0x33` (`iq.smali:3276`) | +| 2 | int | `Speed` | `iq.OO0O0OOO00O00OOO0000()` (`iq.smali:3281`) | +| 3 | short | `BatteryPercentage` | BMS `0x32` (or ESC `0xB4`) (`iq.smali:3286`) | +| 4 | short | `BatteryVoltage` | BMS `0x34` (`iq.smali:3289`) | +| 5 | short | `BatteryCapacity` | `BMS 0x31 + BMS2 0x31` (`iq.smali:3292-3298`) | +| 6 | char | `RemainingMileage` | ESC `0x25` (`iq.smali:3292`) | +| 7 | int | `TotalMileage` | ESC `0xB7` (or `0xB5` on forza-like) (`iq.smali:3281` path) | +| 8 | short | `BatteryTemperature` | BMS `0x35` (`iq.smali`) | +| 9 | short | `ESCTemperature` | `0x3E` or `0xBB` / 10 (`iq.smali`) | + +Names read from `ScooterDAO.smali:12-32`; order read from `ScooterDAO.smali:92-144` +(`iput` sequence) and `iq.smali:3273-3330`. + +**Trip distance and trip time are not in any of the four buffers.** They live in +a separate `0x3A` "trip info" reply handled outside `iq` (`ScooterRepository` +in the fork reads `0x3A` for trip data; in M365 Tools the equivalent path is the +`Trip` entity, not `iq`). This report found **no** offset for trip distance or +trip time in `iq.smali` — treat them as **not established here**. + +--- + +## 5. Serial number, second battery, and model dependence — direct answers + +* **Serial number**: ESC serial = `mDataBytes[32..46)` = registers `0x10`–`0x16`, + 14 ASCII bytes (`iq.smali:1499-1539`). Battery serial = + `mDataBytesBattery[32..46)` (`iq.smali:2763-2793`). External-pack serial = + buffer 4 `[32..46)` (`iq.smali:2395-2400`). BLE has no serial in the parsed + offsets. +* **Second battery**: yes — buffer 4, board type `xm.BMS2`, parsed into `ym`. + **Detection is structural, not value-based**: `zm`'s board list for the model + must contain a `wm` whose type is `xm.BMS2`, and then the payload's + `decoded[0]` must resolve to it (`iq.smali:1093-1103`). Models whose board list + has an `xm.BMS2` entry: `MARK2` (ord. 4), `MARK3` (13), `SCOOTER2` (14), + `SCOOTER2_P` (15), `MI_SCOOTER_1S` (19) — `zm.java` static block lines 117, + 235, 250, 265, 373. There is **no** `hasSecondBattery` boolean anywhere in the + parse path, and no check that the pack is non-zero before the values are added. +* **Model-dependent parsing branches actually in `iq`:** + 1. `App.OO00O0R0O0000OO00OO0 ∈ {124,125,127,128,129,131,141}` — "forza-like": + speed source (`0x26/10 ×1000` vs `0xB5/1000`), odometer source + (`0xB5` u32 vs `0xB7` u32), ESC temperature source (`0x3E` vs `0xBB`). + `iq.java:153-157, 183-233, 452-483`. + 2. `zm.OO0O0OOO00O00OOO0000(rn.O0O000OO0OOO0OO000O0)` — multiplies raw speed by 100 + when the model's `qn` list declares that register group (`iq.java:443-445`). + 3. `App.O0000O00OO00OOOO0000 == 1 || escCode ∈ {"0081","0100"}` + (`iq.java:159-162`, strings at `iq.smali:511,525`) — used for + `iq.O000OO0OO0OOO00OO000()`. + 4. The BMS-version `"0000"` fallback (§4.3). + 5. Buffer selection by board type (§2.1, §3.1) — the only branch keyed on the + `zm`/`xm` enums that changes *which bytes are read*. + +--- + +## 6. Comparison with the fork (M365-Rokid-HUD) + +Fork sources read: `app/src/main/java/com/m365bleapp/protocol/EscTelemetryParser.kt`, +`.../BmsTelemetryParser.kt`, `.../PlaintextTelemetryMapper.kt`, +`.../repository/MotorInfoParser.kt`, `.../protocol/ScooterModelRegistry.kt`, +`ninebot-ble/src/model/mod.rs`. + +**Where the two agree** (this is the strongest evidence in this report, because +the fork's values come from a *different* app, Scootbatt 1.9.2): + +| quantity | M365 Tools | fork | note | +|---|---|---|---| +| register addressing | `buffer[reg*2]`, payload byte *k* = register *R+k* | `payload[k]` of the reply to register `R` | **same convention**, different container | +| remaining range | `0x25`, u16 | `0x25`, u16/100 (Java) / u16/10 (Rust) | fork `EscTelemetryParser.kt:34-35,101`; `ninebot-ble/src/model/mod.rs:406` | +| ESC temperature | `0x3E`, u16/10 (forza-like); `0xBB`, u16/10 (others) | `0x3E`, i16/10; byte 22 = `0xBB` | fork `:40-41,112-113`; `MotorInfoParser.kt:57` | +| cruise engaged | `0x7C`, `== 1` | `0x7C`, `== 1` | fork `:195-196` | +| tail light always on | `0x7D` bit 1 | `0x7D` bit 1 | fork `:225-228` | +| ride mode | `0x75`, u16 | `0x75`, u16 | fork `:150-154` | +| KERS | `0x7B`, u16 | `0x7B`, u16 | fork `:173-177` | +| speed | `0xB5`, u16, `/1000` km/h for the plain M365 | `0xB5` at payload byte 10, `/1000` | fork `:28-32,87-89` | +| battery % | `0xB4`, u16 (ESC) / `0x32` (BMS) | payload byte 8 of the `0xB0` read | `MotorInfoParser.kt:53` | +| odometer | `0xB7`, u32, `/1000` | payload byte 14, u32 `/1000` | `MotorInfoParser.kt:61` | +| checksum | `~sum(bytes) & 0xFFFF`, LE | `FrameCodec` | — | +| M365 board addresses | `CTL mID 0x20 / mReceiveID 0x23`, `BLE 0x21/0x24`, `BMS 0x22/0x25` | `XIAOMI_BOARDS` = ESC `0x20/0x23`, BMS `0x22/0x25` | `zm$OOOOOOO0OOOOO0O00OO0.smali:46-82`; `ninebot-ble/src/model/mod.rs:294-297` | + +**Where they disagree — flag for the parent:** + +1. **M365 Tools resolves the fork's own flagged `0xB0` offset discrepancy — in + favour of the fork's *Java* parser.** The fork records in + `ninebot-ble/src/model/mod.rs:353-385` that the shipped parser "disagrees by + exactly 2 bytes" and that only a capture can settle it. M365 Tools settles it + statically, because it reads the same registers through the `×2` map: + + | field | M365 Tools register (byte off = 2×reg) | payload byte in a `0xB0` read | fork Java `MotorInfoParser` | fork Rust `M365` `FieldSpec.offset` | + |---|---|---|---|---| + | battery % | `0xB4` (`iq.smali:2034-2038`, off 360) | **8** | `getShort(8)` (`:53`) | `4` (`mod.rs:400`) | + | speed | `0xB5` (`iq.smali:2104-2160`, off 362) | **10** | `getShort(10)` (`:88`) | `6` (`mod.rs:401`) | + | avg speed | `0xB6` (`iq.smali:2308`, off 364 — read then discarded) | **12** | `getShort(12)` (`:60`) | `8` (`mod.rs:402`) | + | odometer | `0xB7`–`0xB8` u32 (`iq.smali:2312-2319`, off 366) | **14** | `getInt(14)` (`:61`) | `10` (`mod.rs:403`) | + | temperature | `0xBB` (`iq.smali:2342-2346`, off 374) | **22** | `getShort(22)` (`:57`) | `18` (`mod.rs:404`) | + + Five for five: M365 Tools puts battery, speed, average speed, odometer and + temperature at payload bytes **8, 10, 12, 14, 22** of a 32-byte `0xB0` read — + exactly the fork's Java indices, and exactly 4 payload bytes (2 registers) + above the Rust profile's `offset` values. The Rust `FieldSpec.offset` is used + as a **byte index into the payload** + (`ninebot-ble/src/model/mod.rs:208-213, 258-259`), so on this evidence the + `M365` profile's offsets should be `8, 10, 12, 14, 22`, and the "2-byte + shift" note understates the gap by half. Confidence: **the five register + identities are read from M365 Tools and agree with the fork's Java parser and + with the fork's Rust doc-comment `0xB0`-block listing**; what remains + unproven is which of the two parsers a real M365 actually feeds — but the + two now agree with each other, which removes the fork's stated ambiguity. +2. **Remaining range scaling.** M365 Tools reads `0x25` as a bare `char`/u16 + (`iq.smali:1666-1675`) with no division in the parser; the fork divides by + 100 in Java (`EscTelemetryParser.kt:34-35,101`) and by **10** in Rust + (`ninebot-ble/src/model/mod.rs:406`, `U16Scaled(10.0)`). The fork's two + scalings disagree with each other; M365 Tools does not disambiguate (the + scaling is applied later, in the UI, which this report did not chase). +3. **Firmware version registers.** M365 Tools reads `%04x` of a **u16 at + `0x1A`** (ESC, `iq.smali:1583-1622`) and a **u16 at `0xBMS 0x17`** (BMS, + `iq.smali:2800-2845`), plus a separate 6-byte ASCII string at `0x17`–`0x19` + used by `ScooterInformationActivity.smali:2589-2615`. The fork's + `firmwareVersions` reads three bytes at `0x66` + (`EscTelemetryParser.kt:232-253`). Different registers for the same idea — + worth reconciling against a capture. + +**Models M365 Tools can talk to that the fork currently withholds.** +The fork's `ScooterModel` enum has 15 members +(`ScooterModelRegistry.kt:112-250`); six have real capabilities (M365, M365_PRO, +M365_PRO2, MI_1S, MI_LITE and — per the Rust table — the same Xiaomi layout), +`MI3` and the eight Ninebot/Segway families are `ModelCapabilities.NONE` +(`ScooterModelRegistry.kt:90-101,214-247`). + +| fork entry | fork state | what M365 Tools has | what the fork would need | +|---|---|---|---| +| `MI3` (`Mi3`) | `NONE` (`:214-219`) | `zm` ordinal 21, marketing name `M365_3`, model id `46`; codec `kn`/`mn`/`nn`; buffers identical to the other M365 ids | one capture on a Mi 3 to confirm which codec the advertisement's `protocol_version` selects; the register offsets are already shared with the M365 profile | +| `NINEBOT_T15` (`NinebotT15`) | `NONE` (default, `:231`) | `zm` ordinal 27, id `44`, codec `fn`/`gn`/`mn`/`bn` | a capture **and** the `fn`/`gn`/`bn` frame layouts, which are only partly recovered here | +| `NINEBOT_MAX_G30` | `NONE` (`:228`) | generic paths only; `zm` id space has no G30-specific constant, it falls to the default branch | the advertisement's `protocol_version` + `scooter_type` values for a G30, then a capture | +| `NINEBOT_ESX` | `NONE` (`:227`) | generic paths only | same | +| `NINEBOT_E_SERIES`, `NINEBOT_F_SERIES`, `NINEBOT_MAX_G2`, `NINEBOT_F2`, `NINEBOT_D_SERIES` | `NONE` (`:229-247`) | `F_Series30`/`F_Series60` (ord. 28/29) and `SCOOTER_G30` (23) are named in `zm`, no field map recovered | the four-buffer model means the fork must first adopt the "which board answers" concept before any per-model map is useful | +| **`M365_1S_DE`** (ord. 17) | *not present at all* as a separate fork entry — `MI_1S` covers it | distinct `zm` constant, id `37`, one of the seven "Xiaomi M365-series" ids | a one-line addition to the registry; shares the M365 register block | + +The single largest structural gap is not a register: it is that **M365 Tools +keys its parse on the answering board** (`ESC` / `BLE` / `BMS` / `BMS2`) and +keeps four separate 512-byte buffers, whereas the fork keys on the polled +register and treats each reply payload as self-contained. Any Ninebot-family +support has to reproduce the board→buffer step first +(`iq.smali:1060-1170`), because a Ninebot reply's *register byte alone* does not +say which subsystem it belongs to. + +--- + +## 7. Unverified / needs hardware + +Everything below is **not** confirmed and must not be implemented as fact. + +1. **Any Ninebot / Segway conclusion.** `kn`, `fn`, `gn`, `bn` frame layouts are + only partly recovered; `kn`'s frame body and `fn`/`gn`'s checksum behaviour + are marked UNRESOLVED above. Only a Xiaomi M365 can ever be tested here. +2. **The `zm` ordinals ≥ 22.** `WILD_STEEL_DUST`, `SCOOTER_G30`, `STEELDUST`, + `MEBIKE`, `SCOOTER_AIR`, `T15`, `F_Series30`, `F_Series60` — their ids, + board lists and codec choice are read, but the field semantics are not. +3. **The seven "forza-like" `scooter_type` ids** `{124,125,127,128,129,131,141}`. + Which models they are was not established, so *which* branch a given real + scooter takes is unknown. This changes speed, odometer and temperature + sources — a wrong branch silently produces plausible numbers. **Do not + implement either branch on the strength of this report alone.** +4. **Every "R / UNRESOLVED" row** in §4 — the offset and width are read from the + code, the *meaning* is not. There are 25 such rows; they are named by their + field symbol only, deliberately. +5. **The unit claim.** `buffer[R*2]` is read from the code and cross-validated at + `0x3E`, `0x25`, `0x7C`, `0x7D`, `0xB5`, `0xB7`; it is not confirmed by a + capture. If it were wrong, every offset in §4 would be wrong by a factor of + two — which is exactly the failure mode that is worth one hardware session. +6. **The `0xB0`-block base.** M365 Tools' five-register agreement (§6.1) is + strong but is *still* an agreement between two static sources, not a capture. + One 32-byte `0xB0` reply from the M365 at `C7:B8:DC:3B:A1:B2` decides it, and + that single capture also validates the `×2` mapping for the whole ESC block. +7. **Trip distance / trip time** — no offset found in `iq`; the values are + presumably carried by a different reply that this report did not locate. +8. **RC4 key derivation.** The key is `SHA-256()[0..16)` + (`po.smali:52-93`), but the exact string form (case, separators) was not + confirmed, and it is recomputed per connection. +9. **The `0x20/0x21/0x22` `mn` family** lands in `mDataBytes` because `iq` looks + the board up from `model` (=62) rather than `toModel`. Read from the code; + whether that is intended, or a bug the app gets away with, is unknown. +10. **`mDataBytesBattery2`** is my own name for `O0000OOO0R0OOOOO0O0O`; the app + never names it in an assert message. + +Clean-room note: this report contains offsets, register addresses, enum names and +algorithm structure only. Nothing here is a copy of the app's code or tables. diff --git a/doc/reverse-engineering/m365tools-reports/05-write-commands.md b/doc/reverse-engineering/m365tools-reports/05-write-commands.md new file mode 100644 index 0000000..190c2c7 --- /dev/null +++ b/doc/reverse-engineering/m365tools-reports/05-write-commands.md @@ -0,0 +1,391 @@ +# AREA 5 — M365 Tools 1.8.0: write / control commands and their safety gating + +Target: `app.peretti.m365tools` 1.8.0 (versionCode 180), sha256 +`4451a47c86339b8f88d77bdad7aef77e1bc4f11b7c728a5803f2b1d342a16872`. + +All smali paths are relative to `/home/kali/ScooterHacking/re/m365tools/apktool-out/smali/`. +"read" = literal instruction/constant in the DEX; "inferred" = a conclusion drawn from +that evidence, stated as such. **No scooter and no phone were attached.** See the +*Unverified / needs hardware* section at the end. + +--- + +## 0. Correction to `CONTEXT.md` (important) + +`CONTEXT.md:88` states *"The app does not use Nordic UART. No `6e4000xx` UUID appears +anywhere."* **This is wrong.** The UUIDs are string-encrypted, so plain `grep` cannot see +them, but the decryptor recovers them: + +| Constant | Plaintext | Where | +|---|---|---| +| `op->OO00000OOOOOOOO0000O` | `6e400002-b5a3-f393-e0a9-e50e24dcca9e` (NUS **TX**, write) | `dalvik/O/O/a/b/O/c/b/O/o/op.smali:140` | +| `op->OOOOOOO0OOOOO0O00OO0` | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (NUS **RX**, notify) | `dalvik/O/O/a/b/O/c/b/O/o/op.smali:149` | + +The write characteristic is NUS TX at every transport's actual ATT write: +`nn.smali:508`, `bn.smali:435`, `mn.smali:842`, `fn.smali:1292`, `gn.smali:974`, +`kn.smali:2364`. `po.smali:1290` subscribes to NUS RX. The Xiaomi +`0000fe95-…` service is used for **scan filtering only**, and a second family of +characteristics (`00000013/14/19-…`, `00000001/02/04/10-…`, `op.smali:158–222`) is used +by the `kn`/`io` handshake paths. + +--- + +## 1. The frame envelope for a write + +### 1.1 The logical message + +Every command is one `um` record — `from`, `to`, `cmd`, `register`, `payload` +(`dalvik/O/O/a/b/O/o/um.smali:15–23`, constructor `um.smali:27–57`). Its `toString` is +`"%s%s%s: %02X @%02X %s"` = `from→to: reg @cmd payloadhex` (`um.smali:141`). + +`from`/`to` are the `um$OO00000OOOOOOOO0000O` address enum (`um$OO00000OOOOOOOO0000O.smali:54–132`): + +| Address | Value | Symbol | +|---|---|---| +| MOTOR | `0x01` | `um$…smali:60` | +| ESC | `0x20` | `um$…smali:70` | +| BLE | `0x21` | `um$…smali:82` | +| BMS | `0x22` | `um$…smali:94` | +| EXTBMS | `0x23` | `um$…smali:106` | +| BMS_NBSEC | `0x25` | `um$…smali:118` | +| HOST | `0x3E` | `um$…smali:130` | + +`cmd` is the `BaseCommand$O0OOOOOO00OOOOO000O0` enum (read, decryptor keys +`-0x1183…`…`-0x120f…`, constants at `BaseCommand$O0OOOOOO00OOOOO000O0.smali:64–261`): +`READ=1`, `WRITE=2`, `WRITE_NR=3`, `RESPONSE_READING=4`, `RESPONSE_WRITING=5`, +`AUTHORIZATION=0xA0`, `EXT_READ=0xA1`, `EXT_Write=0xA2`, `EXT_LOADREGS=0xA3`, +`EXT_SAVEREGS=0xA4`, `EXT_GETPROFILE=0xA6`, `EXT_GETINFO=0xB0`, `ERROR=0xFF`. +**The two command bytes that matter for this report are `0x01` (read) and `0x02` (write).** + +### 1.2 There *is* a checksum, and the app always sends it + +`vm.OO00000OOOOOOOO0000O([B)S` (`dalvik/O/O/a/b/O/o/vm.smali:56–89`) is a one's-complement +16-bit sum: + +``` +sum = 0; for b in bytes: sum += (b & 0xFF) +return (short)(sum & 0xFFFF) ^ 0xFFFF +``` + +It is appended **little-endian** as the last two bytes of every outbound frame +(`nn.smali:402–425`, `bn.smali:319–348`). The checksum covers the **length byte and the +whole body**, but *not* the two magic header bytes — this is visible in the code order: +the body is accumulated into the buffer, checksummed, then the two magic bytes are +prepended (`nn.smali:450–466`, `bn.smali:373–389`). + +### 1.3 Three distinct wire framings + +| Transport | Frame | Evidence | +|---|---|---| +| **`nn`** (plaintext) | `55 AA \| len(payload+2) \| to \| cmd \| reg \| payload \| cksum16LE` | `nn.smali:336–458` (magic `nn.smali:450,455`; len `nn.smali:352–359`; body order `nn.smali:362–384`) | +| **`bn`** (plaintext) | `5A A5 \| len(payload) \| from \| to \| cmd \| reg \| payload \| cksum16LE` | `bn.smali:260–389` (magic `bn.smali:373,378`) | +| **`mn`** (encrypted) | `55 AB \| len(payload+6) \| AES(inner) \| cksum16LE`, where `inner = from \| cmd \| reg \| payload \| FF FF FF FF` | `mn.smali:522–780` (inner `mn.smali:528–614`, pad `mn.smali:601–614`, encrypt `mn.smali:702–712`, magic `mn.smali:768,773`) | +| **`fn`, `gn`** | `5A A5` family | `fn.smali:1241,1246`; `gn.smali:1523,1528` | +| **`kn`** | no `55`/`5A` magic; uses `00000013`/`00000019` characteristics and a length-prefixed AES path (`kn.smali:1771–1845, 2490–2626`) | read | + +Note the len field asymmetry: `nn` counts `payload+2`, `bn` counts `payload`, +`mn` counts `payload+6`. **A reimplementation that reuses one length rule across +transports will produce frames the scooter rejects.** + +### 1.4 Response checksum *is* verified + +`nn.OO0OOOOOOOO00000O0O0([B)Z` (`nn.smali:539–594`) recomputes the one's-complement sum +over `bytes[2 .. len-2]` and compares against the trailing little-endian word. +`mn.smali:223` carries the literal `Checksum missmatch`; `nn.smali:293` carries +`Wrong Header` (it compares the first two bytes against `55aa`, `nn.smali:104,166`). + +### 1.5 BLE write mechanics + +Every transport writes in **20-byte (`0x14`) chunks** through +`kb0.O0OOOOOO00OOOOO000O0(UUID, byte[])`, clearing its RX queue first +(`nn.smali:485–533`, `bn.smali:408–457`, `mn.smali:822–864`). A whole command is +serialised into one buffer and then sliced, so the 20-byte chunking is *not* +command-aware — a long payload is split mid-frame. + +--- + +## 2. Command inventory + +Registers/commands come from the `BaseCommand$…` builders. `cmd` and `reg` are the +3rd and 4th `um` fields described above. + +### 2.1 Writes + +| # | Function | `from` | `to` | `cmd` | `reg` | Payload | Model gate | Requires auth | Conf. | +|---|---|---|---|---|---|---|---|---|---| +| W1 | **Scooter lock / unlock** | `0x3E` | model `CTL` addr | `0x02` | **`0x70` lock / `0x71` unlock** | 1 byte `{0x01}` | none seen | no | read (`op.smali:4280,4282,4842–4888`) | +| W2 | **KERS level** (weak/med/strong) | `0x3E` | `CTL` | `0x02` | **`0x7B`** | 2 bytes, **little-endian** short | `rn.KERS_MODE` | no | read (`op.smali:2238–2383`; `iq.smali`× via `tk.smali:66`,`uk.smali:58`) | +| W3 | **Cruise control on/off** | `0x3E` | `CTL` | `0x02` | **`0x7C`** | 1 byte `{0x00∣0x01}` | `rn.CRUISE_CONTROL` | no | read (`op.smali:1604,1851–1877`) | +| W4 | **Tail light ("Light set")** | `0x3E` | `CTL` | `0x02` | **`0x7D`** | 2 bytes LE short | `rn.BACK_LED` | no | read (`op.smali:5103,5360–5382`) | +| W5 | **Units km/h ↔ mph** | `0x3E` | `CTL` | `0x02` | **`0x7D`** | 2 bytes LE short | `rn.SPEED_UNIT` (not observed gating) | no | read (`op.smali:3823,3971`) | +| W6 | **Speed limit (all / eco)** | `0x3E` | `ESC` | `0x02` | caller-supplied byte | 2 bytes LE short | `rn.SPEED_LIMIT_ALL`, `rn.SPEED_LIMIT_ECO` | no | read (`op.smali:2110–2196`, gate `ScooterBasicInformationFragment.smali:6546,6602`) | +| W7 | **Dash/throttle display mode** | `0x3E` | `ESC` | **`0x03` (WRITE_NR)** | caller-supplied byte | 2 bytes LE short | — | no | read (`op.smali:2041–2053`) | +| W8 | **Bluetooth name** | `0x3E` | BLE `0x21` | **`0x50`** | `0x00` | name bytes (`String.getBytes()`) | `rn.SET_BT_NAME` | no | read (`ck.smali:222–230`; builder `BaseCommand$OOOOOOO0OOOOO0O00OO0.smali:18–37` — `cmd=0x50`, `reg=0`, **`z=false`**) | +| W9 | **"Atmosphere"/ambient light** | `0x3E` | BLE addr | `0x02` | caller-supplied | byte[] | `rn.ATMOSTHERE_LIGHT` | no | inferred (`ScooterBasicInformationFragment.smali:6509`; builder generic) | +| W10 | **LED colour + mode (Kart)** | `0x3E` | `CTL` | `0x02` | **`0xC6`, `0xC8`, `0xCA`, `0xCC`** | 1–2 bytes; `0xCC`/`0xCA`/`0xC8` take `{0xFF, mode}` | `zm` model subclass only | no | read (`op.smali:5740–5828`) | +| W11 | **Magic serial write (ESC, 12 B)** | `0x3E` | model addr | `0x58` | `0x00` | `LE32(a) ‖ LE32(b)` | — | needs `WriteSNException` handling | read (`BaseCommand.java`-equivalent `BaseCommand$O00OOO0O00O00OOO0000.smali:31`; used `ScooterInformationActivity$OO0OOOOOOOO00000O0O0.smali:484`) | +| W12 | **Magic serial write (14 B)** | `0x3E` | model addr | `0x59` | `0x00` | `LE32(a) ‖ LE32(b) ‖ LE16(c)` | — | same | read (`BaseCommand$OO00000OOOOOOOO0000O.smali`, used `ScooterInformationActivity$…:469`) | +| W13 | **BMS serial write** | `0x3E` | BMS `0x22` | `0x5C` | `0x00` | caller byte[] | — | no | read (`dm.smali:59–86`, `BaseCommand$O0O000OOOO0000OOOO0O` ctor) | +| W14 | **BLE module config** | `0x3E` | BLE `0x21` | `0x5D` | `0x00` | empty | — | no | read (`cm.smali:71`, `em.smali:41`) | +| W15 | **Extended write** (used for BT name, W8) | `0x3E` | enum addr | `0x50` | `0x00` | caller byte[] | — | no | read (`ck.smali:222`, `BaseCommand$OOOOOOO0OOOOO0O00OO0.smali:18–37`) | +| W16 | **Handshake response** | `0x3E` | BLE `0x21` | `0x5B` | `0x00` | empty (reply carries 30 bytes) | — | yes — it *is* the auth step | read (`BaseCommand$OO0OOOOOOOO00000O0O0.smali:180`, used `fn.smali:1587`) | + +Notable **non**-writes: `BaseCommand$OO0O0OOO00O00OOO0000` is `cmd=1` (read) at `to=0x21` +with the literal `NinebotGetDeviceName` (`BaseCommand$OO0O0OOO00O00OOO0000.smali:79,163`); +`BaseCommand$OO00OOOOO00OO00OOOO0` is `cmd=1` (`BaseCommand$OO00OOOOO00OO00OOOO0.smali:147`). +`ui.smali:75–442` is a **read-polling queue** (registers `0x10,0x15,0x18,0x1C,0x20,0x22, +0x25,0x2C,0x30,0x67,0x72`), not a write list. + +### 2.2 "Magic serial" semantics + +`WriteSNException` (`BaseCommand$WriteSNException.smali`) is thrown by W11/W12 when the +scooter's reply is not `payload==empty && reg==1`, or when the reply *does* carry a +payload — i.e. the app treats a non-empty reply to a serial write as a hard failure, not +a soft one (`BaseCommand$O00OOO0O00O00OOO0000.smali:144–225`). The whole command aborts. + +### 2.3 Risk notes (what the app itself says, and does not say) + +* **W11/W12 magic serial.** Irreversible in practice. The app's only visible handling is + the `WriteSNException` path; it carries **no user-facing warning string** that could be + located in the DEX. The exception text is built from a decrypted template that includes + the attempted serial (`BaseCommand$…:44`). +* **Region change (`rn.CHANGE_REGION`).** The capability exists as a model feature + (`rn.smali` key `-0x…` → `CHANGE_REGION`, and it is present in the `M365_1S`, + `M365_3`, `M365_1S_DE`, `M365_PRO2`, `M365_Lite`, `MAX`, `ESx`, `ESx_P`, `F` + feature lists) but **no register number for it is reachable in this DEX** — the write + builder for it was not located. Withheld rather than guessed. +* **Speed limit (W6).** Written as a plain 16-bit little-endian short with no range + clamp visible at the call site (`op.smali:2110–2196`). The app shows no confirmation + dialog on that path. +* **Lock (W1).** The UI entry point logs `Toggle ScooterLock clicked` + (`vd.smali:109`, `ee.smali:99`) and writes immediately; **no confirmation dialog was + found on this path**. +* **Firmware update.** No OTA / firmware-flash write command exists in `BaseCommand`. + The app can read firmware version registers but there is **no code path that writes + firmware** — this is a finding, not an omission in this report. (`op.smali`'s largest + strings are telemetry labels, not flash commands.) + +--- + +## 3. Gating + +### 3.1 Model gate — a capability table, not a version comparison + +Each scooter model is a **subclass of `zm`** (`zm.smali:98` declares +`OOOO00OOO0OO0O0000O:Lzm$OO00000OOOOOOOO0000O;`; 23 `zm$*` classes exist). Each model +subclass passes an `ArrayList` of supported features to the `zm` constructor +(`zm.smali:1516`, `:1643`). Each `qn` wraps one `rn` feature enum value +(`zm.smali:1919`). + +The gate itself is: + +```java +// zm.smali:1865–1942 +public final boolean OO0O0OOO00O00OOO0000(rn feature) { + for (qn q : this.OO0O00O0OO00OOO00O00) if (q.OO00000OOOOOOOO0000O == feature) return true; + return false; +} +``` + +Per-model feature lists recovered by extracting the `rn` references from each `zm$*` +constructor: + +| Model | Features the app will write/offer | +|---|---| +| **M365** | SET_BT_NAME, BACK_LED, LOCK, SPEED_UNIT, CRUISE_CONTROL, KERS_MODE | +| M365PRO | same six as M365 | +| M365_1S / M365_3 / M365_1S_DE / M365_PRO2 / M365_Lite | the six + SPEED_LIMIT_ALL, CHANGE_REGION, AUTOMATION | +| MAX | + SPEED_100M_H, SPEED_LIMIT_ECO | +| ESx / ESx_P | + CHARGING_OPTIMIZE_INFO, SECOND_BATTERY, ATMOSTHERE_LIGHT, SPEED_LIMIT_ECO | +| S-Plus | SET_BT_NAME, LOCK, SPEED_UNIT, ATMOSTHERE_LIGHT | +| Mini / MiniLite | SET_BT_NAME, ATMOSTHERE_LIGHT, (UN_FIRM) | +| Mark2 / Z | SET_BT_NAME, SECOND_BATTERY / UN_FIRM only | +| Wild_Steel_Dust, Kart, KartPro, MiniMax, N4MZ | empty list (nothing gated-in) | + +Call sites of the gate in the UI layer: `ScooterBasicInformationFragment.smali:6130` +(CRUISE_CONTROL), `:6188` (BACK_LED), `:6244` (KERS_MODE), `:6509` (ATMOSTHERE_LIGHT), +`:6546` (SPEED_LIMIT_ALL), `:6602` (SPEED_LIMIT_ECO); +`DashboardFragment.smali:2922,2994`, `BatteryInformationFragment.smali:765`, +`op.smali` (SECOND_BATTERY), `ui.smali:284–290`. + +**Consequence for the only hardware we have:** on an **M365** the app will not offer +region change or any speed-limit write. It *will* write lock, KERS, cruise, tail light +and units. + +A second, independent protocol-family gate exists: `zm.OO0OOOOOOOO00000O0O0()` +(`zm.smali:1944–2037`) compares `String.valueOf(this.OO00OOOOOO000O0OO00O)` against the +list `{32, 34, 37, 40, 41, 43, 46}`. Transport selection in `po.smali:112–250` then +branches on `App.O000OO0OO0OOO00OO000()` combined with the `zm` ordinal, so *which* +framing (`nn` / `bn` / `mn` / `fn` / `gn` / `kn`) a model uses is decided there. + +### 3.2 Auth gate — who gets an AuthToken + +`po.smali:41–93` (the Rx `onNext` for a connected `kb0`) computes: + +``` +input = App.OO0OOOOOOOO00000O0O0() // po.smali:45 (App.ScooterName, default "" at App.smali:85–91) +d = SHA-256(input.getBytes()) // po.smali:49–66 (algo string 'SHA-256') +AuthToken = Arrays.copyOf(hexToBytes(hex(d)), 16) // po.smali:75–93 +op.O00000OO0OOOOO00OOOO = AuthToken // po.smali:93 — the ONLY writer +``` + +So the "authenticated session" token is **`SHA-256()[0..15]`**, not a +negotiated secret. It is handed to the `fn` and `mn` transports +(`po.smali:177`, `225`, `306`, `336`, `375`, `405`, `470`, `488`, `558`, …) and, in `mn`, +is used directly as the AES key for the frame body (`mn.smali:702–712`, key field +`mn->O00OOO0O00O00OOO0000:[B`, XOR helper `vm.OO0O0OOO00O00OOO0000([B,[B)[B` at +`vm.smali:91–126`). `nn` and `bn` receive **no** token (3-arg constructors, +`po.smali:235–240`, `po.smali:415–420`). + +**Therefore: what a third-party app can do at all depends on the transport, not on a +credential.** On the plaintext `nn`/`bn` transports every write in §2.1 is available +without any handshake. On `mn`/`fn` the payload is encrypted with a value any client can +recompute from the scooter's own advertised name/MAC — there is no server, no pairing +secret and no per-device key exchange in this path. (The ECDH machinery that exists — +`po.smali` strings `nbsecf`, `nbsecft`, `ecdh_old` at `po.smali:154,267,447,623,734` — is +used for a *different* transport family and its output was not traced here.) + +### 3.3 Handshake-gated writes + +Only W16 (`cmd=0x5B`, `BaseCommand$OO0OOOOOOOO00000O0O0`) is explicitly a handshake +message; its reply is split into a 16-byte key and a 14-byte block +(`BaseCommand$OO0OOOOOOOO00000O0O0.smali:85–99`, `Arrays.copyOf(bArr,16)` / +`copyOfRange(bArr,16,30)`), stored in the `an` session object. It is issued by +`fn.smali:1587`. No *user-facing* setting write was found behind an auth check — the +gating is structural (which transport the model selected), not a permission test. + +### 3.4 Confirmation dialogs, warnings, refusals + +* **`SCOOTERLOCK_WARNING`.** Decrypts from `gq.smali:66` (`-0x30ee76b3f29eL`). It sits in + `gq` next to `LOCK_OPTION` (`gq.smali:54`) and `LOCK_SCREEN_ROTATION_OPTION` + (`gq.smali:62`) — i.e. it is a **preference key**, not a dialog string. The literal + `-0x30ee76b3f29eL` occurs **exactly once in the whole DEX** (`gq.smali:66`), so in this + build it is a **declared-but-unreferenced key**: no read, no write, no dialog uses it. + Do not treat it as evidence that the app warns before locking. +* **A real confirmation dialog does exist for KERS.** `ScooterBasicInformationFragment_ViewBinding$O00000000OOOOOOOOO00.smali:210–250` builds an AlertDialog with a + `setItems(CharSequence[], checkedIndex, listener)` list, a positive button (`oj`) and a + negative button (`uk`). The listeners are `tk.smali`/`uk.smali`, which map the selection + to `0/1/2` and call `op.OO00000OOOOOOOO0000O(S)` → write to `0x7B`. This is a *chooser*, + not a danger warning. +* **No warning dialog was found** on the lock/unlock path (`vd.smali:78–109`, + `ee.smali:68–99`) or on the serial-write path. +* **Firmware/OTA: deliberately absent.** No write path exists. +* **Premium/billing.** `App.smali:60–76` decrypts `m365_premium_id_2` and + `m365_premium_id_3`; `BillingDataSource` and `Premium_Showroom_Activity` exist in the + app package. The billing literals that surface in the string table are Play-Billing + product ids and a sample purchase JSON (`dalvik/O/O/a/b/O/o/pq.smali:3393`). **No write + or control command in §2.1 is behind a billing check** — the gate that is actually + enforced at every write site is `zm.OO0O0OOO00O00OOO0000(rn)` (model capability), not + a purchase state. (Confidence: read for "no billing check at the write sites"; the + premium feature set itself was not enumerated.) + +--- + +## 4. Retries and failure surfacing + +* **One command at a time.** `vm$OO00000OOOOOOOO0000O.call()` grabs a global + `AtomicBoolean` with `compareAndSet(false,true)`; a second concurrent command + immediately returns integer `0` (`vm$OO00000OOOOOOOO0000O.smali:59–75,176–191`). +* **Send then wait.** `vm.OO00O0O0O0000000000O(baseCommand)` performs the ATT writes; if + `BaseCommand.O0OOOOOO00OOOOO000O0` is `false` the callable returns `1` immediately + without waiting (`vm$…:89–117`). +* **The `-2` re-read loop.** Otherwise it blocks on `vm.O00OOO0O00O00OOO0000(timeout)` and + validates the reply with `BaseCommand.OO00000OOOOOOOO0000O(um)`. A return of `-2` means + "not my reply" and the loop **fetches the next frame** rather than re-sending + (`vm$…:126–154`). **Nothing re-sends the command itself.** +* **Timeouts.** A `TimeoutException` yields `-3` if `BaseCommand.OO00O0O0O0000000000O` + (the "suppress" flag) is set, else it is rethrown (`vm$…:233–274`). +* **`WriteSNException` is swallowed when the suppress flag is set** and returns `1` + (success) — `vm$…:198–231`. **That is the app's one dangerous failure mode for a write: + a failed serial write can be reported as success.** +* **Return-code table.** `BaseCommand.` builds an `ImmutableMap` + (`BaseCommand.smali:37–184`) of 9 status codes — `0 OK`, `1 Out of bounds`, + `2 Erase error`, `3 Write error`, `4 Not locked`, `5 Invalid address`, + `6 Command in progress`, `7 Invalid payload len`, `8 Too many Errors or Retry attempts` + (keys `-0x13fa76b3f29e` … `-0x146676b3f29e`). The built map is **discarded in the + ``** (no `sput-object` follows `BaseCommand.smali:181–183`), so the table is + inert in this build; the *strings* are still the decoder ring for status codes. +* **A write can be fire-and-forget.** `cmd=0x50` (BT name, W8) is built with the + `z` flag `false` (`BaseCommand$OOOOOOO0OOOOO0O00OO0.smali:29`) — `v6=0` is passed as the + last constructor argument — so the callable returns `1` without waiting for any reply + (`vm$…:94–117`). The app therefore **cannot know** whether the name write landed. +* **No backoff, no attempt counter** is implemented anywhere in the app's write path. The + fork's `WriteRetryPolicy` has no counterpart here. + +--- + +## 5. Comparison with M365-Rokid-HUD (`/home/kali/ScooterHacking/repo`) + +| Capability | M365 Tools 1.8.0 | Fork | Difference | +|---|---|---|---| +| Frame envelope | `55 AA`/`5A A5`/`55 AB` + len + addr + cmd + reg + payload + **cksum16LE** | `len ‖ direction ‖ read_write ‖ attribute ‖ payload`, **no header, no checksum** (`ninebot-ble/src/session/commands.rs:115–130`) | Fork omits both magic bytes and the checksum. The scooter's own reply checksum verification the app performs (`nn.smali:539–594`) has no fork equivalent. | +| Length field | counts differently per transport (`payload+2` / `payload` / `payload+6`) | `payload.len() + 2` only (`commands.rs:121`, `MAX_PAYLOAD_LEN=253` `:92`) | Fork models only the `nn`-style rule. | +| Read/write byte | `cmd` **1 = read, 2 = write**, separate `reg` byte | `ReadWrite::Read=0x01`, `Write=0x03` (`commands.rs:30–37`) | **The fork uses `0x03` for write; the app uses `0x02`.** This is the single most consequential divergence found. | +| Lock / unlock | `reg 0x70` / `0x71`, payload `{01}` | `Attribute::Lock=0x70`, `Unlock=0x71` (`commands.rs:74–75`) — **registered but no writer** | Fork has the registers, deliberately sends neither (`ScooterSettingsWriter.kt:20–22`). | +| KERS | `reg 0x7B`, 2-byte **little-endian** short | `REG_KERS=0x7B`, `{code, 0x00}` (`ScooterSettingsWriter.kt:47,91–92`) | Agreement. | +| Cruise | `reg 0x7C`, **1 byte** `{00\|01}` (`op.smali:1851–1877`) | `REG_CRUISE=0x7C`, `{0x01,0x00}` 2 bytes (`:50,95–96`) | Fork sends one byte more than the app. | +| Tail light | `reg 0x7D`, 2-byte LE short | `REG_STATUS=0x7D`, **big-endian** (`:53,135–144`) with a documented read-LE/write-BE asymmetry | Fork *writes* BE; the app writes **LE** (`op.smali:3823,3971` uses `ByteOrder.LITTLE_ENDIAN`). Divergence — one of the two is wrong on hardware. | +| Units | `reg 0x7D`, LE short | same register, BE (`:118–125`) | Same divergence. | +| Speed limit / eco | `op.smali:2110–2196`, gated `SPEED_LIMIT_ALL`/`_ECO` | **absent** | Fork lacks it. | +| Region change | capability flag exists; **no register found** | absent | Both effectively lack it; the app's flag has no located implementation. | +| Magic serial (`0x58`/`0x59`) | present (`ScooterInformationActivity$…:469,484`) | absent | Fork lacks it. | +| BMS serial (`0x5C`) | present (`dm.smali:59`) | absent — the fork states "the reference app performs no BMS writes" | **The fork's premise is wrong for M365 Tools**: `cmd=0x5C` to BMS exists. | +| BLE config (`0x5D`), ext write (`0x50`), handshake (`0x5B`) | present | `NinebotHandshake.kt` exists but was not compared byte-for-byte here | Fork lacks the `0x50`/`0x5B`/`0x5D` builders in `commands.rs`. | +| Model gating | `zm.OO0O0OOO00O00OOO0000(rn)` capability list per model (`zm.smali:1865`) | `ScooterModelRegistry.kt` (386 lines) | Different mechanism; not byte-compared. | +| Retry | none; `-2` loop re-reads, and `WriteSNException` can be **swallowed as success** (`vm$…:198–231`) | `WriteRetryPolicy` = 3 attempts/chunk, 40 ms doubling to 250 ms, `DISCARDED_BY_PEER` gives up loudly (`WriteRetryPolicy.kt:41,44,53,106–133`) | Fork is stricter and safer. | +| Error codes | 9 generic status codes (`BaseCommand.smali:37–184`, table inert) | 40 ESC fault codes from Scootbatt (`ScooterErrorCodes.kt:36–70`) | Different code spaces; not comparable. | +| Readback verification | app re-reads after a write (`op.smali:2062` reads back what `:2037` wrote) | explicit `verify*` helpers (`ScooterSettingsWriter.kt:156–180`) | Agreement in principle. | + +**Fork has, app lacks:** nothing found; the fork is a strict subset of the app's writes +plus stricter retry/verification. +**App has, fork lacks:** lock/unlock writes, speed limit, region (flag only), magic serial +(both lengths), BMS serial `0x5C`, BLE config `0x5D`, extended write `0x50`, handshake +`0x5B`, and the `5A A5` / `55 AB` framings. +**Gated differently:** the app gates on a per-model *capability list* keyed by an `rn` +enum; the fork models only three status registers and gates by explicit omission. + +--- + +## 6. Unverified / needs hardware + +**No scooter and no phone were attached during this analysis. Not one command in this +report has been executed, transmitted or observed. Nothing here should be sent to +hardware on the strength of this report alone.** + +Specifically unverified: + +1. **Every command byte and register in §2.1** is a *reading of the builder code*, not an + observation. The mapping from `cmd=0x02 + reg=0x7B` to "KERS changes on a real M365" + is inferred from the app's own UI wiring and from the fork's labels — never confirmed + on a vehicle. +2. **The `0x7D` endianness conflict** between the app (little-endian, `op.smali:3823,3971`) + and the fork (big-endian, `ScooterSettingsWriter.kt:135–144`) is *unresolved*. At most + one is correct. Writing the wrong one to a shared status word would silently flip the + other setting. +3. **The `mn` encrypted framing** (§1.3) is reconstructed from control flow, including a + plaintext buffer (`mn.smali:522–688`) whose result is computed and then never + transmitted. That dead path is reported as read but its *purpose* is not established. +4. **`kn`'s framing** is the least understood: no `55`/`5A` magic exists in the file and + the AES path (`kn.smali:1771–1845`) was not fully traced. Any `kn`-family model + (newest firmware) write is **withheld**. +5. **Which string feeds `App.OO0OOOOOOOO00000O0O0()`** (the AuthToken input) is not + settled. Its default is `""` (`App.smali:85–91`) and it is set through + `App$OO00000OOOOOOOO0000O.OO00O0O0O0000000000O(String)` (`:379–391`). If it is the BLE + MAC the token is fully client-computable; if it is a user-chosen nickname it is not. + **Do not assume a third-party app can reproduce the `mn`/`fn` token until this is + settled.** +6. **Region change.** The `rn.CHANGE_REGION` flag exists but no write builder for it was + found; the register is **withheld**, not guessed. +7. **`rn` feature lists** in §3.1 were recovered by extracting `rn` field references from + each `zm$*` constructor. If R8 inlined any entry the list is short. Treat the M365 row + as read-with-caveat and the other rows as inferred. +8. **`SCOOTERLOCK_WARNING`** is asserted to be unreferenced because its `const-wide` + literal occurs once. A use site that reaches the string through a different key + (the decryptor is not injective across all observed keys) would invalidate that; the + claim is "no reference found", not "impossible to reference". +9. **The premium/billing conclusion** covers only the write sites. Whether some *read* or + UI feature is paywalled was not enumerated. +10. **No write was checked for a range clamp.** Registers such as `0x70/0x71/0x7B/0x7C/0x7D` + receive whatever the UI hands them (`op.smali:2110–2196` passes a caller-supplied + byte and short straight through). What a scooter does with an out-of-range value is + unknown and is exactly the class of mistake that cannot be undone remotely. diff --git a/doc/reverse-engineering/m365tools-reports/06-connection-state-machine.md b/doc/reverse-engineering/m365tools-reports/06-connection-state-machine.md new file mode 100644 index 0000000..5cd1e86 --- /dev/null +++ b/doc/reverse-engineering/m365tools-reports/06-connection-state-machine.md @@ -0,0 +1,712 @@ +# AREA 6 — Connection lifecycle: state machine, reconnection, timeouts, MTU, error handling + +Target: `app.peretti.m365tools` **M365 Tools 1.8.0** (versionCode 180), sha256 +`4451a47c86339b8f88d77bdad7aef77e1bc4f11b7c728a5803f2b1d342a16872`. +Static analysis only. Source of record: the apktool decode at +`/home/kali/ScooterHacking/re/m365tools/apktool-out/`. apktool split the DEX into +`smali/` and `smali_classes2/`; both are cited, both are the same decode. No +`jadx-out/` path is cited anywhere in this report. + +All paths below are relative to `/home/kali/ScooterHacking/re/m365tools/`. + +--- + +## 0. Method, and how the Rx chain was read at all + +R8 repackaged **the whole application and most of its libraries, including RxJava +and RxAndroidBle, into one obfuscated package** `dalvik/O/O/a/b/O/c/b/O/o/`. RxJava +method names are gone, so "look for `retryWhen`" finds nothing (verified: zero +occurrences of `retryWhen`, `repeatWhen`, `subscribeOn`, `observeOn`, `flatMap`, +`requestMtu`, `establishConnection` anywhere in the decode). + +The chains were recovered by three independent means, and every operator named in +this report is justified by at least one of them: + +1. **Interface shapes.** R8 kept the SAM method bodies, so the functional + interfaces are identifiable with certainty: + `pp0` = `io.reactivex.functions.Consumer` (`O00OOO0O00O00OOO0000(Object)V`, + `smali/dalvik/O/O/a/b/O/c/b/O/o/pp0.smali:18`); `kp0` = `Action` (`run()V`, + `smali_classes2/dalvik/O/O/a/b/O/o/kp0.smali:7`); `qp0` = `Function` + (`OO00O0O0O0000000000O(Object)Object`, `smali_classes2/…/qp0.smali:20`); + `rp0` = `Predicate` (`smali/dalvik/…/zn.smali:36` implements it); + `to0` = `ObservableSource` (`O0O000OOOO0000OOOO0O(vo0;)V`, + `smali_classes2/…/to0.smali:18`); `gp0` = `Disposable` + (`smali_classes2/…/gp0.smali:7,10`). +2. **Null-check strings RxJava emits.** `qo0` (= `io.reactivex.Observable`) keeps + RxJava's own argument-validation messages verbatim: `"mapper is null"`, + `"maxConcurrency"`, `"bufferSize"` at `smali_classes2/…/qo0.smali:925-938` + identify `flatMap(Function, boolean, int)`; `"onNext is null" … "onAfterTerminate + is null"` at `qo0.smali:1216-1234` identify the 4-argument + `doOnEach(Consumer,Consumer,Action,Action)`; `"other is null"` at + `qo0.smali:784` plus a generic `` independent of `` identifies + `takeUntil(ObservableSource)`; `"invalid device address "` at + `smali/dalvik/…/op.smali:2488` is RxAndroidBle's own `getBleDevice` guard. +3. **Return types.** `qo0.OO0O0O0OOO0OOO00OO00(Consumer,Consumer,Action,Consumer) + → Disposable` (`qo0.smali:993`) is `Observable.subscribe(...)`; + `qo0.OO0OOOO00OO0O0OOO00O(long,TimeUnit,Scheduler) → Observable` + (`qo0.smali:116`) is `Observable.timer`; `qo0.OOOO00O0OOO0OOOOO000(long,long, + TimeUnit) → Observable` (`qo0.smali:179`) is `Observable.interval`. + +**Vocabulary.** Every interesting literal is encrypted; the plaintexts below were +recovered with `tools/m365tools_strings.py`'s table (`analysis/strings.json`). +"Read" in the confidence columns means the instruction was read in smali; +"inferred" means a conclusion drawn from surrounding instructions. + +--- + +## 1. Class map for this area + +| Class (path under `apktool-out/`) | Identity | Evidence | +|---|---|---| +| `smali_classes2/dalvik/O/O/a/b/O/c/b/O/o/kb0.smali` | `RxBleConnection` — write / setupNotification / read | `:15,27,40` | +| `smali_classes2/…/kb0$OO00000OOOOOOOO0000O.smali` | `RxBleConnection.RxBleConnectionState` enum | `:26-34` | +| `smali_classes2/…/lb0.smali` | `RxBleDevice` — `establishConnection(Z)`, `observeConnectionStateChanges()`, `getConnectionState()`, `getMacAddress()` | `:15,19,23,27,31,35` | +| `smali/dalvik/…/op.smali` (5831 lines) | the BLE manager: connect, disconnect, teardown, queues, state | §2, §4 | +| `smali/dalvik/…/po.smali` (1468 lines) | per-model handshake + protocol (`vm`) selection; arms notifications | `:140-229`, `:1288-1355` | +| `smali/dalvik/…/vm.smali` + `vm$OO00000OOOOOOOO0000O.smali` | command transport base: checksum, XOR, one-command-at-a-time executor | `vm.smali:56-126,133-161` | +| `smali/dalvik/…/mn.smali`, `nn`, `fn`, `gn`, `bn`, `kn` | six `vm` protocol implementations (framing / fragmentation) | `po.smali:169-245,282-415,…` | +| `smali/dalvik/…/{sp,ho,rp,so}.smali` | the 10 ms command pump and its queue | §5.2 | +| `smali/dalvik/…/{yn,wn,zn,qt0}.smali` | the 3 s keep-alive chain | §5.3 | +| `smali/dalvik/…/{qo,lo,ao,pp,mo}.smali` | connection error classification & the GATT-133 path | §3 | +| `smali/app/peretti/m365tools/model/BaseCommand*.smali` | command objects, reply validation, status→message table | `BaseCommand.smali:37-184` | +| `smali/app/peretti/m365tools/ScooterService.smali` (3502 lines) | foreground service: notifications, poll loop, watchdog | §2.3, §4 | +| `smali/app/peretti/m365tools/ScooterService$*.smali` | poll runnable, 15 s watchdog, notification builder | §4 | + +The app uses **greenrobot EventBus** (`org/greenrobot/eventbus/ThreadMode` in the +`onEvent` annotations at `ScooterService.smali:913-916`) for app-internal +distribution and **RxJava 2-style chains** for everything BLE. + +--- + +## 2. The connection state machine + +### 2.1 The only discrete states the app models + +The app does **not** define its own `enum`. It re-uses +`RxBleConnectionState`, whose four constants are read directly from the enum's +``: + +| Ordinal | Constant | Citation | +|---|---|---| +| 0 | `CONNECTING` | `smali_classes2/…/kb0$OO00000OOOOOOOO0000O.smali:48-54` | +| 1 | `CONNECTED` | `…kb0$OO00000OOOOOOOO0000O.smali:58-64` | +| 2 | `DISCONNECTED` | `…kb0$OO00000OOOOOOOO0000O.smali:68-74` | +| 3 | `DISCONNECTING` | `…kb0$OO00000OOOOOOOO0000O.smali:78-84` | + +Everything else that a reimplementer would model as a state — *scanning*, +*discovering*, *handshaking*, *ready*, *error* — is represented **not as a state +but as a side effect**: the presence or absence of the static fields in `op`. + +| "State" a reimplementer would expect | What the app actually holds | Citation | +|---|---|---| +| idle | `op.OO00OOOOOO000O0OO00O == null` (no `RxBleDevice`) | `op.smali:70`, `:2418-2422` | +| scanning (connect path) | no flag; the 1 s `takeUntil` timer is running | `op.smali:2514-2530` | +| device acquired | `op.OO00OOOOOO000O0OO00O != null` | `op.smali:2561`, `op$OO00000OOOOOOOO0000O.smali:56-61` | +| connecting | `device.getConnectionState() == CONNECTING` | `op.smali:2585-2593` | +| handshaking | the handshake runs *inside* the connection `onNext` (`po`) | `op.smali:2683-2688` → `po.smali` | +| ready / streaming | `op.OO0OOO00O0O00O00OO00 != null` (pump Disposable) + `op.O00000OOO0O0O00O0O0O` (error counter) | `ho.smali:94`; `op.smali:352` | +| disconnecting | `getConnectionState() == DISCONNECTING` | `op.smali:2595-2603` | +| error | the error counter and the teardown are the "state" | `qo.smali:52-56`, `lo.smali:38-80` | + +`op.O0O000OOOO0000OOOO0O()` is the one boolean predicate the app treats as +"connected": `device != null && device.getConnectionState() == CONNECTED` +(`op.smali:2085-2107`). It is used as the gate for *every* command +(`op.smali:4203`, `:4912`, `:5030`, `:1571`). + +### 2.2 Transition diagram (read from code) + +``` + user taps connect (ScooterInformationActivity.smali:2473, + ScooterService.smali:3431) + | + v + [idle] --op.OO00O0O0O0000000000O()------------------------------+ + | scanBleDevices(ScanFilter{mac}) + takeUntil(timer 1 s) | + | op.smali:2501-2530 | + v | + [scan 1 s] --onNext(ScanResult)--> getBleDevice(mac) | + | op.smali:2557, op$OO00000OOOOOOOO0000O.smali:56-61 | + v | + device == null ? --yes--> log "mbledevice null" (op.smali:2698) -+ + | no + v + already CONNECTED/DISCONNECTING ? --yes--> nothing (op.smali:2593-2603) + | no + v + establishConnection(autoConnect = false) op.smali:2650 + .subscribeOn(Schedulers.io()) op.smali:2654-2659 + .doOnComplete(dispose previous conn) op.smali:2663-2668 + .doOnEach(onError = qo) op.smali:2672-2680 + .subscribe(onNext = po, onError = lo) op.smali:2683-2693 + | + +--> CONNECTING (emitted by RxAndroidBle on + | observeConnectionStateChanges, ScooterService.smali:3397-3412) + | -> ScooterService.onEvent(RxBleConnectionState) case 0: + | no branch (ScooterService.smali:2937-2943) + | + +--> onNext: po == HANDSHAKE, then READY + | po.smali: picks a vm per protocol family (po.smali:140-245), + | writes auth, arms notify on the model's characteristic + | (po.smali:1288-1355), starts the 10 ms pump (ho) + | -> RxAndroidBle emits CONNECTED + | -> ScooterService.smali:2937 -> :3008-3037: + | foreground notification "CONNECTED", + | postDelayed(pollRunnable, 600 ms) + | + +--> onError: lo == FAILURE + lo.smali:38-80: log "Error onConnectionFailure", + remove all Handler callbacks, dispose pump + command + subscription, clear both queues, full teardown + -> NO retry, NO reconnect (see §3) + + [any state] -- user disconnect / logout -- + op.OO00OOOOOO000O0OO00O(boolean) op.smali:3784-3994 + (queues a 0x7d protocol command first, then tears down) + + [any state] -- GATT/connection error --> [idle], everything cleared +``` + +`ScooterService.onEvent(RxBleConnectionState)` handles exactly two of the four +constants; the `ordinal` switch only tests 1 (`CONNECTED`) and 2 +(`DISCONNECTED`) and falls through for 0 and 3 (`ScooterService.smali:2931-2943`). + +On `DISCONNECTED` (ordinal 2) the service cancels notifications 1 and 2, clears +the trip queue, removes *all* Handler callbacks, sets the "started" flag +`ScooterService.OO0O0O0OO0OOO0OO0000` to false, `stopForeground(true)` and stops +location updates (`ScooterService.smali:2946-3005`). On `CONNECTED` it posts the +poll runnable 600 ms later (`ScooterService.smali:3025-3037`). + +### 2.3 The state observer + +The connection-state stream is subscribed **once**, in `onStartCommand`, and only +if the Disposable field is still null (`ScooterService.smali:3392-3427`): + +``` +device.observeConnectionStateChanges() lb0.smali:23 + .doOnEach(EMPTY_CONSUMER, np /*onError*/, EMPTY_ACTION, EMPTY_ACTION) + .subscribe(onNext = no, onError = bo, EMPTY_ACTION, EMPTY_CONSUMER) + -> op.O00000000OOOOOOOOO00 +``` +`no` logs, re-posts the state on the EventBus, and — on `DISCONNECTED` — aborts +any in-flight command by resetting the protocol object's pending-reply holder +(`no.smali:36-85`, the `kb0$…OOOOO0000OO0O00000O0` test at `:69-71` and the +`vm.OOOOOOO0OOOOO0O00OO0` field access at `:74-81`). `bo` synthesises a +`DISCONNECTED` event when the stream itself errors (`bo.smali:36-72`, state post +at `:45-51`, reply-holder reset at `:61-68`). + +--- + +## 3. Reconnection: what triggers it, what the backoff is, when it gives up + +**There is no general automatic reconnection.** This is a finding, not an +omission in the analysis: + +* The connection Observable is subscribed exactly once per user-initiated + connect (`op.smali:2688`), and neither the subscription nor any operator on it + retries. A repository-wide search for the RxJava retry/repeat operators finds + none: the only `Function`-taking operators used in the app's own packages are + `flatMap`-family calls (`qo0.smali:906,1059,1131`), and no `qp0` whose generic + parameters form the `Function, ObservableSource>` + shape that `retryWhen` requires appears in the connection path. +* The connection `onError` handler `lo` performs a **one-way teardown** and + returns (`lo.smali:38-80`): log `"Error onConnectionFailure"` + (`lo.smali:44-49`, key `-0x233476b3f29e`), `Handler.removeCallbacksAndMessages` + (`:52`), dispose `op.OO0OOO00O0O00O00OO00` (the pump, `:61`), clear + `op.OOOOO0000OO0O00000O0` and `op.OO0OO000O000OOO0O00O`… i.e. both queues + (`:68`, `:73`), dispose the connection subscription itself + `op.OO0OO000O000OOO0O00O` (`:79`) and call the full teardown + `op.OO0O00O0OO00OOO00O00()` (`:80`). +* `ScooterService.onEvent(DISCONNECTED)` (`ScooterService.smali:2946-3005`) does + not call connect either. +* `op.OO00O0O0O0000000000O()` (the connect entry point) has only three callers in + the whole app, and all three are user/lifecycle driven: + `ScooterInformationActivity.smali:2473`, + `ScooterInformationActivity$O00OOO0O00O00OOO0000.smali:276` and + `ScooterService.smali:3431` (the latter inside `onStartCommand`). + +**The one exception: GATT status 133 (`0x85`).** The connection chain's +`doOnEach` `onError` consumer `qo` special-cases it (`qo.smali:70-82`): + +| Step | Code | Citation | +|---|---|---| +| Classify | `throwable instanceof BleDisconnectedException && ((BleDisconnectedException) t).status == 0x85` | `qo.smali:70-82` | +| Drop the client | `op.OO0OO0OOO0O0OOO0OO0O = null` (the `RxBleClient`) | `qo.smali:85` | +| Rebuild it | `App.O000000O000000000OO0()` | `qo.smali:88` | +| **Re-scan for the device** | `scanBleDevices(settings, ScanFilter{mac}).takeUntil(timer(1, SECONDS))` | `qo.smali:173-193` | +| Re-acquire the handle | on each ScanResult: `client.getBleDevice(mac)` → `op.OO00OOOOOO000O0OO00O` | `pp.smali:32-52` | +| Dispose the old link subscription | `op.OOOOO00OOO0O00OO0O0O.dispose()` | `pp.smali:55-70` | +| Subscribe | `subscribe(pp, ao /*BleScanException consumer*/, EMPTY_ACTION, EMPTY_CONSUMER)` → `op.OOOOO00OOO0O00OO0O0O` | `qo.smali:213-229` | +| Always | `op.OO0O00O0OO00OOO00O00()` (teardown) | `qo.smali:233` | +| `BleAlreadyConnectedException` | also drops `op.OO0OO0OOO0O0OOO0OO0O` (the client) | `qo.smali:59-67` | + +Two things this path does **not** do, both material for a reimplementation: + +1. It never calls `establishConnection` again. It refreshes the `RxBleDevice` + handle and disposes the dead subscription; something else must call + `op.OO00O0O0O0000000000O()` to actually reconnect. +2. It has no backoff and no attempt counter of its own. The **only** delay is the + 1 second `takeUntil` window on the re-scan (`qo.smali:177-193`), which is a + scan bound, not a retry backoff. + +**Attempt counter that does exist.** `op.O00000OOO0O0O00O0O0O:Ljava/util/concurrent/atomic/AtomicInteger` +is created at 0 (`op.smali:348-352`) and incremented on **every** connection +error (`qo.smali:52-56`). Nothing reads it to bound retries: a whole-file check of +`op.smali` shows no read of that field. It is telemetry only (it is reported to +Crashlytics alongside the MAC at `ScooterService.smali:3381-3389`). + +**Does a reconnect re-run the handshake?** Yes, necessarily: the handshake *is* +the connection `onNext`. `op.OO00O0O0O0000000000O()` subscribes with +`onNext = po` (`op.smali:2683-2688`), and `po` is the routine that selects the +protocol implementation (`po.smali:140-245`), writes the auth token / ECDH +material, reads the serial/version, arms the notification channel +(`po.smali:1288-1355`) and starts the pump. There is **no session resumption, no +cached session, no "fast reconnect"** path: every `establishConnection` runs the +full handshake from scratch. The only thing cached across connections is the +model/protocol choice in preferences (`gq.smali:86` +`LAST_SCOOTER_SCOOTER_PROTOCOL`, `:82` `LAST_SCOOTER_SCOOTER_TYPE`). + +**User-visible "give up".** Two places: +* After a successful connect, `ScooterService.onStartCommand` posts + `ScooterService$OOOOOOO0OOOOO0O00OO0` with `0x3a98` = **15000 ms** + (`ScooterService.smali:3477-3479`). That runnable checks + `op.O0O000OOOO0000OOOO0O()`; if the link is still not CONNECTED it cancels + notification id 1 and calls `stopForeground(true)` — the service stops + advertising itself but is not stopped and does not retry + (`ScooterService$OOOOOOO0OOOOO0O00OO0.smali:38-88`). +* A refused command surfaces as a toast `"Not connected"` in + `op.OO0OOOOOOOO00000O0O0` (`op.smali:4931-4943`) and as a Crashlytics + `RuntimeException("Not Connected" + command)` in `op.OO0O0OOO00O00OOO0000` + (`op.smali:4225-4257`). + +`onStartCommand` returns `2` = `START_STICKY` (`ScooterService.smali:3499-3502`), +so Android may restart the service; a sticky restart arrives with a null Intent +and the whole connect block is skipped (`ScooterService.smali:3166`). + +--- + +## 4. Timeouts + +Values are milliseconds unless stated. "Guarded operation" is what the constant +actually bounds in the code, not what its name suggests. + +| Value (ms) | Constant / form | Guards | Citation | Confidence | +|---|---|---|---|---| +| 1000 | `takeUntil(Observable.timer(1, SECONDS, computation))` | The **connect-path device scan**: how long `op.OO00O0O0O0000000000O()` is willing to scan for the MAC before giving up and logging `"mbledevice null"` | `op.smali:2514-2530`, log at `:2698` | read | +| 1000 | same construct | The **GATT-133 recovery re-scan** | `qo.smali:177-193` | read | +| 230 | `0xe6` literal passed as the timeout argument | **Per-command reply deadline.** Every command queued by the normal dispatcher gets this budget; the protocol reads the notification queue until the wall-clock deadline, then throws `TimeoutException("Timeout")` | producer `op.smali:4214`; consumer `mn.smali:127-160`, throw at `mn.smali:347-355` | read | +| caller-supplied | `op.OO0OOOOOOOO00000O0O0(BaseCommand, int)` | Same reply deadline, for callers that need a different budget | `op.smali:4898-4929` | read | +| 600 | `0x258`, `Handler.postDelayed` | **Delay before the first telemetry poll** after `CONNECTED` (a settle delay after the handshake) | `ScooterService.smali:3031-3037` | read | +| 300 | `0x12c`, `Handler.postDelayed` | **Telemetry poll period.** The poll runnable re-posts itself unconditionally every 300 ms | `ScooterService$O0OOOOOO00OOOOO000O0.smali:100-108` | read | +| 10 | `Observable.interval(0, 10, MILLISECONDS)` | **Command-pump tick.** One queued command is dispatched per tick | `ho.smali:55-67` | read | +| 3000, initial delay 1000 | `Observable.interval(1, 3, SECONDS)` | **Keep-alive / session-refresh writes** on the auth characteristic (see §5.3) | `yn.smali:137-144` | read | +| 15000 | `0x3a98`, `Handler.postDelayed` | **Connect watchdog**: after 15 s without `CONNECTED`, remove the foreground notification and stop foreground | `ScooterService.smali:3477-3479`; body `ScooterService$OOOOOOO0OOOOO0O00OO0.smali:38-88` | read | +| ~4800 (16 × 300) | counter `> 0xf` inside the 300 ms poll runnable | Enqueue of the **periodic maintenance command** (`op.OOOOOOO00OOO0O00OOOO()`); counter reset each time it fires, so the period is 16 poll ticks | `ScooterService$O0OOOOOO00OOOOO000O0.smali:65-85`, call at `:72` | inferred (arithmetic read; period depends on the 300 ms value) | +| 40 entries (a bound, not a time) | `queue.size() > 0x28` | **Backpressure cap**: when more than 40 commands are pending, the whole queue is cleared instead of drained | `sp.smali:42-56` | read | + +**Absent timeouts** (checked, not assumed): + +* No timeout operator and no deadline is applied to `establishConnection`. The + only bound on the connection attempt is the 15 s service watchdog above, and + that watchdog does not cancel the attempt — it only hides the notification. +* No per-write timeout. `mn` fires every 20-byte chunk as an independent + `writeCharacteristic` and discards the returned `Disposable`, so a write that + is never acknowledged is never timed out by the app (`mn.smali:840-864`). +* No scan window in the user-facing scan: + `MainActivity.smali:699-739` builds the chain + `scanBleDevices → subscribeOn(io) → doOnComplete → subscribe` and stores the + Disposable in `MainActivity.OOOO00OOO00O000O0OOO` (`:739`). No `take`, no + `takeUntil`, no `timeout`. The scan ends when the activity disposes it. +* No heartbeat-style "reply watchdog" other than the 230 ms command deadline: + there is no counter of unanswered polls anywhere in the poll path + (`ScooterService$O0OOOOOO00OOOOO000O0.smali:45-110`). + +--- + +## 5. The read / notify pump + +### 5.1 Telemetry is **polled**, not pushed + +The scooter pushes nothing the app acts on spontaneously. The app decides what to +read and when; incoming notifications are only ever *replies* to its own writes. + +* The foreground service starts a `Runnable` 600 ms after `CONNECTED` + (`ScooterService.smali:3035-3037`) that re-posts itself every 300 ms + (`ScooterService$O0OOOOOO00OOOOO000O0.smali:105-108`). +* Each tick, if `op.O0OO0O00O0OOOO0OO0OO` (the "session live" `AtomicBoolean`) is + true, it calls `op.O000000O000000000OO0()` and `op.O000OO0OO0OOO00OO000()` + (`ScooterService$O0OOOOOO00OOOOO000O0.smali:50-62`). Both methods *enqueue* + command Observables into `op.OOOO00O0OOO0OOOOO000`; they do not write + (`op.smali:640-662`, `:910-953`). +* Every 16th tick the runnable also calls `op.OOOOOOO00OOO0O00OOOO()` + (`ScooterService$O0OOOOOO00OOOOO000O0.smali:65-85`) which enqueues one more + command (`op.smali:5613-5619`). +* The queue is drained by a **separate 10 ms pump** (`ho.smali:55-67`), started + from the handshake when the notification channel is armed + (`po.smali:1288-1307`): `Observable.interval(0, 10, MILLISECONDS)` + `.doOnEach(sp, …)` `.filter(so)` `.subscribe(…)`, Disposable stored in + `op.OO0OOO00O0O00O00OO00` (`ho.smali:94`). +* `sp` polls **exactly one** Observable per tick and subscribes it + (`sp.smali:39-56`). If the queue has grown past 40 entries it is cleared first + (`sp.smali:50-56`). The observer it passes (`rp`) only checks disposal state + (`rp.smali:44-56`). + +### 5.2 How overlapping commands are avoided — two independent mechanisms + +1. **Global transport mutex.** `vm.O0OOOOOO00OOOOO000O0` is an `AtomicBoolean` + used as a monitor: the executor does `synchronized(flag) { if + (flag.compareAndSet(false, true)) { …run… } else { flag.set(false); return 0; + } }` (`vm$OO00000OOOOOOOO0000O.smali:59-75` and the `cond_2` branch `:176-191`). + A command that finds the transport busy is **dropped and returns integer 0** — + it is not queued here. The flag is released in every exit path, including the + `TimeoutException` and `WriteSNException` handlers (`:107-117`, `:166-174`, + `:233-267`, `:277-283`). +2. **Queue + single dispatcher.** Pending work lives in one + `ConcurrentLinkedQueue` (`op.smali:308`) and is drained + one-at-a-time on a 10 ms grid (§5.1). Because the command Observable is an + `Observable.fromCallable` (`vm.smali:152-161`) whose `call()` blocks while it + waits for the reply, the pump tick is serialised behind the command. + +There is no priority, no per-command queue key and no fairness: it is FIFO, and +the only backpressure policy is "clear everything above 40". + +### 5.3 The 3-second keep-alive (the closest thing to a heartbeat) + +`fo` is the subscriber registered by the handshake when the data-notification +read completes (`po.smali:1415-1442`). Inside it, arming the notification is +wrapped in `doOnNext(yn)` (`fo.smali:65-72`). `yn` does three things: + +1. Builds the session cipher object `op.OO0OOOO00OO0O0OOO00O:vp` from a + device-derived byte string (`yn.smali:121-135`). +2. Starts `Observable.interval(1, 3, SECONDS)` — first tick after 1 s, then every + 3 s (`yn.smali:137-144`) — filtered by the `Predicate` `zn` and subscribed with + the log-only consumer `kp`, the swallow-everything consumer `lp`, and the no-op + action `mp` (`yn.smali:148-190`). +3. The doOnEach consumer `wn` performs the actual writes + (`yn.smali:156-163` → `wn.smali:36-164`): depending on the session counter + `vp.OO00000OOOOOOOO0000O`, it writes `op.O000OO0OO0OOO00OO000` (counter 0) or + `op.O000000O000000000OO0` (counter 2) to the auth characteristic + `op.OO0OOOOOOOO00000O0O0` = `00000010-0000-1000-8000-00805f9b34fb`, and in the + `== 2` branch also writes the encrypted payload to + `op.O00OOO0O00O00OOO0000` = `00000001-0000-1000-8000-00805f9b34fb` + (`wn.smali:44-48,51-77,87-160`; UUID literals at `op.smali:194-201` and + `op.smali:167-174`). + +Important properties of this loop, all of which a reimplementation must decide +about explicitly: + +* Its errors are **swallowed** (`lp.smali:32-39` is an empty `onError`), so a + failing keep-alive write neither kills the session nor triggers anything. +* The teardown resets the session object (`op.OO0O00O0OO00OOO00O00` sets + `vp.OO00000OOOOOOOO0000O = 0`, `vp.OOOOOOO0OOOOO0O00OO0 = false`, `op.smali:4006-4015`), + which is how the keep-alive is stopped: the interval is never disposed + explicitly, only its counter is reset by the teardown of the *connection*. +* It is **not** a link-liveness probe: nothing observes whether a keep-alive + write succeeded, and no disconnect is raised on failure. + +### 5.4 The reply path + +Replies arrive as notifications into a per-protocol +`ConcurrentLinkedQueue` (`mn`'s second constructor argument, +`mn.smali:20`, field at `:9`). The command executor's waiter (`mn.O00OOO0O00O00OOO0000`, +`mn.smali:121-160`) is a **busy poll over that queue with a wall-clock deadline** +(`System.currentTimeMillis()` at `mn.smali:127` and `:148`, comparison at `:158`, +`TimeoutException("Timeout")` at `mn.smali:347-355`). Before each command the +queue is **cleared** (`mn.smali:822-824` in the transmit method), so a late reply +to the previous command can never be mistaken for the current one. Checksum +mismatches cause the loop to continue waiting rather than fail +(`mn.smali:219-226`, checksum function `vm.OO00000OOOOOOOO0000O([B)S` at +`vm.smali:56-89`). + +--- + +## 6. MTU and fragmentation + +**The app never requests an MTU, and never reads one.** Findings: + +* The `RxBleConnection` interface the app holds has exactly three methods — + write (`kb0.smali:15`), setupNotification (`:27`), read (`:40`). There is no + `requestMtu` on it because R8 kept only the members the app uses; a + repository-wide search for `requestMtu`, `Mtu`, `mtu` in the decode returns + **zero** hits in code and **zero** hits in the decrypted string table. +* No `Integer`-returning `Single` (which `requestMtu(int): Single` would + be) exists anywhere in the app's classes. + +Consequently fragmentation is hard-coded, in `mn.OO00O0O0O0000000000O` +(`mn.smali:826-864`): + +``` +ByteArrayOutputStream frame = <0x55 0xAB | payload | checksum(le)> mn.smali:764-817 +queue.clear() mn.smali:822-824 +remaining = frame.length +while (remaining > 0) { mn.smali:829-830 + n = min(remaining, 0x14) // 0x14 = 20 mn.smali:832-837 + connection.writeCharacteristic( + UUID.fromString(op.OO00000OOOOOOOO0000O), // 6e400002 mn.smali:840-846 + copyOfRange(frame, offset, offset + n)) // ob1 = Arrays.copyOfRange + .subscribe() // Disposable discarded mn.smali:848-858 + offset += n; remaining -= n +} +``` + +* **Chunk size arithmetic: 20 bytes, unconditionally.** `0x14` = 20 = the + ATT_MTU 23 guaranteed minimum minus 3 bytes of ATT overhead. The app assumes + the default MTU for the whole session. +* **No delay, no acknowledgement and no backpressure between chunks**: each chunk + is written and subscribed independently, and the returned `Disposable` is + dropped, so the app cannot tell whether a chunk landed. Ordering is preserved + only because RxAndroidBle serialises GATT operations internally, and because + the *receiver* of those writes is a serialised Rx chain + (`mn.smali:854-858`). +* **There is a second, different write path.** `wn` (the 3 s keep-alive) writes + short, pre-fragmented payloads directly — one `writeCharacteristic` call, no + loop: `wn.smali:51-61`, `wn.smali:87-97`, `wn.smali:116-144`; `fo.smali:65` + arms notifications. So "the write path" is not one function: the protocol/data + frames go through the fragmentation loop, the auth/keep-alive frames do not. +* **Write-with-response only.** The only characteristic API used is + `kb0.O0OOOOOO00OOOOO000O0(UUID,[B)` = `RxBleConnection.writeCharacteristic` + (write request). There is **no write-no-response path** in this app and + therefore no different chunk size for one. (The one place a "no reply expected" + concept exists is protocol-level: `BaseCommand.O0OOOOOO00OOOOO000O0 == false` + makes the executor skip the write and return 1 immediately, + `vm$OO00000OOOOOOOO0000O.smali:92-99`.) + +UUID roles, for the record (decrypted literals, final assignments in ``): + +| Field | Value | Used as | Citation | +|---|---|---|---| +| `op.OO00000OOOOOOOO0000O` | `6e400002-b5a3-f393-e0a9-e50e24dcca9e` | **write** target of the fragmentation loop | `op.smali:450-457`, used `mn.smali:842` | +| `op.OOOOOOO0OOOOO0O00OO0` | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` | notify channel armed by the handshake | `op.smali:459-466`, used `po.smali:1290-1296` | +| `op.O00OOO0O00O00OOO0000` | `00000001-0000-1000-8000-00805f9b34fb` | notify channel armed by `fo`; write target in `wn` | `op.smali:167-174`, used `fo.smali:59-65`, `wn.smali:118,144` | +| `op.OO00O0O0O0000000000O` | `00000002-0000-1000-8000-00805f9b34fb` | read before arming `fo` | `op.smali:176-183`, used `po.smali:1415-1421` | +| `op.OO0OOOOOOOO00000O0O0` | `00000010-0000-1000-8000-00805f9b34fb` | keep-alive writes | `op.smali:194-201`, used `wn.smali:53,89` | +| `op.O0O000OOOO0000OOOO0O` | `00000010-0000-1000-8000-00805f9b34fb` | second field, same value, unused in this area | `op.smali:213-220` | +| `op.OO00OOOOO00OO00OOOO0` | `00000019-0000-1000-8000-00805f9b34fb` | auth/AVDTP | `op.smali:222-229` | +| `op.O0OOOOOO00OOOOO000O0` | `00000014-…` then **overwritten** with `new String(Base64.decode(ScooterInformationActivity.BluetoothItem()), UTF-8)` | model-dependent string | `op.smali:158-165` then `:424-448` | + +Which of the six `vm` implementations is used is decided per model/protocol in +`po` (`po.smali:140-245`): `kn` at `:194`, `mn` at `:217`, `nn` at `:235`, `fn` +at `:169`, `gn`/`bn` at `:298`/`:415`. The condition for `mn` is an equality test +on the app's protocol-family int (`po.smali:210-214`); mapping that int to "M365" +is AREA 1/2's result, not established here — see §9. + +--- + +## 7. Error handling + +### 7.1 Classification + +| Error class | Where classified | What happens | Citation | +|---|---|---|---| +| `BleAlreadyConnectedException` | `qo.smali:59-67` | drop the cached `RxBleClient` (`op.OO0OO0OOO0O0OOO0OO0O = null`) | `qo.smali:59-67` | +| `BleDisconnectedException`, GATT status `0x85` (133) | `qo.smali:70-82` | drop client, re-scan ≤1 s, re-acquire the device handle, dispose the dead subscription, then full teardown | §3 | +| any other connection error | `lo.smali:38-80` | log `"Error onConnectionFailure"`, clear queues, dispose everything, teardown | `lo.smali:38-80` | +| connection-state stream error | `bo.smali:36-72` | synthesise `DISCONNECTED` onto the EventBus, reset the protocol's reply holder | `bo.smali:36-72` | +| command issued while not CONNECTED | `op.smali:4203-4257` | Crashlytics `RuntimeException("Not Connected" + cmd)` and return an empty Observable | `op.smali:4223-4263` | +| same, via the 2-arg overload | `op.smali:4929-4947` | toast `"Not connected"` | `op.smali:4930-4943` | +| reply timeout | `vm$OO00000OOOOOOOO0000O.smali:233-274` | if the command is marked optional → return `-3`; else rethrow `TimeoutException` | `vm$OO00000OOOOOOOO0000O.smali:237-274` | +| `WriteSNException` | `vm$OO00000OOOOOOOO0000O.smali:198-231` | if optional → return `1`; else rethrow | `vm$OO00000OOOOOOOO0000O.smali:198-231` | +| transport busy | `vm$OO00000OOOOOOOO0000O.smali:176-191` | silently return `0` (command dropped) | `vm$OO00000OOOOOOOO0000O.smali:176-191` | +| malformed / unexpected reply frame | `BaseCommand$O000000O000000000OO0.smali:119-401` | throws `InvalidParameterException` with a formatted message (address + length) | `BaseCommand$O000000O000000000OO0.smali:162-202` | +| scan failure | `lc.smali:54-62`, `ao.smali:45` | read `BleScanException.status` and surface it | `lc.smali:54-62` | + +### 7.2 The protocol status→message table ("ScooterErrorCodes") + +`BaseCommand.` builds an `ImmutableMap` with nine entries +(`BaseCommand.smali:37-184`). Keys and plaintexts are read (keys decrypted with +`tools/m365tools_strings.py`): + +| Key | Message | Citation | +|---|---|---| +| 0 | `OK` | `BaseCommand.smali:52-58` | +| 1 | `Out of bounds` | `:67-73` | +| 2 | `Erase error` | `:82-88` | +| 3 | `Write error` | `:97-103` | +| 4 | `Not locked` | `:112-118` | +| 5 | `Invalid address` | `:127-133` | +| 6 | `Command in progress` | `:142-148` | +| 7 | `Invalid payload len` | `:157-163` | +| 8 | `Too many Errors or Retry attempts` | `:172-178` | + +`BaseCommand` also carries the retry budget: field `OOOOOOO0OOOOO0O00OO0:I`, +initialised to **3** in every constructor (`BaseCommand.smali:199-202`, +`:275-278`, `:329-332`), next to two booleans: `O0OOOOOO00OOOOO000O0:Z` = "expects +a response", default **true** (`:204-207`), and `OO00O0O0O0000000000O:Z` = +"tolerate failure", default **false** (`:209-210`). Note that a whole-file scan +of `op`, `vm` and the `BaseCommand` subclasses finds **no read** of the `3` +field: the number is set but the retry loop that consumes it is the `-2 → +repeat` loop inside the executor (`vm$OO00000OOOOOOOO0000O.smali:134-154`), which +is driven by the reply's status code, not by that constant. Treat "retry count 3" +as dead/unused until proven otherwise. + +**The only retry loop that exists** is per-command and unbounded in count: +`vm$OO00000OOOOOOOO0000O.smali:134-154` re-invokes +`command.OO00000OOOOOOOO0000O(waiter)` while the returned status is `-2` +("send again"), with `goto :goto_0` and no attempt counter. Since each iteration +carries the same 230 ms deadline, a scooter stuck answering `-2` would spin +inside one pump tick. There is no exponential backoff anywhere in the app: a +whole-tree search for `BASE_BACKOFF`-style constants finds no delay between +retries; the only delays are the interval constants in §4. + +**Fatal for the session?** Yes, for two classes: +* Any connection error → full teardown, queues cleared, handshake required again + (§3). +* A `TimeoutException` or `WriteSNException` on a **non-optional** command + propagates out of the `fromCallable`, killing that subscription; because the + pump's error handler is the empty consumer `zp0.OO00O0O0O0000000000O` + (`ho.smali:87-89`), the pump itself is **not** restarted — the Disposable stays + in `op.OO0OOO00O0O00O00OO00` and the session is silently dead until the next + disconnect/teardown. This is a real robustness defect worth not copying. + +--- + +## 8. Robustness measures on real hardware that a naive implementation misses + +1. **Settle delay after connect.** 600 ms between `CONNECTED` and the first poll + (`ScooterService.smali:3035-3037`). A naive implementation polls immediately. +2. **Reply queue cleared before every write** (`mn.smali:822-824`), so a stale or + late notification cannot be matched to the wrong command. Without it a single + lost reply permanently desynchronises a request/response protocol. +3. **Checksum-gated reply acceptance**, with mismatch causing "keep waiting" + rather than "accept garbage" (`mn.smali:219-226`; checksum one's-complement + 16-bit sum at `vm.smali:56-89`). +4. **One command in flight, globally.** A monitor + CAS on a static + `AtomicBoolean`, with the loser silently dropped rather than queued + (`vm$OO00000OOOOOOOO0000O.smali:59-75`). +5. **Bounded queue with an explicit drop policy** (>40 → clear, + `sp.smali:50-56`) so a stalled link cannot grow the queue without bound. +6. **A dedicated 10 ms dispatcher** decoupled from the 300 ms producer, which + keeps the write cadence off the UI/measurement cadence (`ho.smali:55-67`, + `ScooterService$O0OOOOOO00OOOOO000O0.smali:105-108`). +7. **Connect watchdog that degrades the UI** rather than retrying blindly: after + 15 s the foreground notification is withdrawn (`ScooterService.smali:3477-3479`). +8. **The GATT-133 workaround**: drop the client, re-scan, re-acquire the device + (`qo.smali:70-229`). Even though it does not reconnect by itself, refreshing + the `RxBleDevice` is what makes the *next* user-initiated connect work on + Android stacks that cache a bad handle. +9. **In-flight command abort on disconnect**: the state observer resets the + protocol's reply holder, so a command blocked in its wait loop cannot return a + reply that belongs to the next session (`no.smali:69-81`, `bo.smali:61-68`). +10. **Everything is torn down explicitly** on teardown: three Disposables, two + queues, all Handler callbacks, two counters and a state object + (`op.smali:3996-4187`). Nothing is left for a later session to trip over. +11. **Keep-alive traffic** every 3 s on the auth characteristic + (`yn.smali:137-144`, `wn.smali`), which keeps a session-scoped cipher state + fresh. +12. **`autoConnect = false`** — the app passes `false` to `establishConnection` + (`op.smali:2650`), i.e. it wants a direct, fast connect, not the background + auto-connect path. +13. **Per-command optionality** rather than a blanket error policy: commands can + be marked "tolerate timeout" and then report `-3` instead of aborting + (`vm$OO00000OOOOOOOO0000O.smali:237-274`). + +--- + +## 9. What the app does *not* do (findings as important as the positives) + +* **No automatic reconnection.** No `retryWhen`, no `repeatWhen`, no retry + counter that terminates. `lo` tears down and stops (§3). +* **No exponential backoff of any kind** — not on connect, not on commands, not + on the keep-alive. +* **No MTU negotiation and no MTU awareness.** 20-byte chunks, always (§6). +* **No write-no-response path**, hence no dual chunk-size arithmetic. +* **No heartbeat in the liveness sense**: the 3 s interval writes are fire and + forget, their errors are swallowed (`lp.smali:32-39`), and nothing concludes + "the link is dead" from a missing reply. +* **No per-write acknowledgement** in the fragmentation loop; the `Disposable` is + discarded (`mn.smali:858`). +* **No timeouts on `establishConnection`, `writeCharacteristic` or service + discovery** inside the BLE layer. Every deadline in this app is at the protocol + layer (230 ms) or the service layer (15 s watchdog). +* **No scan window** for the user-facing scan (`MainActivity.smali:699-739`). +* **No persistence of a session**: every connect re-handshakes (§3). +* **No use of `RxBleConnection` state beyond the two handled ordinals** — the + `CONNECTING` and `DISCONNECTING` events are ignored by the service + (`ScooterService.smali:2931-2943`). +* **No bounded retry for a `-2` ("again") reply** — the loop has no counter + (`vm$OO00000OOOOOOOO0000O.smali:134-154`). + +--- + +## 10. Comparison with the fork (`/home/kali/ScooterHacking/repo`) + +The fork already has three things the app does not: a real MTU negotiation, a +write-retry policy with backoff, and a bounded queue-free "one outstanding +request" polling loop with a failure counter. The gaps run the other way. + +| # | App behaviour | Fork state | Materia for a Xiaomi M365? | +|---|---|---|---| +| 1 | **Every GATT callback has a deadline.** The command layer waits at most 230 ms for a reply (`op.smali:4214`; `mn.smali:127-160`) and a reply-less command cannot hang the loop. | `BleManager.connect()` (`app/src/main/java/com/m365bleapp/ble/BleManager.kt:323-357`) suspends until `onServicesDiscovered` fires; `write()` with `waitForResponse = true` (`:365-414`) suspends until `onCharacteristicWrite` fires; `enableNotifications()` (`:418-455`) until `onDescriptorWrite` fires. **None of the three has a timeout**; if the callback never arrives the coroutine hangs and the "busy" guard (`:324-328`, `:366-370`, `:419-422`) then fails every later attempt. | **Yes — highest priority.** A M365 whose GATT cache is stale (or that powers off mid-connect) is exactly the case where `onServicesDiscovered` never arrives. | +| 2 | **15 s connect watchdog** that at least surfaces the failure (`ScooterService.smali:3477-3479`). | No equivalent; a stuck `connect()` has no upper bound. | Yes. | +| 3 | **GATT status 133 handling**: drop the client, re-scan, re-acquire the device (`qo.smali:70-229`). | `onConnectionStateChange` treats any non-zero status the same: `gatt.close()` and resume `null` (`BleManager.kt:179-186`). No 133 special case, and `gatt.close()` without dropping the `BluetoothDevice`/`connectGatt` state is the classic path to a repeat-133 loop. | **Yes** — 133 is the single most common M365 connect failure. The fork does refresh the GATT cache (`BleManager.kt:190-197`), which the app does not; keep that. | +| 4 | **Session keep-alive every 3 s** on the auth characteristic (`yn.smali:137-144`, `wn.smali:36-164`). | No keep-alive. | Likely yes: the Mi-auth session state is written on a 3 s cadence and a link that stops being touched is the usual reason a scooter-side session expires. Needs hardware confirmation (§11). | +| 5 | **Retry with backoff per ATT chunk** — the app has none, but the fork's `WriteRetryPolicy` is the better design (`app/src/main/java/com/m365bleapp/protocol/WriteRetryPolicy.kt:92-133`). | Present and used (`ScooterRepository.kt:1485-1520`). | Keep. Note the fork retries `MAX_ATTEMPTS_PER_CHUNK = 3` (`WriteRetryPolicy.kt:41`) where the app loops a `-2` status unbounded — the fork is stricter and should stay so. | +| 6 | **MTU never negotiated; fixed 20-byte chunks** (`mn.smali:832-864`). | Negotiated MTU captured in `onMtuChanged` and used via `MtuFragmenter` (`BleManager.kt:159-173`, `MtuFragmenter.kt:63-86`), with a reset to 23 on disconnect (`BleManager.kt:204-211`). | The fork is strictly better here. Nothing to copy from the app. | +| 7 | **Queue cleared before each command** (`mn.smali:822-824`) so a stale reply cannot be matched. | `ScooterRepository` uses `withTimeoutOrNull` per chunk (`:1186-1200`) and re-reads; stale-notification handling was not verified in this area. | Worth checking: on M365 the notify stream is shared, and a late reply is the usual cause of "one command behind" bugs. | +| 8 | **One command in flight globally**, with a blocking wait (`vm$OO00000OOOOOOOO0000O.smali:59-75`). | The fork's `write()` returns `false` when busy (`BleManager.kt:366-370`) rather than serialising; the repository serialises at a higher level. The app's stronger property is that the loser cannot proceed. | Yes — cheap to add and prevents interleaved frames. | +| 9 | **Explicit full teardown**: 3 Disposables, 2 queues, Handler callbacks, counters, session object (`op.smali:3996-4187`). | `BleManager` clears the continuation, the notify callback and resets MTU on disconnect (`BleManager.kt:200-227`) — good — but there is no equivalent "reset the protocol session state" hook. | Yes if a keep-alive/session counter is ever added. | +| 10 | **Settle delay** 600 ms after connect before polling (`ScooterService.smali:3035-3037`). | `delay(500) // Wait for connection to stabilize` exists at `ScooterRepository.kt:865`. | Already covered. | + +**The single biggest gap: the fork's BLE primitives have no deadlines.** The app +never relies on a callback arriving; every wait is bounded above by a wall-clock +deadline it controls (`mn.smali:127-160`) and every connection attempt is bounded +by a 15 s watchdog (`ScooterService.smali:3477-3479`). On an M365 — where +`onServicesDiscovered` silently never firing after a bad cache, and status 133, +are the two dominant failure modes — a `connect()` that can suspend forever and a +busy-guard that then rejects every retry is the difference between "reconnect +works on the second try" and "the app must be force-stopped". + +Second: **no GATT-133 recovery** (`qo.smali:70-229`). The fork closes the GATT +and stops; the app treats 133 as a distinct, recoverable class. + +--- + +## 11. Unverified / needs hardware + +No scooter and no phone are attached. Everything below is *not* established by +this analysis and must not be presented as measured behaviour: + +1. **All timing is nominal, not measured.** The 300 ms poll period, the 600 ms + settle delay, the 10 ms pump tick, the 3 s keep-alive period and the 15 s + watchdog are constants read from code. Their *effective* period under load is + unknown: the 10 ms pump blocks inside the command wait (up to 230 ms), so the + real dispatch rate is bounded by the link, not by 10 ms. No measurement was + possible. +2. **The 230 ms reply budget's adequacy is unverified.** Whether a real M365 + answers within 230 ms for every polled register is exactly the kind of thing + that can only be measured. The app's own design implies that a miss is + tolerated for commands marked optional (return `-3`), but which commands are + marked optional was not enumerated here. +3. **The 3 s keep-alive's necessity and effect are unverified.** Whether the + M365 requires it, tolerates it, or whether it is only needed for the + Ninebot/newer families that use the `00000010`/`00000001` characteristics + (`wn.smali:51-160`) is not determinable statically. +4. **Which `vm` implementation the M365 uses is not established here.** `mn` + (the implementation whose fragmentation loop is documented in §6) is selected + by one protocol-family constant at `po.smali:210-229`; mapping that constant + to `M365` belongs to AREA 1/2 and was not re-derived. If the M365 turns out + to use a different `vm`, the chunk size may differ (each of `mn`, `nn`, `fn`, + `gn`, `bn`, `kn` has its own transmit method). Only `mn`'s `0x14` chunk size + was read. +5. **Whether the app really never requests an MTU** rests on the absence of the + API in the R8-pruned `kb0` interface plus zero `mtu` strings. That is strong + negative evidence but it is still an argument from absence: a reflection-based + or library-internal MTU request would not appear in `kb0`. Worth a runtime + `hci snoop log` to confirm. +6. **The state machine's UI-visible states are inferred.** The four + `RxBleConnectionState` constants are read; the mapping of "scanning / + discovering / handshaking" onto field-presence side effects (§2.1) is this + report's model of the code, not a structure the app names anywhere. +7. **Ordering between `qo` (doOnEach onError) and `lo` (subscribe onError) on a + connection failure** is inferred from Rx semantics (`doOnEach` sits upstream of + `subscribe`), not observed. If they run in the other order the teardown in `lo` + would precede the re-scan scheduled by `qo`. +8. **Whether the "retry count 3" field is truly unused** rests on a textual scan + for reads of `BaseCommand.OOOOOOO0OOOOO0O00OO0`. R8 could have inlined a read + in a way that scan missed; the negative is probable, not certain. +9. **The claim that a command timeout permanently kills the pump** (the empty + pump error consumer at `ho.smali:87-89`) is read from code but its runtime + consequence — whether a later 300 ms tick re-enqueues enough to recover, or + the session stays silently dead — cannot be settled without a device. +10. **The chunk loop's reliance on RxAndroidBle's internal serialisation** is an + inference about the library's behaviour, not something this app's code + states. On hardware the loop must be re-checked for interleaving. diff --git a/doc/reverse-engineering/m365tools-reports/07-model-component-addressing.md b/doc/reverse-engineering/m365tools-reports/07-model-component-addressing.md new file mode 100644 index 0000000..6d3ec54 --- /dev/null +++ b/doc/reverse-engineering/m365tools-reports/07-model-component-addressing.md @@ -0,0 +1,149 @@ +# 07 — Per-model board addressing (the transferable mechanism) + +**Scope.** How M365 Tools 1.8.0 (`app.peretti.m365tools`) decides *which address to +send a command to*, and *which address a reply comes back on*, for each scooter +model. This is the single most transferable finding in the whole RE, because it is +exactly the thing M365-Rokid-HUD hard-codes. + +All citations are smali under +`/home/kali/ScooterHacking/re/m365tools/apktool-out/smali/`. Literals were recovered +with `tools/m365tools_strings.py` (clean-room reimplementation of the app's string +decryptor); see `CONTEXT.md`. + +## 1. The addressing record + +`dalvik/O/O/a/b/O/c/b/O/o/wm.smali` is a three-field record. Its `toString()` +(`wm.java`, which jadx renders in full) names the fields, so their meaning is read, +not inferred: + +| Field | Type | `toString()` label | Meaning | +|---|---|---|---| +| `OO00000OOOOOOOO0000O` | `xm` | `mType=` | which board: CTL / BLE / BMS / BMS2 / UPC / ANCHOR / TAG / PTZ | +| `OOOOOOO0OOOOO0O00OO0` | `byte` | `mID=` | the address the app **sends to** | +| `O0OOOOOO00OOOOO000O0` | `byte` | `mReceiveID=` | the address a reply from that board **arrives from** | +| `O00OOO0O00O00OOO0000` | `int` | `VersionMaskCode =` | initialised to `4095` (`0xFFF`); the version filter described below | + +Evidence: `wm.java` `toString()`; constructor at `wm.smali`. + +## 2. The reverse lookup + +`zm.smali:1752` — `zm.O00OOO0O00O00OOO0000(int) -> wm` walks the model's board list +and returns the first entry whose **`mID` or `mReceiveID`** equals the argument: + +```smali +zm.smali:1777 iget-byte v2, v1, L.../wm;->OOOOOOO0OOOOO0O00OO0:B # mID +zm.smali:1781 if-eq v2, v3, :cond_1 +zm.smali:1784 iget-byte v2, v1, L.../wm;->O0OOOOOO00OOOOO000O0:B # mReceiveID +zm.smali:1786 if-ne v2, v3, :cond_0 +``` + +and for one special address it synthesises a record on the fly: + +```smali +zm.smali:1792 const/16 v0, 0x3e +zm.smali:1802 invoke-direct {p1, v1, v0, v0}, L.../wm;->(L.../xm;BB)V +``` + +**`0x3E` (62) is a special address that is both a valid send and receive id**, and its +record is created rather than stored. *Cross-check:* the independently reverse +engineered Scootbatt app uses `0x3E` as the leading byte of every request envelope — +two unrelated apps agreeing on `0x3E` is strong corroboration that this is a +protocol constant, not an artefact of one app's design. + +So the app can translate an address in **either** direction: given a received frame's +source address it can still name the board that produced it. That is what makes +per-model addressing work without a model-specific parser per board. + +## 3. The table, per model + +Extracted mechanically by `tools/model_table.py` into +`analysis/model-table.md` / `analysis/model-table.json`. `a` = `mID`, `b` = `mReceiveID`. + +| Model (`id` string) | Boards (`mType(mID, mReceiveID)`) | +|---|---| +| `MI_SCOOTER_PRO` / `_PRO2` / `_1S` / `_1S_DE` / `_LITE` / `_3` (`M365*`) | CTL(32,35) BLE(33,36) BMS(34,37) | +| `ESCOOTER` (`M365`) | CTL(32,35) BLE(33,36) BMS(34,37) | +| `SCOOTER_G30` (`MAX`) | CTL(32,32) BLE(33,33) BMS(34,34) BMS2(35,35) | +| `SCOOTER2` (`ESx`) | CTL(32,32) BLE(33,33) BMS2(35,35) | +| `SCOOTER2_P` (`ESx_P`) | CTL(32,32) BLE(33,33) BMS2(35,35) | +| `F_Series30` / `F_Series60` (`F`) | CTL(32,32) BLE(33,33) BMS(34,34) | +| `MINI` (`Mini`) | CTL(10,13) BLE(11,14) BMS(12,15) | +| `NANO` (`MiniLite`) | CTL(36,36) BLE(38,38) BMS(39,39) | +| `MARK2` (`Mark2`) | CTL(17,17) BLE(11,14) BMS(19,19) BMS2(20,20) | +| `MARK3` (`Z`) | CTL(20,20) BLE(22,22) BMS(17,17) BMS2(18,18) UPC(8,8) | +| `VIO` (`S-Plus`) | CTL(4,4) BLE(6,6) BMS(7,7) UPC(8,8) ANCHOR(9,9) TAG(10,10) PTZ(12,12) | +| `MARK2`-era / `KART*` / `MINI_MAX` / `MINI_KIDS*` / `STEELDUST` / `MEBIKE` / `WILD_STEEL_DUST` / `SCOOTER_AIR` | no board list in the constructor form used (see §5) | + +Two structural observations, both load-bearing: + +1. **The Xiaomi family and the plain `M365` id share one identical table.** CTL 32/35, + BLE 33/36, BMS 34/37. Whatever distinguishes `M365_3` from `M365_1S` on the wire, it + is *not* the board addressing. +2. **A `+3` offset is the Xiaomi signature** (`32→35`, `33→36`, `34→37`). Every Ninebot + family in the table uses `send == receive` instead. That single bit — does the model + echo on a different address — separates the two addressing conventions. + +## 4. What this means for M365-Rokid-HUD + +`repo/ninebot-ble/src/model/mod.rs:127` currently hard-codes the send address: + +```rust +impl Board { + pub fn address(self) -> u8 { + match self { Board::Esc => 0x20, Board::Bms => 0x22 } + } +} +``` + +That is `mID` for one family only, and `mReceiveID` — the address replies arrive on — has +no representation anywhere in the crate. Consequences: + +- The `+3` convention that every Xiaomi model in this table uses is not modelled. If a + reply is matched by source address anywhere, it is matched against the wrong value. +- `Board` is a two-variant enum (Esc/Bms). The app's `xm` has at least seven components, + including a **second battery** (`BMS2`) that the fork's `ModelCapabilities` already + anticipates but cannot address, and non-scooter components (`UPC`, `ANCHOR`, `TAG`, + `PTZ`) that the fork does not need to support. +- Adding a model currently means copying a whole `ModelProfile`. With an addressing table + it becomes adding one row. + +The suggested shape is a per-model `&'static [BoardAddress]` of +`{ component, send_id, receive_id }` on `ModelProfile`, with `Board::address()` replaced +by a lookup that takes the model. That is a strictly smaller change than a new profile +per model, and it is the mechanism the reference implementation actually uses. + +## 5. What the app does *not* keep + +`qn` (`dalvik/O/O/a/b/O/o/qn.smali`) is constructed as +`qn(rn type, int[] startVersions, xm[] boardTypes)` — and the primary constructor +**stores only `type`**: + +```smali +qn.smali .field public final OO00000OOOOOOOO0000O:Ldalvik/O/O/a/b/O/c/b/O/o/rn; +``` + +`startVersions` and `boardTypes` are null-checked and then discarded. The arrays still +exist at the call sites — `zm.java:78` allocates `new int[326]` and `zm.java:78` again +`new int[534]`, and `F_Series30`/`F_Series60` carry a 534-entry table — so this build +carries **dead** per-command version/board gating tables. Do not model them as live +behaviour; do not read a per-command version gate into this app's design on the strength +of those arrays. + +`rn` (37 constants) carries **no** string fields at all (`grep -c 'vl0->' rn.smali` → 0), +so command semantics are not recoverable from the enum; they must come from the +`BaseCommand` implementations in the unobfuscated app package (see report 05). + +## Unverified / needs hardware + +- `mID`/`mReceiveID` semantics are **read** from `wm.toString()` and the reverse-lookup + at `zm.smali:1784`. How the *framing* layer consumes them — which byte of an outgoing + frame carries `mID`, and which byte of an incoming frame is compared against + `mReceiveID` — was **not** established here and is the next thing to pin down. +- No frame has been observed on a wire. The `+3` convention is a table entry, not a + captured behaviour. **Nothing here should be treated as verified against hardware.** +- The board tables for `KART*`, `MINI_MAX`, `MINI_KIDS*`, `STEELDUST`, `MEBIKE`, + `WILD_STEEL_DUST` and `SCOOTER_AIR` are absent from the constructor form this + extraction reads. They may be defined in the anonymous subclass initialisers that the + extractor did not attribute, or they may genuinely have no board list. Unresolved. +- Only a Xiaomi M365 is available for testing. Every Ninebot row above is + documentation-grade. diff --git a/doc/reverse-engineering/m365tools-reports/08-xiaomi-family-uniformity.md b/doc/reverse-engineering/m365tools-reports/08-xiaomi-family-uniformity.md new file mode 100644 index 0000000..0fdf198 --- /dev/null +++ b/doc/reverse-engineering/m365tools-reports/08-xiaomi-family-uniformity.md @@ -0,0 +1,77 @@ +# 08 — The Xiaomi family is one protocol in the reference implementation + +**Scope.** What distinguishes the six Xiaomi entries in M365 Tools 1.8.0's device +table (`zm`) from one another. This is the evidence base for any claim that a +given Xiaomi model shares the M365 register layout — the exact question +`ninebot-ble/src/model/mod.rs`'s `MI3` doc comment says is unanswered. + +Source: `jadx-out-nodeobf/sources/dalvik/O/O/a/b/O/c/b/O/o/zm.java`, literals +decrypted with `tools/m365tools_strings.py`. Method: each model constant is +constructed by an anonymous subclass; its instance-initialiser body was extracted, +every encrypted literal replaced by `LIT`, every local name normalised, and the +ordinal masked, then the bodies were compared character by character. + +## Finding + +All six Xiaomi entries are **structurally identical** apart from their name, their +serial prefix and one per-model scalar pair: + +| Model | Boards | Commands | per-model scalar (`int i` / `int i2`) | +|---|---|---|---| +| `MI_SCOOTER_1S` | CTL(32,35) BLE(33,36) BMS(34,37) | 9, same set and order | 43 | +| `MI_SCOOTER_1S_DE` | identical | identical | 37 | +| `MI_SCOOTER_LITE` | identical | identical | 41 | +| `MI_SCOOTER_PRO2` | identical | identical | 40 | +| **`MI_SCOOTER_3`** | **identical** | **identical** | **46** | +| `MI_SCOOTER_PRO` | identical | *differs* — a different `qn` list | — | + +The command list, in order, is the same nine `rn` constants for 1S, 1S_DE, Lite, +Pro2 and **Mi 3**: + +``` +rn.OOOO00O0OOO0OOOOO000, rn.O00000OOO0O0O00O0O0O, rn.OOO0O0OO0O00O00O00O0, +rn.O0OO000O00O0O00O0O0O, rn.OO0O00OO000000000OO0, rn.OO0O0OO00000O0O00O00, +rn.O0OO000O0000O0O00O00, rn.OOOO0OO00OO0O0O00000, rn.O0OOOO0000O0OO0O00O0 +``` + +`MI_SCOOTER_PRO` is the exception: it carries a different command list, so the Pro +is *not* interchangeable with the rest in this app's model of the world — even +though the board addressing is the same. + +## Why this matters to M365-Rokid-HUD + +`ninebot-ble/src/model/mod.rs` gives `Mi3` `Confidence::Unverified` and no fields, +with this stated reason: + +> **Its BLE protocol was never confirmed**, and Xiaomi's own firmware naming groups +> it with the 1S/Pro2 lineage without evidence that the register block is identical. + +The reference implementation is a second, independent source, and it does more than +group Mi 3 with the lineage by naming: it gives it the *same board addressing and +the same command set in the same order* as the 1S, differing only in a scalar. +That is materially stronger than the firmware-naming argument the doc rejects. + +**But it is evidence about commands, not about response offsets.** Which registers +the app reads is settled by this table; what the bytes in the replies *mean* is +decided in the command handlers, which this comparison did not read. So the honest +position is: + +- Mi 3's **addressing and command set** are now documented → `Documented`, not + `Verified` (no capture exists, exactly like the 1S and Lite rows the fork already + marks `Documented` with "no capture"). +- Whether it decodes the same **field offsets** needs the command handlers, and is + the thing to check before promoting it further. + +Note the asymmetry the fork already relies on: 1S and Lite are `Documented` *without +any capture*, so requiring a capture for Mi 3 would be a stricter bar than the one +the same family already meets. + +## Unverified / needs hardware + +- No frame has been exchanged with any Xiaomi scooter here. This is a static + comparison of one app's model table. +- The per-model scalar (43 / 40 / 46 / …) is not identified. It is almost certainly + a model or firmware code, but nothing in this pass establishes that, and it is + **not** claimed to be a protocol version. +- `MI_SCOOTER_PRO`'s differing command list is observed, not explained. +- A Xiaomi M365 is the only hardware available, and it is not a Mi 3. diff --git a/doc/reverse-engineering/m365tools-reports/09-device-table.md b/doc/reverse-engineering/m365tools-reports/09-device-table.md new file mode 100644 index 0000000..824fa39 --- /dev/null +++ b/doc/reverse-engineering/m365tools-reports/09-device-table.md @@ -0,0 +1,41 @@ +# M365 Tools device table (`zm` enum) + +Extracted from `jadx-out-nodeobf/.../zm.java` with literals decrypted by +`tools/m365tools_strings.py`. The enum name, label and serial prefix are read +from the constructor; `components` are `wm(xm., a, b)` triples. + +| ord | enum constant | label | id / serial prefix | scalars | components (a,b) | commands | int[] tables | +|---|---|---|---|---|---|---|---| +| 0 | `OOO0O0OO0O00O00O00O0` | | `` | [0, 0, 0, 64] | — | OOO0O0OO0000O0O0000O, O0OOOO0O000000OO0OO0, OO0O0OO00000O0O00O00, OOOO00OOO00O000O0OOO, OOOO0OO00OO0O0O00000 | 326, 534 | +| 1 | `OOO000O00000OO000O0O` | ONE | `One` | [1, 2, 2, 64] | — | O000O0000O00OOOO00O0 | — | +| 2 | `O00O00O00O000OO0O0OO` | MINI | `Mini` | [2] | CTL(10,13), BLE(11,14), BMS(12,15) | OOOO00O0OOO0OOOOO000, OO0O0O0OO0OOO0OO0000, O000O0000O00OOOO00O0 | — | +| 3 | `O0000OOO0O0OOOOO0O0O` | MINI_MAX | `MiniMax` | [3] | — | — | — | +| 4 | `O0000O00OO00OOOO0000` | MARK2 | `Mark2` | [4] | CTL(17,17), BLE(11,14), BMS(19,19), BMS2(20,20) | O000O0000O00OOOO00O0 | — | +| 5 | `OO00O0O0O0000OO00OO0` | ESCOOTER | `M365` | [5] | CTL(32,35), BLE(33,36), BMS(34,37) | OOOO00O0OOO0OOOOO000, O00000OOO0O0O00O0O0O, OOO0O0OO0O00O00O00O0, O0OO000O00O0O00O0O0O, OO0O00OO000000000OO0, O0OO000O0000O0O00O00 | — | +| 6 | `O00000OOO0O0O00O0O0O` | VIO | `S-Plus` | [6] | CTL(4,4), BLE(6,6), BMS(7,7), UPC(8,8), ANCHOR(9,9), TAG(10,10), PTZ(12,12) | OOOO00O0OOO0OOOOO000, OOO0O0OO0O00O00O00O0, O0OO000O00O0O00O0O0O, OO0O0O0OO0OOO0OO0000 | — | +| 7 | `O0OO0O00O0OOOO0OO0OO` | KART | `N4MZ` | [7] | — | — | — | +| 8 | `OO0O0O0OOO0OOO00OO00` | KART_PRO | `KartPro` | [8] | — | — | — | +| 9 | `O0O000O0O0OOO0OO00O0` | KART_PRO_LAMBO | `KartProLambo` | [9, 73, 50, 112] | — | — | — | +| 10 | `O00000000OOOOOOOOO00` | NANO | `MiniLite` | [10] | CTL(36,36), BLE(38,38), BMS(39,39) | OOOO00O0OOO0OOOOO000, OO0O0O0OO0OOO0OO0000 | — | +| 11 | `O00O0O000O0000OO0OOO` | MINI_KIDS_MI | `MiniKidsMi` | [11, 36, 23, 64] | — | — | — | +| 12 | `OOOOO00OOO0O00OO0O0O` | MINI_KIDS | `MiniKids` | [12, 37, 25, 64] | — | — | — | +| 13 | `O0OOOO0O000000OO0OO0` | MARK3 | `Z` | [13] | CTL(20,20), BLE(22,22), BMS(17,17), BMS2(18,18), UPC(8,8) | OOOO00O0OOO0OOOOO000, OOO0OO0O0O00OO000000 | — | +| 14 | `OOO0O0OO0000O0O0000O` | SCOOTER2 | `ESx` | [14] | CTL(32,32), BLE(33,33), BMS2(35,35) | OOOO00O0OOO0OOOOO000, O00000OOO0O0O00O0O0O, O0OO000O00O0O00O0O0O, OOO0O0OO0000O0O0000O, O0O000OO0OOO0OO000O0, OOO0OO0O0O00OO000000, OO0O00OO000000000OO0, OO0O0OO00000O0O00O00, OOOO00OOO00O000O0OOO, OO0O0O0OO0OOO0OO0000, O0OO000O0000O0O00O00, OOOO0OO00OO0O0O00000, O0OOOO0000O0OO0O00O0 | 326 | +| 15 | `OO0OOOO00OO0O0OOO00O` | SCOOTER2_P | `ESx_P` | [15] | CTL(32,32), BLE(33,33), BMS2(35,35) | OOOO00O0OOO0OOOOO000, O00000OOO0O0O00O0O0O, OOO0O0OO0O00O00O00O0, O0OO000O00O0O00O0O0O, OOO0O0OO0000O0O0000O, O0OOOO0O000000OO0OO0, O0O000OO0OOO0OO000O0, OOO0OO0O0O00OO000000, OO0O00OO000000000OO0, OO0O0OO00000O0O00O00, OOOO00OOO00O000O0OOO, OO0O0O0OO0OOO0OO0000, OOOO0OO00OO0O0O00000, O0OO000O0000O0O00O00 | 326, 534 | +| 16 | `O00000OO0OOOOO00OOOO` | MI_SCOOTER_PRO | `M365PRO` | [16] | CTL(32,35), BLE(33,36), BMS(34,37) | OOOO00O0OOO0OOOOO000, O00000OOO0O0O00O0O0O, OOO0O0OO0O00O00O00O0, O0OO000O00O0O00O0O0O, OO0O00OO000000000OO0, O0OO000O0000O0O00O00 | — | +| 17 | `OO0OOO00O0O00O00OO00` | MI_SCOOTER_1S_DE | `M365_1S_DE` | [17] | CTL(32,35), BLE(33,36), BMS(34,37) | OOOO00O0OOO0OOOOO000, O00000OOO0O0O00O0O0O, OOO0O0OO0O00O00O00O0, O0OO000O00O0O00O0O0O, OO0O00OO000000000OO0, OO0O0OO00000O0O00O00, O0OO000O0000O0O00O00, OOOO0OO00OO0O0O00000, O0OOOO0000O0OO0O00O0 | — | +| 18 | `O0O000OO0OOO0OO000O0` | MI_SCOOTER_LITE | `M365_Lite` | [18] | CTL(32,35), BLE(33,36), BMS(34,37) | OOOO00O0OOO0OOOOO000, O00000OOO0O0O00O0O0O, OOO0O0OO0O00O00O00O0, O0OO000O00O0O00O0O0O, OO0O00OO000000000OO0, OO0O0OO00000O0O00O00, O0OO000O0000O0O00O00, OOOO0OO00OO0O0O00000, O0OOOO0000O0OO0O00O0 | — | +| 19 | `O0OO000O00O0O00O0O0O` | MI_SCOOTER_1S | `M365_1S` | [19] | CTL(32,35), BLE(33,36), BMS(34,37) | OOOO00O0OOO0OOOOO000, O00000OOO0O0O00O0O0O, OOO0O0OO0O00O00O00O0, O0OO000O00O0O00O0O0O, OO0O00OO000000000OO0, OO0O0OO00000O0O00O00, O0OO000O0000O0O00O00, OOOO0OO00OO0O0O00000, O0OOOO0000O0OO0O00O0 | — | +| 20 | `OOOOOO00O00000OOO0OO` | MI_SCOOTER_PRO2 | `M365_PRO2` | [20] | CTL(32,35), BLE(33,36), BMS(34,37) | OOOO00O0OOO0OOOOO000, O00000OOO0O0O00O0O0O, OOO0O0OO0O00O00O00O0, O0OO000O00O0O00O0O0O, OO0O00OO000000000OO0, OO0O0OO00000O0O00O00, O0OO000O0000O0O00O00, OOOO0OO00OO0O0O00000, O0OOOO0000O0OO0O00O0 | — | +| 21 | `OO0O0O0OO0OOO0OO0000` | MI_SCOOTER_3 | `M365_3` | [21] | CTL(32,35), BLE(33,36), BMS(34,37) | OOOO00O0OOO0OOOOO000, O00000OOO0O0O00O0O0O, OOO0O0OO0O00O00O00O0, O0OO000O00O0O00O0O0O, OO0O00OO000000000OO0, OO0O0OO00000O0O00O00, O0OO000O0000O0O00O00, OOOO0OO00OO0O0O00000, O0OOOO0000O0OO0O00O0 | — | +| 22 | `OO0000O000OO0OO000O0` | WILD_STEEL_DUST | `Wild_Steel_Dust` | [22] | — | — | — | +| 23 | `OOO0OO0O0O00OO000000` | SCOOTER_G30 | `MAX` | [23] | CTL(32,32), BLE(33,33), BMS(34,34), BMS2(35,35) | OOOO00O0OOO0OOOOO000, O00000OOO0O0O00O0O0O, OOO0O0OO0O00O00O00O0, O0OO000O00O0O00O0O0O, O0O000OO0OOO0OO000O0, OO0O00OO000000000OO0, OO0O0OO00000O0O00O00, OOOO00OOO00O000O0OOO, O0OO000O0000O0O00O00, OOOO0OO00OO0O0O00000, O0OOOO0000O0OO0O00O0 | — | +| 24 | `O000O0000O00OOOO00O0` | STEELDUST | `Steeldust` | [24, 8, 64, 112] | — | — | — | +| 25 | `OO0O0OO00000O0O00O00` | MEBIKE | `Mebike` | [25, 10, 65, 112] | — | — | — | +| 26 | `OOOO00OOO00O000O0OOO` | SCOOTER_AIR | `T15` | [26, 58, 35, 64] | BMS2(35,35) | — | — | +| 27 | `O0OO000O0000O0O00O00` | F_Series30 | `F` | [27] | CTL(32,32), BLE(33,33), BMS(34,34) | OOOO00O0OOO0OOOOO000, O00000OOO0O0O00O0O0O, OOO0O0OO0O00O00O00O0, O0OO000O00O0O00O0O0O, O0OOOO0O000000OO0OO0, O0O000OO0OOO0OO000O0, OO0O00OO000000000OO0, OOOO0OO00OO0O0O00000, O0OOOO0000O0OO0O00O0, O0OO000O0000O0O00O00 | 534 | +| 28 | `OO0O00OO000000000OO0` | F_Series60 | `F` | [28] | CTL(32,32), BLE(33,33), BMS(34,34) | OOOO00O0OOO0OOOOO000, O00000OOO0O0O00O0O0O, OOO0O0OO0O00O00O00O0, O0OO000O00O0O00O0O0O, O0OOOO0O000000OO0OO0, O0O000OO0OOO0OO000O0, OO0O00OO000000000OO0, OOOO0OO00OO0O0O00000, O0OOOO0000O0OO0O00O0, O0OO000O0000O0O00O00 | 534 | + +## Comparison with M365-Rokid-HUD's model list + +See `doc/MODEL_SUPPORT.md` in the fork for the current list. \ No newline at end of file diff --git a/doc/reverse-engineering/m365tools-reports/README.md b/doc/reverse-engineering/m365tools-reports/README.md new file mode 100644 index 0000000..bd65322 --- /dev/null +++ b/doc/reverse-engineering/m365tools-reports/README.md @@ -0,0 +1,66 @@ +# m365 Tools 逆向 — 總索引與對本專案的結論 + +> 目標:`app.peretti.m365tools` **m365 Tools 1.8.0** (versionCode 180) +> 來源 APK:APKPure,sha256 `4451a47c86339b8f88d77bdad7aef77e1bc4f11b7c728a5803f2b1d342a16872` +> 方法:jadx 1.5.1 + apktool smali 交叉驗證;字串表以 clean-room 重實作的解密器還原 +> (該工具留在分析工作區,未進版控 — 本目錄只收結論) +> **所有結論均為靜態分析,尚未經任何實車驗證。** 唯一可用的硬體是一台 Xiaomi M365。 + +## 報告導覽 + +| 檔案 | 內容 | 狀態 | +|---|---|---| +| `01-scan-and-identification.md` | **連線前識別**:`0x424E` manufacturer data 的 `mfr[0]`/`mfr[1]`、掃描設定、fe95 只是活性計數 | ✅ 已交叉驗證 | +| `02-gatt-selection.md` | UUID 全表、**跨 service 的 characteristic 找法**、notify-only、MTU 從不協商 | ✅ 已交叉驗證 | +| `03-handshake-and-auth.md` | **完整握手序列**、四種協定家族、`AuthToken = SHA-256(MAC)`、mible ECDH、RC4 | ✅ 已交叉驗證 | +| `04-register-maps.md` | 各緩衝區逐欄位 offset、單位、模型差異 | ✅ 已交叉驗證 | +| `05-write-commands.md` | 寫入命令清單、**checksum 演算法**、三種 framing、模型能力閘門、風險 | ✅ 已交叉驗證 | +| `06-connection-state-machine.md` | 狀態、重連、逾時表、輪詢幫浦、錯誤分類 | ✅ 已交叉驗證 | +| `07-model-component-addressing.md` | **每個車種的送/收位址**(`mID`/`mReceiveID`) | ✅ 本專案自行驗證 | +| `08-xiaomi-family-uniformity.md` | 六個 Xiaomi 車種在參考實作中是同一套協定 | ✅ 本專案自行驗證 | +| `09-device-table.md` | 29 個車種的元件位址/指令集/欄位表(機器抽取) | ✅ 本專案自行驗證 | + +--- + +## 1. 三句最重要的話 + +1. **協定是在連線前就決定的,不是連線後探測的。** 車種碼與協定版本來自廣告中 + company id `0x424E` 的 manufacturer data:`mfr[0]` = 車種碼、`mfr[1]` = 協定版本 + (`hc.smali:341-361`、`ic.smali:184-210`)。這推翻了本專案「掃描無法決定協定」的前提。 +2. **同一個 characteristic 不一定掛在預期的 service 底下。** 參考實作**從不**用 + `getService(UUID)`;它只用 characteristic UUID 找,讓 BLE 層跨所有 service 列舉 + (`mb0.smali:42-92`)。它也從不把 `0000fe95` 當 service 用 —— 只讀它的廣告 service-data。 +3. **認證是可重現的。** `AuthToken = SHA-256()[0..16]`(`po.smali:41-93`), + 沒有任何伺服器或配對祕密參與,因此第三方實作能自行算出並完成握手。 + +## 2. 與本專案直接相關的落差 + +| # | 參考實作 | 本專案現況 | 影響 | +|---|---|---|---| +| 1 | characteristic 跨 service 找 | `BleManager` 以 `getService(AUTH_SERVICE)` 取 `00000010`/`00000019` | 若裝置把它們掛在別的 service 底下,會**靜默**失敗。已於本次修正 | +| 2 | 要求 `PROPERTY_NOTIFY` | `GattProfileDiscovery` 接受 NOTIFY 或 INDICATE,但 `BleManager` 一律寫入 notify 值 | 選到 indicate-only 通道時會用錯的 CCCD 值 | +| 3 | 每個等待都有上限(命令 230 ms、連線 15 s watchdog) | `BleManager.connect()`/`write()`/`enableNotifications()` **沒有逾時** | **最高優先**:GATT cache 失效時 `onServicesDiscovered` 不來,coroutine 永久懸置,之後 busy-guard 拒絕所有重試 | +| 4 | GATT 133 視為可恢復 | 任何非零 status 都 `gatt.close()` | 133 是 M365 最常見的連線失敗 | +| 5 | 遙測為**輪詢**,300 ms 排程 + 10 ms 幫浦,一次一個命令 | 已有序列化 | 概念一致 | +| 6 | MTU 從不協商,固定 20 byte | 已協商並用 `MtuFragmenter` | **本專案較好**,勿改 | +| 7 | 每個 ATT chunk 不重試 | 已有 `WriteRetryPolicy` 退避 | **本專案較好**,勿改 | + +## 3. 兩份參考實作互相矛盾之處(勿單方面改動) + +- **`0x7D` 的寫入位元組序**:Scootbatt 用大端(本專案跟隨),m365 Tools 用小端 + (`ByteBuffer.allocate(2).order(LITTLE_ENDIAN)`)。兩者讀取都是小端。 + 在 `ScooterSettingsWriter.statusWordWrite` 留有對照表,需以硬體裁決。 +- **`AuthToken` 是同名不同物**:參考實作的 16-byte `AuthToken`(SHA-256(MAC))**不是** + 本專案 `mi_crypto.rs` 的 12-byte HKDF token。不可合併。 + +## 4. 未驗證(最重要的一節) + +1. **沒有任何一項經過實車驗證。** 沒有滑板車、沒有手機、沒有藍牙擷取。 +2. `0x424E` 廣告 payload 在真車上的形狀從未被捕捉過 —— `mfr[0]`/`mfr[1]` 的語意 + 是「讀到用法、推論名稱」。 +3. `protocolVersion` 0–5 的**意義**未知;Firebase Remote Config 旗標由伺服器控制, + 分支表描述的是程式路徑,不是今日的實際上線行為。 +4. 各項逾時都是常數,不是量測值。M365 是否需要 3 秒 keep-alive 未知。 +5. `zm` 的 ordinal 6/8/10/13–23/27/28 是由宣告順序推得,非直接讀出。 +6. 密碼學參數全部為靜態讀取;CCM tag 參數 `0x20` 的解讀(位元 vs 位元組)未定。 +7. 只有 M365 可用,因此**所有 Ninebot 相關結論都是文件等級**。 diff --git a/ninebot-ble/src/model/mod.rs b/ninebot-ble/src/model/mod.rs index c0f0ee3..b32405f 100644 --- a/ninebot-ble/src/model/mod.rs +++ b/ninebot-ble/src/model/mod.rs @@ -113,23 +113,64 @@ impl ModelId { /// Which physical board a register lives on. /// /// Legacy Xiaomi/Ninebot models use a flat address space where the destination -/// byte selects the board (`0x20` ESC, `0x22` BMS). Current-generation models -/// use board-scoped `TARGET_ID` addressing instead; keeping the distinction in -/// the type stops an address from one scheme being used with the other. +/// byte selects the board. Current-generation models use board-scoped +/// `TARGET_ID` addressing instead; keeping the distinction in the type stops an +/// address from one scheme being used with the other. +/// +/// **The address is not a property of the board.** The same component sits at a +/// different address on different models, and — more importantly — the address a +/// reply arrives *from* is not always the address the request was sent *to*. Read +/// the pair from [`ModelProfile::board_address`], never from a constant here. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Board { - /// `0x20` — motor controller. + /// Motor controller. `mType = CTL` in the reference implementation. Esc, - /// `0x22` — battery management. + /// Primary battery management. `mType = BMS`. Bms, + /// Second battery pack, on models that carry one. `mType = BMS2`. + /// + /// Present on Ninebot ESx and on Max models with an add-on pack. Note that its + /// address collides with other boards' addresses on *other* models, which is + /// the clearest illustration of why addressing has to be per-model. + Bms2, +} + +/// One board's address pair on one model. +/// +/// A request is addressed to [`send_id`](Self::send_id); the reply is recognised +/// by [`receive_id`](Self::receive_id). The Xiaomi lineage answers three +/// addresses higher than it was addressed on (`0x20` -> `0x23`, `0x22` -> `0x25`); +/// every Ninebot family answers on the same id. Assuming one convention on the +/// other family mis-attributes every reply. +/// +/// Evidence for the pairs: the reference implementation's `wm` record, whose +/// `toString()` labels the fields `mID` and `mReceiveID` +/// (`re/m365tools/reports/07-model-component-addressing.md`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BoardAddress { + /// Which board this pair addresses. + pub board: Board, + /// The id a request for this board is sent to. + pub send_id: u8, + /// The id a reply from this board arrives on. + pub receive_id: u8, } impl Board { - /// Destination byte for the legacy flat addressing scheme. - pub fn address(self) -> u8 { + /// Destination byte for the legacy flat addressing scheme — **Xiaomi only**. + /// + /// This is the Xiaomi lineage's `send_id` and nothing more. It is not valid for + /// a Ninebot model, where the same component sits at a different address; use + /// [`ModelProfile::board_address`] for anything that has a model in hand. + /// + /// `None` for [`Board::Bms2`]: the Xiaomi lineage carries no second pack, and + /// the id a Ninebot puts it on (`0x23`) is the Xiaomi *motor-to-master* address. + /// Returning a number here would invite exactly that mix-up. + pub fn xiaomi_legacy_address(self) -> Option { match self { - Board::Esc => 0x20, - Board::Bms => 0x22, + Board::Esc => Some(0x20), + Board::Bms => Some(0x22), + Board::Bms2 => None, } } } @@ -230,8 +271,31 @@ pub struct ModelProfile { /// Blocks written to change scooter state. Empty for every model here: this /// crate is read-only for telemetry, and the app's write path is separate. pub writable: &'static [u8], + /// Where each board sits on this model. + /// + /// Empty when the addressing has not been established for this model — which is + /// different from "no boards": a model with an empty table must not be sent a + /// register read on the strength of a guess, because a wrong address is answered + /// by nothing at all rather than by an error. + pub boards: &'static [BoardAddress], } +/// The address pair of every board in the Xiaomi lineage. +/// +/// M365, M365 Pro, Pro 2, 1S, 1S-DE, Lite and Mi 3 all share one table: the ESC +/// and BMS boards are addressed at `0x20`/`0x22` and answer at `0x23`/`0x25` — +/// three higher. The reference implementation gives the plain `M365` id the same +/// table as the `MI_SCOOTER_*` ids, which is why the Xiaomi family and the plain +/// M365 entry are not distinguished here either. +/// +/// Public because the legacy session code addresses frames with bare constants +/// that are these values, and a reader comparing the two needs to see that they +/// are the same numbers — see `session::commands::Direction`. +pub static XIAOMI_BOARDS: &[BoardAddress] = &[ + BoardAddress { board: Board::Esc, send_id: 0x20, receive_id: 0x23 }, + BoardAddress { board: Board::Bms, send_id: 0x22, receive_id: 0x25 }, +]; + /// A register read the telemetry loop issues. #[derive(Clone, Copy, Debug, PartialEq)] pub struct PollSpec { @@ -244,6 +308,15 @@ pub struct PollSpec { } impl ModelProfile { + /// Where `board` sits on this model, if the addressing is known. + /// + /// Returns `None` rather than a default when the model has no table: a wrong + /// address produces silence, and silence is indistinguishable from a scooter + /// that is simply not answering. + pub fn board_address(&self, board: Board) -> Option { + self.boards.iter().copied().find(|a| a.board == board) + } + /// The spec for `field`, if this model exposes it. pub fn field(&self, field: Field) -> Option<&'static FieldSpec> { self.fields.iter().find(|f| f.field == field) @@ -336,6 +409,7 @@ pub static M365: ModelProfile = ModelProfile { FieldSpec { field: Field::TripDistance, board: Board::Esc, register: 0x3A, offset: 2, decoder: Decoder::U16Raw, mirrored_at: None }, ], writable: &[], + boards: XIAOMI_BOARDS, }; /** @@ -387,6 +461,7 @@ pub static M365_PRO: ModelProfile = ModelProfile { polled: M365.polled, fields: M365.fields, writable: &[], + boards: XIAOMI_BOARDS, }; /** @@ -407,6 +482,11 @@ pub static MI3: ModelProfile = ModelProfile { polled: &[], fields: &[], writable: &[], + // The reference implementation gives `MI_SCOOTER_3` a board table identical to + // `MI_SCOOTER_1S`/`_PRO2`/`_LITE` and the same command set. That is evidence for + // the *addressing* only — a shared address says nothing about what the bytes at + // those addresses mean, so the field list stays empty. + boards: XIAOMI_BOARDS, }; #[cfg(test)] @@ -689,9 +769,51 @@ mod tests { // --- addressing -------------------------------------------------------- #[test] - fn board_addresses_match_the_legacy_flat_scheme() { - assert_eq!(Board::Esc.address(), 0x20); - assert_eq!(Board::Bms.address(), 0x22); + fn xiaomi_addresses_match_the_legacy_flat_scheme() { + assert_eq!(Board::Esc.xiaomi_legacy_address(), Some(0x20)); + assert_eq!(Board::Bms.xiaomi_legacy_address(), Some(0x22)); + // The Xiaomi lineage has no second pack, and 0x23 is its motor-to-master + // address — answering with a number here would invite that mix-up. + assert_eq!(Board::Bms2.xiaomi_legacy_address(), None); + } + + #[test] + fn every_xiaomi_profile_answers_three_ids_above_its_request_id() { + for id in [ModelId::M365, ModelId::M365Pro, ModelId::M365Pro2, ModelId::Mi1S, ModelId::MiLite, ModelId::Mi3] { + let profile = id.profile(); + let esc = profile.board_address(Board::Esc).expect("Xiaomi ESC addressing is documented"); + let bms = profile.board_address(Board::Bms).expect("Xiaomi BMS addressing is documented"); + assert_eq!(esc.send_id, 0x20, "{id:?} ESC send id"); + assert_eq!(esc.receive_id, 0x23, "{id:?} ESC receive id"); + assert_eq!(bms.send_id, 0x22, "{id:?} BMS send id"); + assert_eq!(bms.receive_id, 0x25, "{id:?} BMS receive id"); + // The whole point of the pair: the reply does NOT come back on the + // request id, so a decoder that matched on send_id would drop every reply. + assert_ne!(esc.send_id, esc.receive_id, "{id:?} would not exercise the pair"); + } + } + + #[test] + fn a_model_without_addressing_says_so_instead_of_guessing() { + // Every profile that polls a board must be able to address it; a profile that + // cannot address a board must not poll it. + for id in ModelId::all() { + let profile = id.profile(); + for poll in profile.polled { + assert!( + profile.board_address(poll.board).is_some(), + "{id:?} polls {:?} but has no address for it", + poll.board + ); + } + for spec in profile.fields { + assert!( + profile.board_address(spec.board).is_some(), + "{id:?} decodes a field from {:?} but has no address for it", + spec.board + ); + } + } } #[test] diff --git a/ninebot-ble/src/session/commands.rs b/ninebot-ble/src/session/commands.rs index a84c333..a36758e 100644 --- a/ninebot-ble/src/session/commands.rs +++ b/ninebot-ble/src/session/commands.rs @@ -2,6 +2,19 @@ use core::fmt::Debug; use pretty_hex::*; use thiserror::Error; +/// The four addressing bytes of the **Xiaomi** frame layout. +/// +/// These are not four independent directions: they are two boards, each with the +/// id a request is sent to and the id its reply arrives on. `MasterToMotor`/`MotorToMaster` +/// are the ESC's `send_id`/`receive_id` (`0x20`/`0x23`) and +/// `MasterToBattery`/`BatteryToMaster` are the BMS's (`0x22`/`0x25`), exactly the +/// Xiaomi pair in [`crate::model::XIAOMI_BOARDS`]. +/// +/// **They are only correct for the Xiaomi lineage.** Every Ninebot family answers +/// on the id it was addressed on, and several sit at different addresses +/// altogether, so a session built on this enum cannot be reused for those models. +/// New code should take its addresses from +/// [`crate::model::ModelProfile::board_address`]. #[derive(Clone)] pub enum Direction { MasterToMotor, @@ -21,6 +34,11 @@ impl Direction { } } +/// The operation selector byte. +/// +/// ⚠️ [`ReadWrite::Write`] is `0x03` here, but **m365 Tools** uses `0x02` for a +/// write and `0x03` for a *write without response*. See +/// [`ScooterCommand::as_bytes`] for why this is documented rather than changed. #[derive(Clone)] pub enum ReadWrite { Read, @@ -112,6 +130,24 @@ impl ScooterCommand { /// The leading length byte counts `read_write + attribute + payload`. An /// oversized payload is rejected instead of being silently truncated (or /// wrapping around) into a corrupt frame. + /// + /// ⚠️ **This is not the frame the reference implementation sends, and nothing in + /// this repository calls it.** Two divergences were confirmed against + /// **m365 Tools** (`app.peretti.m365tools`, statically analysed): + /// + /// 1. **No sync word and no checksum.** The reference frame is + /// `55 AA | len | to | cmd | reg | payload | cksum16LE` (or `5A A5 | len | + /// from | to | …`), where `cksum = (~Σ bytes) & 0xFFFF` over the length byte + /// and the body, little-endian. This function emits neither the magic nor the + /// checksum, and the length byte's meaning differs per framing (`+2`, `+0` + /// or `+6` — this models only the `+2` form). + /// 2. **The write selector is wrong.** [`ReadWrite::Write`] is `0x03` here; the + /// reference uses `0x02` for a write and `0x03` for a write-without-response. + /// + /// `ninebot-ffi` does not reference this module, so no shipping path is affected + /// today — which is why it is documented rather than patched blind. Before this + /// is wired up, both must be corrected and checked against hardware. See + /// `doc/reverse-engineering/m365tools-reports/05-write-commands.md`. pub fn as_bytes(&self) -> Result, CommandError> { if self.payload.len() > MAX_PAYLOAD_LEN { return Err(CommandError::PayloadTooLong(self.payload.len())); From b9454a75690689af7d27177fb9ff836dd3942a29 Mon Sep 17 00:00:00 2001 From: Kali Agent Date: Tue, 29 Sep 2026 00:08:31 +0800 Subject: [PATCH 2/2] feat(scan): recognise a scooter by its own model code, not just its name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ScooterModelRegistry` identified a model from the advertised name alone and reported every result as `UNVERIFIED`, because a name prefix cannot establish a protocol — the same name spans several wire generations. Ninebot/Xiaomi scooters advertise manufacturer-specific data under company id **16974 (`0x424E`)**, and its first byte is the vendor's own model code. That is the scooter declaring its model, not this app inferring one from a string, and it is what the reference implementation uses (`doc/reverse-engineering/m365tools-reports/01-scan-and-identification.md`). - `ScooterModelRegistry.MANUFACTURER_COMPANY_ID` and `fromManufacturerTypeCode()`; `resolve()` takes an optional type code and prefers it over the name. - `IdentificationSource.MANUFACTURER_DATA`, reported at `Confidence.DOCUMENTED` — documented, never verified: no real `0x424E` advertisement has been captured. - `ScanScreen` reads the code off the `ScanRecord` it already holds. - Only codes this project can name are mapped (Xiaomi lineage, ESx, Max G30, T15, F-series). Mini, Nano, Mark2/3, VIO, Kart and the rebadges are deliberately absent so an unlisted code falls back to the name instead of being forced onto the nearest model. - Seven tests, including one that no mapped code can resolve to `UNKNOWN` or to a model the picker cannot offer. The Xiaomi codes were cross-checked against this project's own independent extraction of the same vendor table and agree (Mi 3 = 46, 1S = 43, Lite = 41, Pro2 = 40, 1S-DE = 37, Pro = 34, M365 = 32). Verified: `./gradlew :app:testDebugUnitTest` — BUILD SUCCESSFUL, 325 tests, 0 failures. Static analysis only; no scooter was present. --- .../protocol/ScooterModelRegistry.kt | 91 ++++++++++++++++++- .../main/java/com/m365bleapp/ui/ScanScreen.kt | 22 ++++- .../protocol/ScooterModelRegistryTest.kt | 87 ++++++++++++++++++ doc/MODEL_SUPPORT.md | 19 ++++ 4 files changed, 216 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/m365bleapp/protocol/ScooterModelRegistry.kt b/app/src/main/java/com/m365bleapp/protocol/ScooterModelRegistry.kt index 0d5800f..08d8eab 100644 --- a/app/src/main/java/com/m365bleapp/protocol/ScooterModelRegistry.kt +++ b/app/src/main/java/com/m365bleapp/protocol/ScooterModelRegistry.kt @@ -272,6 +272,17 @@ enum class ScooterModel( */ object ScooterModelRegistry { + /** + * Bluetooth SIG company identifier for Ninebot/Xiaomi scooter advertisements. + * + * `0x424E` (`16974`). Its manufacturer-specific data carries the vendor's own + * model code in byte 0 and the protocol version in byte 1; see + * [fromManufacturerTypeCode]. A scooter that does not advertise it is not + * necessarily a scooter this app cannot talk to — only one this app cannot + * *name* from the advertisement. + */ + const val MANUFACTURER_COMPANY_ID: Int = 16974 + /** * Models offered in the manual override picker, in a sensible order. * @@ -308,6 +319,64 @@ object ScooterModelRegistry { return null } + /** + * The model named by a scooter's own advertised type code, if we know it. + * + * ## Why this beats a name prefix + * + * Ninebot/Xiaomi scooters put manufacturer-specific data in the advertisement + * under company id **16974 (`0x424E`)**, and its first byte is the vendor's own + * model code — the same integer that identifies the model internally. That is a + * declaration by the scooter, not an inference from a string, and it survives + * the thing that makes [fromAdvertisementName] unreliable: the same model name + * spanning several wire generations. + * + * Codes come from an independent implementation — **m365 Tools 1.8.0** + * (`app.peretti.m365tools`), statically analysed: it reads the byte and matches + * it against its own device table + * (`doc/reverse-engineering/m365tools-reports/01-scan-and-identification.md`). + * The Xiaomi entries in that table were cross-checked against this project's + * own extraction of the same table and agree. + * + * Confidence is [Confidence.DOCUMENTED], not `VERIFIED`: this project has not + * confirmed on hardware that a given scooter advertises the code we expect, + * and no capture of a real `0x424E` payload exists yet. + * + * Only codes this project can actually name are listed. An unlisted code + * returns `null` so the caller can fall back, rather than being forced onto the + * nearest model. + */ + fun fromManufacturerTypeCode(typeCode: Int): Pair? { + val model = MANUFACTURER_TYPE_CODES[typeCode] ?: return null + return model to Confidence.DOCUMENTED + } + + /** + * Vendor model codes from the `0x424E` manufacturer data, first byte. + * + * Several codes have no [ScooterModel] to map to — Mini, Nano, Mark2/3, VIO, + * the Kart family and the rebadges — so they are deliberately absent: an + * unmapped code falls back to the name prefix instead of being reported as a + * model this app cannot support anyway. + */ + private val MANUFACTURER_TYPE_CODES: Map = mapOf( + // Xiaomi lineage. `1S_DE` is the German 1S, which shares the 1S layout. + 32 to ScooterModel.M365, + 34 to ScooterModel.M365_PRO, + 37 to ScooterModel.MI_1S, + 40 to ScooterModel.M365_PRO2, + 41 to ScooterModel.MI_LITE, + 43 to ScooterModel.MI_1S, + 46 to ScooterModel.MI3, + // Ninebot / Segway families this app recognises. + 33 to ScooterModel.NINEBOT_ESX, + 36 to ScooterModel.NINEBOT_MAX_G30, + 35 to ScooterModel.NINEBOT_T15, + 44 to ScooterModel.NINEBOT_F_SERIES, + 45 to ScooterModel.NINEBOT_F_SERIES, + 39 to ScooterModel.NINEBOT_ESX, + ) + /** * The model to use, given an advertisement and any manual override. * @@ -315,10 +384,15 @@ object ScooterModelRegistry { * registry is guessing from a string. A manual choice is still reported as * [Confidence.UNVERIFIED], because choosing a model tells the app which * layout to *try* — it does not make that layout correct. + * + * The vendor type code in [manufacturerTypeCode] is preferred over the + * advertised name when present: it is the scooter declaring its own model, + * where a name is a string several generations share. */ fun resolve( advertisedName: String?, override: ScooterModel? = null, + manufacturerTypeCode: Int? = null, ): Identification { if (override != null && override != ScooterModel.UNKNOWN) { return Identification( @@ -327,7 +401,8 @@ object ScooterModelRegistry { source = IdentificationSource.MANUAL_OVERRIDE, ) } - val guessed = fromAdvertisementName(advertisedName) + val declared = manufacturerTypeCode?.let { fromManufacturerTypeCode(it) } + val guessed = declared ?: fromAdvertisementName(advertisedName) return if (guessed == null) { Identification( model = ScooterModel.UNKNOWN, @@ -338,7 +413,11 @@ object ScooterModelRegistry { Identification( model = guessed.first, confidence = guessed.second, - source = IdentificationSource.ADVERTISEMENT, + source = if (declared != null) { + IdentificationSource.MANUFACTURER_DATA + } else { + IdentificationSource.ADVERTISEMENT + }, ) } } @@ -349,6 +428,14 @@ enum class IdentificationSource { /** The rider chose it. */ MANUAL_OVERRIDE, + /** + * The scooter declared its own model code in the `0x424E` manufacturer data. + * + * The strongest signal available before connecting, and still weaker than a + * capture: it says which model the scooter claims to be. + */ + MANUFACTURER_DATA, + /** Matched an advertised-name prefix. */ ADVERTISEMENT, diff --git a/app/src/main/java/com/m365bleapp/ui/ScanScreen.kt b/app/src/main/java/com/m365bleapp/ui/ScanScreen.kt index ee4a0ef..1b43c07 100644 --- a/app/src/main/java/com/m365bleapp/ui/ScanScreen.kt +++ b/app/src/main/java/com/m365bleapp/ui/ScanScreen.kt @@ -125,6 +125,19 @@ private data class ScannedDevice( val name: String? = scanResult.scanRecord?.deviceName ?: scanResult.device.name, val address: String = scanResult.device.address ) { + /** + * The vendor's own model code, from the `0x424E` manufacturer data. + * + * Byte 0 of that payload, or `null` when the device does not advertise the + * company id at all — which is most non-scooter traffic and every device whose + * advertisement this app cannot read. + */ + val manufacturerTypeCode: Int? = scanResult.scanRecord + ?.getManufacturerSpecificData(ScooterModelRegistry.MANUFACTURER_COMPANY_ID) + ?.firstOrNull() + ?.toInt() + ?.and(0xFF) + /** * What this device looks like it is. * @@ -137,9 +150,16 @@ private data class ScannedDevice( * Ninebot and current Segway models all advertise the same Nordic UART * service — so the registry returns a confidence alongside the guess and the * badge shows both. + * + * The vendor's model code is preferred over the name when the device + * advertises one; it is the scooter declaring its own model rather than this + * app inferring one from a string. */ val identification: Identification - get() = ScooterModelRegistry.resolve(advertisedName = name) + get() = ScooterModelRegistry.resolve( + advertisedName = name, + manufacturerTypeCode = manufacturerTypeCode, + ) /** * Whether this device is worth offering as a scooter. diff --git a/app/src/test/java/com/m365bleapp/protocol/ScooterModelRegistryTest.kt b/app/src/test/java/com/m365bleapp/protocol/ScooterModelRegistryTest.kt index a2b8050..8110545 100644 --- a/app/src/test/java/com/m365bleapp/protocol/ScooterModelRegistryTest.kt +++ b/app/src/test/java/com/m365bleapp/protocol/ScooterModelRegistryTest.kt @@ -122,6 +122,93 @@ class ScooterModelRegistryTest { assertNotNull("an NB-prefixed name should match something", result) } + // --- the vendor's own model code --------------------------------------- + + @Test + fun `a manufacturer type code names the model without guessing`() { + val resolved = ScooterModelRegistry.resolve( + advertisedName = null, + manufacturerTypeCode = 46, + ) + assertEquals(ScooterModel.MI3, resolved.model) + assertEquals(IdentificationSource.MANUFACTURER_DATA, resolved.source) + // Documented, never Verified: a scooter declaring its own code is the + // strongest pre-connection signal there is, but nothing here has seen it + // on a wire. + assertEquals(Confidence.DOCUMENTED, resolved.confidence) + } + + @Test + fun `a manufacturer type code outranks the advertised name`() { + // The name says Xiaomi, the scooter's own code says Max G30. The code is + // the scooter's declaration; the name is a string several generations + // share, so the code must win. + val resolved = ScooterModelRegistry.resolve( + advertisedName = "MIScooter7353", + manufacturerTypeCode = 36, + ) + assertEquals(ScooterModel.NINEBOT_MAX_G30, resolved.model) + assertEquals(IdentificationSource.MANUFACTURER_DATA, resolved.source) + } + + @Test + fun `an unmapped type code falls back to the name instead of guessing`() { + // 3 is the vendor's Mini code, which has no ScooterModel here. Reporting + // the nearest model would be worse than reporting nothing, so the name + // prefix still decides. + val resolved = ScooterModelRegistry.resolve( + advertisedName = "MIScooter7353", + manufacturerTypeCode = 3, + ) + assertEquals(ScooterModel.M365, resolved.model) + assertEquals(IdentificationSource.ADVERTISEMENT, resolved.source) + assertEquals(Confidence.UNVERIFIED, resolved.confidence) + } + + @Test + fun `an unmapped type code with no usable name is still unknown`() { + val resolved = ScooterModelRegistry.resolve( + advertisedName = null, + manufacturerTypeCode = 3, + ) + assertTrue(resolved.isUnknown) + assertEquals(IdentificationSource.NONE, resolved.source) + } + + @Test + fun `a manual override beats a manufacturer type code too`() { + val resolved = ScooterModelRegistry.resolve( + advertisedName = null, + override = ScooterModel.M365, + manufacturerTypeCode = 36, + ) + assertEquals(ScooterModel.M365, resolved.model) + assertEquals(IdentificationSource.MANUAL_OVERRIDE, resolved.source) + } + + @Test + fun `every manufacturer type code maps to a model the registry can offer`() { + // A code that mapped to UNKNOWN would render as "Unidentified" while + // claiming to have been declared by the scooter — worse than not mapping. + for (code in 0..255) { + val mapped = ScooterModelRegistry.fromManufacturerTypeCode(code) ?: continue + assertTrue( + "type code $code mapped to ${mapped.first}, which the UI cannot offer", + mapped.first != ScooterModel.UNKNOWN, + ) + assertTrue( + "type code $code mapped to ${mapped.first}, which is not selectable", + mapped.first in ScooterModelRegistry.selectableModels, + ) + } + } + + @Test + fun `the manufacturer company id is the Ninebot one`() { + // A wrong id would silently read another vendor's payload as a model code. + assertEquals(16974, ScooterModelRegistry.MANUFACTURER_COMPANY_ID) + } + // --- override ---------------------------------------------------------- @Test diff --git a/doc/MODEL_SUPPORT.md b/doc/MODEL_SUPPORT.md index 33a9a7b..54f7c57 100644 --- a/doc/MODEL_SUPPORT.md +++ b/doc/MODEL_SUPPORT.md @@ -21,6 +21,25 @@ number that looks plausible. | **Connectable** | The app can complete the protocol handshake. | | **Readable** | The app can produce telemetry, because a register layout is published for it. | +### How a scooter is recognised + +Two advertisement signals, in order of strength: + +1. **The vendor's own model code.** Ninebot/Xiaomi scooters advertise + manufacturer-specific data under company id **16974 (`0x424E`)**, and its first + byte is the model code the vendor uses internally. `ScooterModelRegistry` + matches that code directly and reports the identification as **Documented**. + A code the registry cannot name falls through to the name, rather than being + forced onto the nearest model. See + `doc/reverse-engineering/m365tools-reports/01-scan-and-identification.md`. +2. **The advertised name.** A prefix match only, and always reported as + **Unverified**: the same model name spans several wire generations, so a name + cannot establish a protocol. + +> ⚠️ Both are static-analysis findings. No real `0x424E` advertisement has been +> captured by this project, so "the scooter declares code X" is documentation-grade +> until someone records one. + ### The matrix | 對照表 `—` means the capability is not implemented. Capabilities come from