Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
46 changes: 42 additions & 4 deletions app/src/main/java/com/m365bleapp/ble/BleManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 ->
Expand All @@ -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
Expand Down
91 changes: 89 additions & 2 deletions app/src/main/java/com/m365bleapp/protocol/ScooterModelRegistry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -308,17 +319,80 @@ 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<ScooterModel, Confidence>? {
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<Int, ScooterModel> = 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.
*
* An override always wins: the rider is looking at the scooter and this
* 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(
Expand All @@ -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,
Expand All @@ -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
},
)
}
}
Expand All @@ -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,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `<init>(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
Expand Down
22 changes: 21 additions & 1 deletion app/src/main/java/com/m365bleapp/ui/ScanScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading