feat(model): per-model board addressing, from m365 Tools reverse engineering - #10
Merged
Merged
Conversation
added 2 commits
September 29, 2026 00:02
…neering
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<u8>`, 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.
`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.
|
This was referenced Oct 1, 2026
zero2005x
added a commit
that referenced
this pull request
Oct 1, 2026
…ay upload (#12) Play already serves 1.5.1 as versionCode 10, built before per-model addressing (#10) reached main. A new bundle needs a higher versionCode, so move app to 11 and glass-hud to 8 (versionName stays 1.5.1) and note the supersession plus the #10 changes in the 1.5.1 release notes. Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
4 tasks
zero2005x
added a commit
that referenced
this pull request
Oct 1, 2026
The v1.5.1 release workflow failed before publishing and Play already serves 1.5.1 as versionCode 10, so the build that carries per-model detection (#10) ships as v1.5.2 and the existing v1.5.1 tag is left alone. - app and glass-hud: versionName 1.5.1 -> 1.5.2 (versionCode stays 11 / 8) - doc/RELEASE_NOTES_v1.5.2.md: new, bilingual, covers 1.5.0 through 1.5.2 since this is the first GitHub Release since v1.4.0 - doc/RELEASE_NOTES_v1.5.1.md: restored to what Play's 1.5.1 (versionCode 10) shipped, undoing the versionCode 11 edits from the earlier bump Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Why
Board::address()hard-coded0x20/0x22as the ESC/BMS addresses. That is theXiaomi lineage's send id and nothing more. A second reference implementation,
m365 Tools 1.8.0 (
app.peretti.m365tools, statically analysed), stores twoaddresses per board — its
wmrecord'stoString()labels themmIDandmReceiveID— and they differ by family:send_id→receive_id)0x20→0x23, BLE0x21→0x24, BMS0x22→0x250x20→0x20, BLE0x21→0x21, BMS0x22→0x22, BMS20x23→0x230x20→0x20, BLE0x21→0x21, BMS20x23→0x230x20→0x20, BLE0x21→0x21, BMS0x22→0x220x0A→0x0D, Mark3 ESC0x14→0x14)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 thatmatched on the send id alone would drop every Xiaomi reply; one that assumed the
Xiaomi
+3offset would mis-attribute every Ninebot reply.Changes
Board::Bms2for the second pack;Board::address()becomesxiaomi_legacy_address() -> Option<u8>— named for the family it describes, andrefusing to answer for a board that family does not have.
BoardAddress { board, send_id, receive_id }andModelProfile::boards, withModelProfile::board_address(). An empty table means "not established", which isdifferent from "no boards".
XIAOMI_BOARDSpopulated for all six Xiaomi profiles.+3pair for every Xiaomi profile, theOptioncontract onxiaomi_legacy_address, and an invariant that no profile may poll or decode aboard it cannot address.
Also in this branch
Ninebot/Xiaomi scooters advertise manufacturer-specific data under company id
16974 (
0x424E) whose first byte is the vendor's model code.ScooterModelRegistrymatches that code directly and reports the result asDocumentedvia a newIdentificationSource.MANUFACTURER_DATA; the advertisedname remains a fallback and stays
Unverified. Only codes this project can nameare mapped, so an unlisted code falls back rather than being forced onto the
nearest model. The Xiaomi codes were cross-checked against this project's own
independent extraction of the vendor table and agree (Mi 3 = 46, 1S = 43,
Lite = 41, Pro2 = 40, 1S-DE = 37, Pro = 34, M365 = 32). Seven tests, including
one asserting no mapped code can resolve to
UNKNOWN.BleManagerfinds a characteristic across every discovered service instead ofonly
getService(AUTH_SERVICE). m365 Tools never callsgetService(UUID)at all(zero occurrences in the APK); it addresses characteristics by UUID and lets the
BLE layer enumerate (
mb0.smali:42-92). It also never uses0000fe95as aservice — only that UUID's advertisement service-data while scanning. A device
exposing
00000010/00000019under a different service previously failedsilently. The hinted service is tried first, so existing devices behave
exactly as before.
ScooterSettingsWriter.statusWordWriteandsession::commandsnow documenttheir divergences from m365 Tools rather than guessing at them:
0x7Dwrite byte order — Scootbatt writes big-endian (what this repo does),m365 Tools writes little-endian (
ByteBuffer.allocate(2).order(LITTLE_ENDIAN)).Genuinely unresolved; a table in the KDoc records both with citations.
55 AA/5A A5sync word and the checksum, anduses write selector
0x03where m365 Tools uses0x02. Nothing calls it(
ninebot-ffidoes not referencesession::), so it is documented rather thanpatched blind.
doc/reverse-engineering/m365tools-reports/— nine reports plus an index, the newevidence base for all of the above.
doc/MODEL_SUPPORT.mdgains the per-model addressing table and a fourth datapoint on the open
0xB0offset question (§8.6): m365 Tools reads that block atpayload bytes 8/10/12/14/22, which is the Java
MotorInfoParserlayout, fourbytes above
ninebot-ble's. The offsets are deliberately left unchanged pendinga capture.
Verification
cargo test --manifest-path ninebot-ble/Cargo.toml --no-default-features(the exact CI command)./gradlew :app:testDebugUnitTestcargo doc --no-deps --no-default-featuresNo behavioural change is intended for an existing Xiaomi M365: the addressing
values for every Xiaomi profile are the ones the code already used, the
BleManagerlookup is a strict superset of the old one, and the scan change onlyever adds an identification that the name prefix could not produce.
M365 is available to this project, so every Ninebot row is documentation-grade
and nothing here was observed on a wire.
03-handshake-and-auth.mdincludes a hard-coded vendor key and KDF parameters, and the Scootbatt reports
already publish the same key — but you may want to review what belongs in a
public repository before merging. The analysis tooling (a clean-room
reimplementation of the app's string decryptor) was deliberately left out of the
repository for the same reason.
frame layouts are documented; the register maps for those families are not, so
their profiles stay empty on purpose.
Suggested follow-ups (not in this branch)
Ranked by value against the hardware this project actually has (an M365):
BleManager.connect(),write()andenableNotifications()suspend until a GATT callback that may never arrive; ifit does not, the coroutine hangs and the busy-guard then rejects every later
attempt. m365 Tools bounds every wait (230 ms per command) and every connect
(15 s watchdog). On an M365 with a stale GATT cache this is the difference
between "reconnect works" and "force-stop the app".
class (drop the client, re-scan by MAC for ≤1 s); the fork closes the GATT on any
non-zero status.
0x7Dbyte order, resolved on hardware. Write a known word, read theregister back, see which order reproduces it.
PROPERTY_INDICATEhandling.GattProfileDiscoveryaccepts NOTIFY orINDICATE but
BleManageralways arms the CCCD with the notify value, so anindicate-only channel is selected and then armed incorrectly.
now documented and identical to the 1S/Pro2/Lite.
0x424Epayload carriesthe wire-protocol version (
01/02/05depending on generation), which is whatactually selects the transport. It is
null-safe to read and would let the appstop guessing which family a scooter speaks.