Skip to content

feat(model): per-model board addressing, from m365 Tools reverse engineering - #10

Merged
zero2005x merged 2 commits into
mainfrom
feat/per-model-board-addressing
Sep 29, 2026
Merged

zero2005x merged 2 commits into
mainfrom
feat/per-model-board-addressing

Conversation

@zero2005x

@zero2005x zero2005x commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

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:

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
Mini / Mark3 / VIO different addresses again (Mini ESC 0x0A→0x0D, Mark3 ESC 0x14→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 that
matched on the send id alone would drop every Xiaomi reply; 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 in this branch

  • The app now recognises a scooter by its own model code, not just its name.
    Ninebot/Xiaomi scooters advertise manufacturer-specific data under company id
    16974 (0x424E) whose first byte is the vendor's model code.
    ScooterModelRegistry matches that code directly and reports the result as
    Documented via a new IdentificationSource.MANUFACTURER_DATA; the advertised
    name remains a fallback and stays Unverified. Only codes this project can name
    are 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.
  • BleManager finds a characteristic across every discovered service instead of
    only getService(AUTH_SERVICE). m365 Tools never calls getService(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 uses 0000fe95 as a
    service — only that UUID's advertisement service-data while scanning. 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 now document
    their divergences from m365 Tools rather than guessing at them:
    • the 0x7D write 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.
    • the legacy Rust path omits the 55 AA/5A A5 sync word and the checksum, and
      uses write selector 0x03 where m365 Tools uses 0x02. Nothing calls it
      (ninebot-ffi does not reference session::), so it is documented rather than
      patched blind.
  • doc/reverse-engineering/m365tools-reports/ — nine reports plus an index, the new
    evidence base for all of the above.
  • doc/MODEL_SUPPORT.md gains the per-model addressing table and a fourth data
    point on the open 0xB0 offset question
    (§8.6): m365 Tools reads that block at
    payload bytes 8/10/12/14/22, which is the Java MotorInfoParser layout, four
    bytes above ninebot-ble's. The offsets are deliberately left unchanged pending
    a capture.

Verification

Check Result
cargo test --manifest-path ninebot-ble/Cargo.toml --no-default-features (the exact CI command) 148 pass, exit 0
./gradlew :app:testDebugUnitTest BUILD SUCCESSFUL, 325 tests, 0 failures, 0 errors
cargo doc --no-deps --no-default-features 4 warnings, all pre-existing; none from this branch

No behavioural change is intended for an existing Xiaomi M365: the addressing
values for every Xiaomi profile are the ones the code already used, the
BleManager lookup is a strict superset of the old one, and the scan change only
ever adds an identification that the name prefix could not produce.

⚠️ Review notes

  1. Static analysis only. No scooter and no phone were attached. Only a Xiaomi
    M365 is available to this project, so every Ninebot row is documentation-grade
    and nothing here was observed on a wire.
  2. The reports are large and contain protocol detail. 03-handshake-and-auth.md
    includes 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.
  3. This unblocks but does not deliver Ninebot telemetry. The addressing and the
    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):

  1. Give the BLE primitives deadlines. BleManager.connect(), write() and
    enableNotifications() suspend until a GATT callback that may never arrive; if
    it 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".
  2. GATT status 133 recovery. The app treats 133 as a distinct, recoverable
    class (drop the client, re-scan by MAC for ≤1 s); the fork closes the GATT on any
    non-zero status.
  3. 0x7D byte order, resolved on hardware. Write a known word, read the
    register back, see which order reproduces it.
  4. PROPERTY_INDICATE handling. GattProfileDiscovery accepts NOTIFY or
    INDICATE but BleManager always arms the CCCD with the notify value, so an
    indicate-only channel is selected and then armed incorrectly.
  5. Promote Mi 3 once the register-layout question is settled — the addressing is
    now documented and identical to the 1S/Pro2/Lite.
  6. Read the protocol version too. Byte 1 of the same 0x424E payload carries
    the wire-protocol version (01/02/05 depending on generation), which is what
    actually selects the transport. It is null-safe to read and would let the app
    stop guessing which family a scooter speaks.

Kali Agent 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.
@sonarqubecloud

Copy link
Copy Markdown

@zero2005x
zero2005x merged commit 9c236e4 into main Sep 29, 2026
3 checks passed
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant