Skip to content

Commit a11b43f

Browse files
eriedclaude
andcommitted
Multi-wheel scan: show-all toggle, virtual-wheels collapse, P6 cap
Scan filter now passes Adventure-/P6-/InMotion/V[0-9]+ names through; a "Show all devices" Switch bypasses the filter entirely when an unknown wheel doesn't appear. The simulator block becomes a collapsible "Virtual wheels" section (debug bug header, Album icon per card) and defaults closed. WheelRepository now records the last-sent tiltback before each setMaxSpeed write so the post-reconcile detector can compare intent against firmware-clamped readback. A new reconcile case keeps stored values when the wheel clamps between legal and normal, and a per-model maxSpeedKmh table (P6=130, V11=60, V12HS/HT/PRO=70, V11Y/V12S/V13/V13Pro/V14x2/V9=120) feeds the slider cap, with a 90 km/h default until model-id arrives. ExperimentalBanner uses the filled Warning icon. Includes the btsnoop_hci P6 capture decoder under tools/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a6c019f commit a11b43f

16 files changed

Lines changed: 569 additions & 99 deletions

BRANCH.md

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ state for any of them. Concretely shipped here:
1010
V12S / V13 / V13 Pro / V14 50GB / V14 50S / V9 / P6. The wheel reports
1111
its model ID on connect and the registry maps it to the right command
1212
variant (horn opcode, max-speed packet shape, etc.).
13+
- **P6 connect path.** The scan now lists `P6-XXXXXXXX` peripherals, and
14+
the InMotion V2 adapter switches to the P6's extended-routing-only
15+
command set when it sees that name. Voltage, discharge current, and a
16+
rough battery estimate come through; richer telemetry parsing is the
17+
remaining work tracked under `docs/BLE_CAPTURE_GUIDE.md`.
1318
- **Wheel simulator** in the connect screen. Two virtual wheels (V14 and
1419
P6) feed canned BLE responses through the real adapter pipeline, so the
1520
whole UI works without hardware. Useful for translation, layout, and
@@ -26,7 +31,12 @@ state for any of them. Concretely shipped here:
2631
- **V14 owners**: confirm that nothing changed for you. The banner stays
2732
hidden, the dashboard reads the same values, horn / light / lock /
2833
safety mode still work.
29-
- **Owners of any other InMotion wheel** (V11, V12, V13, V9, P6): try
34+
- **P6 owners**: connecting now works. Confirm the dashboard reports a
35+
plausible pack voltage (around 230–240 V at full charge) and that
36+
battery current swings positive when accelerating. Speed and the
37+
remaining telemetry will read zero until the byte offsets are pinned —
38+
the orange banner walks you through filing a labeled capture.
39+
- **Owners of any other InMotion wheel** (V11, V12, V13, V9): try
3040
connecting. Telemetry decoding outside the V14 family is unverified,
3141
so expect wrong values. If anything works or fails, tap the orange
3242
banner to fire off a wheel report.
@@ -36,11 +46,19 @@ state for any of them. Concretely shipped here:
3646

3747
## Known limits
3848

39-
- **P6 telemetry on real hardware is not parsed yet.** The simulator
40-
pretends the P6 speaks V14-shape framing so the dashboard renders, but
41-
on a real P6 the wheel uses a different binary layout and most fields
42-
will read zero. Fix is pending a labeled BLE capture from a real P6
43-
owner, see `docs/BLE_CAPTURE_GUIDE.md`.
49+
- **P6 real-hardware support is preliminary.** Connecting to a real P6
50+
now works (the BLE name `P6-XXXXXXXX` puts the adapter on the
51+
extended-routing protocol the wheel actually speaks) and the dashboard
52+
shows live voltage and discharge current plus a rough battery estimate
53+
from the pack voltage. The remaining telemetry — speed, PWM, motor
54+
temperature, trip distance, the per-pack battery split — is still
55+
unmapped because the data block's byte layout differs from V14 and we
56+
only have parked captures so far. Help us pin those offsets by recording
57+
a labeled session (`docs/BLE_CAPTURE_GUIDE.md`) while riding.
58+
- **P6 settings, locking, and safety-mode max-speed control are
59+
disabled** until the matching control packets are reverse-engineered.
60+
The UI doesn't gate them yet, so tapping those buttons on a P6 is a
61+
no-op — that should be obvious from the wheel not responding.
4462
- **KingSong, Veteran, Begode/Gotway, InMotion V1 family (V8 / V10)**:
4563
their adapters aren't built yet, so connecting won't work. They appear
4664
in the wheel-report dropdown for users to manually pick if they want

app/src/main/java/com/eried/eucplanet/ble/BleConnectionManager.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ class BleConnectionManager @Inject constructor(
6363
private var gatt: BluetoothGatt? = null
6464
private var rxCharacteristic: BluetoothGattCharacteristic? = null
6565
private var currentAddress: String? = null
66+
/** BLE advertised name from the most recent connect call, kept across reconnects. */
67+
private var currentName: String? = null
6668
private var shouldReconnect = true
6769

6870
// Write serialization - only one BLE write at a time
@@ -80,7 +82,7 @@ class BleConnectionManager @Inject constructor(
8082
}
8183

8284
@SuppressLint("MissingPermission")
83-
fun connect(address: String) {
85+
fun connect(address: String, name: String? = null) {
8486
// Demo / simulator mode: VIRTUAL:<id> bypasses GATT and connects to a fake wheel.
8587
val virtualId = VirtualWheelRegistry.parsePseudoAddress(address)
8688
if (virtualId != null) {
@@ -89,9 +91,17 @@ class BleConnectionManager @Inject constructor(
8991
}
9092

9193
currentAddress = address
94+
// Hold on to the name so the auto-reconnect path keeps the same hint —
95+
// otherwise a P6 that briefly drops would come back as an unknown wheel.
96+
currentName = name ?: currentName
9297
shouldReconnect = true
9398
_connectionState.value = ConnectionState.CONNECTING
9499

100+
// Adapter pre-selects model from the BLE name; needed for the InMotion
101+
// P6 because its legacy carType query returns zeros and we'd otherwise
102+
// never identify it before sending V14-shaped queries the wheel ignores.
103+
wheelAdapter.notifyConnectingTo(currentName)
104+
95105
val device: BluetoothDevice = bluetoothManager.adapter.getRemoteDevice(address)
96106
gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
97107
}
@@ -136,6 +146,7 @@ class BleConnectionManager @Inject constructor(
136146
fun disconnect() {
137147
shouldReconnect = false
138148
currentAddress = null
149+
currentName = null
139150
rxCharacteristic = null
140151
writeReady = false
141152

app/src/main/java/com/eried/eucplanet/ble/BleScanner.kt

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,24 @@ class BleScanner @Inject constructor(
2828
private val bluetoothAdapter = bluetoothManager.adapter
2929
private var scanCallback: ScanCallback? = null
3030

31+
/**
32+
* Start a BLE scan and emit one [BleDevice] per advertisement match.
33+
*
34+
* @param showAll when true, every named peripheral is forwarded (matches
35+
* WheelLog's behaviour). When false, only names that match
36+
* a known wheel prefix are forwarded — useful for keeping
37+
* the scan list short and free of unrelated devices in
38+
* typical usage.
39+
*/
3140
@SuppressLint("MissingPermission")
32-
fun scanForDevices(): Flow<BleDevice> = callbackFlow {
41+
fun scanForDevices(showAll: Boolean = false): Flow<BleDevice> = callbackFlow {
3342
val scanner = bluetoothAdapter?.bluetoothLeScanner
3443
?: throw IllegalStateException("Bluetooth not available")
3544

3645
val callback = object : ScanCallback() {
3746
override fun onScanResult(callbackType: Int, result: ScanResult) {
3847
val name = result.device.name ?: return
39-
if (name.startsWith("Adventure-") || name.startsWith("InMotion")) {
48+
if (showAll || isLikelyWheel(name)) {
4049
trySend(BleDevice(
4150
name = name,
4251
address = result.device.address,
@@ -63,6 +72,32 @@ class BleScanner @Inject constructor(
6372
}
6473
}
6574

75+
/**
76+
* BLE-name allowlist for the default ("known wheels only") scan mode.
77+
*
78+
* V14 advertises as `Adventure-<id>`, P6 as `P6-<id>`. The InMotion V2
79+
* registry covers V8 through V13 — those wheels broadcast as
80+
* `V<digits><letters?>-<id>` (V11-…, V11Y-…, V12HS-…, V13Pro-…) per
81+
* community captures. We don't have one of each here to confirm, so
82+
* the regex errs inclusive. The generic `InMotion` prefix catches
83+
* anything that ships with the brand name in the advertised name.
84+
* Users with an unusual name can flip the "show all" switch on the
85+
* scan screen.
86+
*/
87+
private fun isLikelyWheel(name: String): Boolean {
88+
if (name.startsWith("Adventure-")) return true
89+
if (name.startsWith("P6-")) return true
90+
if (name.startsWith("InMotion")) return true
91+
// V8-…, V9-…, V10-…, V11-…, V11Y-…, V12HS-…, V13Pro-…: leading V
92+
// followed by at least one digit and at least one more character
93+
// (separator, model letter, or further digit). Rejects bare "V" /
94+
// "V1" / "V12" beacons, accepts the InMotion V2 family.
95+
if (name.length < 3 || name[0] != 'V' || !name[1].isDigit()) return false
96+
var i = 2
97+
while (i < name.length && name[i].isDigit()) i++
98+
return i < name.length
99+
}
100+
66101
@SuppressLint("MissingPermission")
67102
fun stopScan() {
68103
val scanner = bluetoothAdapter?.bluetoothLeScanner ?: return

app/src/main/java/com/eried/eucplanet/ble/InMotionV2Adapter.kt

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ class InMotionV2Adapter @Inject constructor() : WheelAdapter {
3232
@Volatile var detectedModel: InMotionV2Model? = null
3333
private set
3434

35+
/**
36+
* Use the P6's extended-routing-only command set. Only set by
37+
* [notifyConnectingTo] from the BLE name (`P6-XXXXXXXX`) and never flipped
38+
* by telemetry — keeping it name-bound means the virtual P6 simulator,
39+
* which emits V14-shaped packets, can keep using the V14 command path even
40+
* after carType identifies it as a P6.
41+
*/
42+
@Volatile private var useP6Protocol: Boolean = false
43+
3544
/**
3645
* BLE notifications can split a single AA AA frame across multiple packets.
3746
* Buffer them here and scan for complete frames. Stays in this adapter so
@@ -40,17 +49,51 @@ class InMotionV2Adapter @Inject constructor() : WheelAdapter {
4049
*/
4150
private val reassemblyBuffer = ByteArrayOutputStream()
4251

43-
override fun initSequence(): List<ByteArray> = listOf(
44-
InMotionV2Commands.getCarType(),
45-
InMotionV2Commands.getSerialNumber(),
46-
InMotionV2Commands.getVersions(),
47-
InMotionV2Commands.getCurrentSettings(),
48-
InMotionV2Commands.getUselessData(),
49-
InMotionV2Commands.getStatistics()
50-
)
52+
/**
53+
* Pre-select model from the BLE advertised name. The InMotion P6 uses an
54+
* extended-routing-only command set: the legacy `02 [cmd]` queries return
55+
* all-zero blobs (verified in real-hardware captures), so we have to know
56+
* we're talking to a P6 *before* sending the first init packet. The name
57+
* `P6-XXXXXXXX` is the cleanest pre-connect signal — we set the model now
58+
* and let [initSequence] / [pollRealtime] / [decode] take the P6 branch.
59+
*/
60+
override fun notifyConnectingTo(deviceName: String?) {
61+
if (deviceName != null && deviceName.startsWith("P6-")) {
62+
detectedModel = InMotionV2Model.P6
63+
useP6Protocol = true
64+
}
65+
}
66+
67+
override fun initSequence(): List<ByteArray> {
68+
// P6 doesn't answer the V14 carType / settings / stats queries; only the
69+
// info bundle (`02 21 06`) returns useful data. Telemetry kicks in via
70+
// pollRealtime once the loop starts.
71+
if (useP6Protocol) {
72+
return listOf(InMotionV2Commands.getP6Info())
73+
}
74+
return listOf(
75+
InMotionV2Commands.getCarType(),
76+
InMotionV2Commands.getSerialNumber(),
77+
InMotionV2Commands.getVersions(),
78+
InMotionV2Commands.getCurrentSettings(),
79+
InMotionV2Commands.getUselessData(),
80+
InMotionV2Commands.getStatistics()
81+
)
82+
}
5183

52-
override fun pollRealtime(): ByteArray = InMotionV2Commands.getRealTimeData()
53-
override fun pollSettings(): ByteArray = InMotionV2Commands.getCurrentSettings()
84+
override fun pollRealtime(): ByteArray =
85+
if (useP6Protocol) InMotionV2Commands.getP6RealTimeData()
86+
else InMotionV2Commands.getRealTimeData()
87+
88+
/**
89+
* P6 settings (`02 21 20 …`) come back in a TLV layout we haven't decoded
90+
* yet, so re-polling them adds load with no benefit. Returning the realtime
91+
* query keeps the polling loop's settings-refresh tick benign — the wheel
92+
* just emits another telemetry packet.
93+
*/
94+
override fun pollSettings(): ByteArray =
95+
if (useP6Protocol) InMotionV2Commands.getP6RealTimeData()
96+
else InMotionV2Commands.getCurrentSettings()
5497

5598
/**
5699
* Horn dispatch. V14 family models (V14g/V14s/V13/V13PRO/V11Y) use the
@@ -143,6 +186,7 @@ class InMotionV2Adapter @Inject constructor() : WheelAdapter {
143186
override fun onDisconnect() {
144187
reassemblyBuffer.reset()
145188
detectedModel = null
189+
useP6Protocol = false
146190
}
147191

148192
/** Internal decode of an unwrapped V2 packet. Called from [onRawNotification]. */
@@ -152,6 +196,38 @@ class InMotionV2Adapter @Inject constructor() : WheelAdapter {
152196
0x04 -> parseTelemetryForModel(data)?.let { DecodeResult.Telemetry(it) } ?: DecodeResult.Unknown
153197
0x11 -> InMotionV2Parser.parseTotalStats(data)?.let { DecodeResult.TotalDistance(it.totalDistanceKm) } ?: DecodeResult.Unknown
154198
0x20 -> parseSettingsForModel(data)?.let { DecodeResult.Settings(it) } ?: DecodeResult.Unknown
199+
0x21 -> decodeP6Extended(data)
200+
else -> DecodeResult.Unknown
201+
}
202+
}
203+
204+
/**
205+
* Unwrap a P6-style extended-routing response. The frame body looks like
206+
* `02 (sub|0x80) (01 00) (payload)` for sub 0x06 (info) and 0x07 (realtime),
207+
* and `02 (sub|0x80) (payload)` for sub 0x10 / 0x11 / 0x60 etc. We only
208+
* decode the two we trust today; the rest pass through silently.
209+
*/
210+
private fun decodeP6Extended(data: ByteArray): DecodeResult {
211+
if (data.size < 3 || data[0] != 0x02.toByte()) return DecodeResult.Unknown
212+
val sub = data[1].toInt() and 0x7F
213+
return when (sub) {
214+
0x07 -> {
215+
// realtime: skip the `02 87 01 00` prefix to land on the data block
216+
if (data.size < 4) return DecodeResult.Unknown
217+
val telem = InMotionV2Parser.parseP6Telemetry(data.copyOfRange(4, data.size))
218+
telem?.let { DecodeResult.Telemetry(it) } ?: DecodeResult.Unknown
219+
}
220+
0x06 -> {
221+
// info bundle: skip `02 86 01 00`, then ASCII serial follows the
222+
// 0x01 record marker. We surface the serial as the model name so
223+
// the dashboard has *something* to identify the wheel until a
224+
// proper P6 parser lands.
225+
if (data.size < 4) return DecodeResult.Unknown
226+
val serial = InMotionV2Parser.parseP6Serial(data.copyOfRange(4, data.size))
227+
if (serial != null) {
228+
DecodeResult.ModelName("InMotion P6 ($serial)", InMotionV2Model.P6)
229+
} else DecodeResult.Unknown
230+
}
155231
else -> DecodeResult.Unknown
156232
}
157233
}

app/src/main/java/com/eried/eucplanet/ble/InMotionV2Commands.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ object InMotionV2Commands {
6464
fun getRealTimeData(): ByteArray =
6565
InMotionV2Protocol.buildPacket(Flags.DEFAULT, Command.REAL_TIME_INFO, byteArrayOf())
6666

67+
// --- InMotion P6 (extended-routing-only variant) ---
68+
//
69+
// The P6 ignores the legacy `02 [cmd]` queries (carType returns all zeros in
70+
// captures) and only responds to extended-routing queries `02 21 [sub]`.
71+
// Confirmed sub-commands seen on a real P6 (firmware A14219B): 0x06 info
72+
// bundle (serial + version), 0x07 realtime telemetry, 0x04 total stats.
73+
// We send only the info + realtime queries — settings (sub 0x20) and ride
74+
// history (sub 0x10/0x11) have a TLV-style layout we haven't reverse-
75+
// engineered yet, so polling them just produces unparsed bytes.
76+
77+
fun getP6Info(): ByteArray =
78+
InMotionV2Protocol.buildExtendedPacket(0x06, byteArrayOf())
79+
80+
fun getP6RealTimeData(): ByteArray =
81+
InMotionV2Protocol.buildExtendedPacket(0x07, byteArrayOf())
82+
6783
// --- Control commands ---
6884

6985
/**

app/src/main/java/com/eried/eucplanet/ble/InMotionV2Model.kt

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,18 @@ enum class InMotionV2Model(
2525
* Horn opcode varies: V13/V14/V11Y use `playBeep(0x02)`, the older models
2626
* use `playSound(0x18)`. Stored as the sub-cmd byte.
2727
*/
28-
val hornOpcode: Byte
28+
val hornOpcode: Byte,
29+
/**
30+
* Upper bound for the user-configurable tiltback slider in km/h. Numbers
31+
* mirror WheelLog's `InmotionAdapterV2.getMaxSpeed()` table; P6 isn't in
32+
* WheelLog so it gets 130 km/h to match community-reported top speeds.
33+
* This is *not* the firmware-enforced cap — V14 firmware steps
34+
* 70 → 80 → 90 km/h depending on revision and break-in mileage. The slider
35+
* just lets the user request up to this value; the wheel clamps further
36+
* if needed and the repository's reconcile logic now keeps the user's
37+
* stored value rather than overwriting it with the clamp.
38+
*/
39+
val maxSpeedKmh: Int
2940
) {
3041
/**
3142
* The P6 actually uses an extended-routing-only variant of the V2 protocol
@@ -35,18 +46,18 @@ enum class InMotionV2Model(
3546
* has the model already wired in. The flags here are placeholders that
3647
* keep the simulator on the V14 command path.
3748
*/
38-
P6( 21, "InMotion P6", maxSpeedHasAlarms = true, hornOpcode = 0x18),
39-
V11( 61, "InMotion V11", maxSpeedHasAlarms = false, hornOpcode = 0x18),
40-
V11Y( 62, "InMotion V11y", maxSpeedHasAlarms = true, hornOpcode = 0x02),
41-
V12HS( 71, "InMotion V12 HS", maxSpeedHasAlarms = false, hornOpcode = 0x18),
42-
V12HT( 72, "InMotion V12 HT", maxSpeedHasAlarms = false, hornOpcode = 0x18),
43-
V12PRO( 73, "InMotion V12 Pro", maxSpeedHasAlarms = false, hornOpcode = 0x18),
44-
V13( 81, "InMotion V13", maxSpeedHasAlarms = true, hornOpcode = 0x02),
45-
V13PRO( 82, "InMotion V13 Pro", maxSpeedHasAlarms = true, hornOpcode = 0x02),
46-
V14_50GB(91, "InMotion V14 50GB", maxSpeedHasAlarms = true, hornOpcode = 0x02),
47-
V14_50S( 92, "InMotion V14 50S", maxSpeedHasAlarms = true, hornOpcode = 0x02),
48-
V12S( 111, "InMotion V12S", maxSpeedHasAlarms = true, hornOpcode = 0x18),
49-
V9( 121, "InMotion V9", maxSpeedHasAlarms = true, hornOpcode = 0x18);
49+
P6( 21, "InMotion P6", maxSpeedHasAlarms = true, hornOpcode = 0x18, maxSpeedKmh = 130),
50+
V11( 61, "InMotion V11", maxSpeedHasAlarms = false, hornOpcode = 0x18, maxSpeedKmh = 60),
51+
V11Y( 62, "InMotion V11y", maxSpeedHasAlarms = true, hornOpcode = 0x02, maxSpeedKmh = 120),
52+
V12HS( 71, "InMotion V12 HS", maxSpeedHasAlarms = false, hornOpcode = 0x18, maxSpeedKmh = 70),
53+
V12HT( 72, "InMotion V12 HT", maxSpeedHasAlarms = false, hornOpcode = 0x18, maxSpeedKmh = 70),
54+
V12PRO( 73, "InMotion V12 Pro", maxSpeedHasAlarms = false, hornOpcode = 0x18, maxSpeedKmh = 70),
55+
V13( 81, "InMotion V13", maxSpeedHasAlarms = true, hornOpcode = 0x02, maxSpeedKmh = 120),
56+
V13PRO( 82, "InMotion V13 Pro", maxSpeedHasAlarms = true, hornOpcode = 0x02, maxSpeedKmh = 120),
57+
V14_50GB(91, "InMotion V14 50GB", maxSpeedHasAlarms = true, hornOpcode = 0x02, maxSpeedKmh = 120),
58+
V14_50S( 92, "InMotion V14 50S", maxSpeedHasAlarms = true, hornOpcode = 0x02, maxSpeedKmh = 120),
59+
V12S( 111, "InMotion V12S", maxSpeedHasAlarms = true, hornOpcode = 0x18, maxSpeedKmh = 120),
60+
V9( 121, "InMotion V9", maxSpeedHasAlarms = true, hornOpcode = 0x18, maxSpeedKmh = 120);
5061

5162
companion object {
5263
/** Horn sub-cmd byte for `playBeep` (V13/V14/V11Y per WheelLog). */

0 commit comments

Comments
 (0)