Skip to content

Commit 8e71a4c

Browse files
eriedclaude
andcommitted
P6: real opcodes for light/horn/setMaxSpeed, real battery and mileage
A second btsnoop capture from a real P6 owner with a settings page open gave us the missing control opcodes. The InMotion app sends: - Light: 60 50 [on/off, on/off] (3-byte mirrored, V14 uses 2 bytes) - Horn: 60 51 [18 01] (V14 sends [02 64] which P6 ignores) - setMaxSpeed: 60 21 [val_lo val_hi] (single uint16 in 0.01 km/h) - Flash commit: 60 3e [val_lo val_hi 00 00] right after setMaxSpeed The P6 path in InMotionV2Adapter now branches to setP6Light, hornP6 and setP6MaxSpeed when useP6Protocol is on. WheelAdapter gains an optional setMaxSpeedCommit hook so the repository fires the flash-commit packet right after the setMaxSpeed write — non-P6 wheels return null and the behaviour is unchanged. The realtime parser now reads the real per-pack battery percent at offsets 20-23 (matched the on-screen 98.94% / 96.90% in the capture) instead of the previous voltage-curve estimate, plus total mileage as uint32 LE at offset 58 in 0.01 km units (matched 1773.5 mi). A new parseP6Settings pulls the current tiltback at offset 13-14 of the 51-byte sub 0x20 settings page so the slider shows the wheel's actual value. Speed, PWM, per-sensor temperatures, gear, and the alarm-write opcodes still wait on a labelled riding capture — the InMotion app stopped polling sub 0x07 during the rider's actual ride in this trace, so parked-only frames have all those fields at zero or idle. The btsnoop analysis tools used for this work land under tools/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a11b43f commit 8e71a4c

11 files changed

Lines changed: 1088 additions & 23 deletions

File tree

BRANCH.md

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,16 @@ 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`.
13+
- **P6 connect path + parser pass.** The scan now lists `P6-XXXXXXXX`
14+
peripherals; the adapter switches to the extended-routing-only command
15+
set when it sees that name. Voltage, discharge current, real per-pack
16+
battery percent, total mileage, and current tiltback all parse from
17+
real-hardware captures. The control plane now uses the P6-specific
18+
opcodes the InMotion app sends — light becomes a 3-byte mirrored
19+
packet, horn drops the V14 sound id, setMaxSpeed sends a single
20+
uint16 followed by a flash-commit. Speed, PWM, and per-sensor
21+
temperatures still wait on a labelled riding capture
22+
(`docs/BLE_CAPTURE_GUIDE.md`).
1823
- **Wheel simulator** in the connect screen. Two virtual wheels (V14 and
1924
P6) feed canned BLE responses through the real adapter pipeline, so the
2025
whole UI works without hardware. Useful for translation, layout, and
@@ -31,11 +36,13 @@ state for any of them. Concretely shipped here:
3136
- **V14 owners**: confirm that nothing changed for you. The banner stays
3237
hidden, the dashboard reads the same values, horn / light / lock /
3338
safety mode still work.
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+
- **P6 owners**: connecting now works and the control plane is wired up
40+
properly. Verify horn beeps, light toggles on/off, and that adjusting
41+
the tiltback slider actually changes the wheel's speed cap (the
42+
flash-commit packet is meant to persist it past sleep). Voltage,
43+
per-pack battery, and total mileage should match the InMotion app.
44+
Speed, PWM, and temperatures still read zero — those need a labelled
45+
riding capture to confirm offsets.
3946
- **Owners of any other InMotion wheel** (V11, V12, V13, V9): try
4047
connecting. Telemetry decoding outside the V14 family is unverified,
4148
so expect wrong values. If anything works or fails, tap the orange

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

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,13 @@ class InMotionV2Adapter @Inject constructor() : WheelAdapter {
6565
}
6666

6767
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.
68+
// P6: query info bundle for serial, then settings page A so the UI
69+
// shows the current tiltback. Telemetry kicks in via pollRealtime.
7170
if (useP6Protocol) {
72-
return listOf(InMotionV2Commands.getP6Info())
71+
return listOf(
72+
InMotionV2Commands.getP6Info(),
73+
InMotionV2Commands.getP6Settings()
74+
)
7375
}
7476
return listOf(
7577
InMotionV2Commands.getCarType(),
@@ -86,13 +88,11 @@ class InMotionV2Adapter @Inject constructor() : WheelAdapter {
8688
else InMotionV2Commands.getRealTimeData()
8789

8890
/**
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.
91+
* Periodic settings refresh. The P6 returns a 51-byte settings page on
92+
* `02 21 20 [20]`; the parser pulls the current tiltback at offset 13-14.
9393
*/
9494
override fun pollSettings(): ByteArray =
95-
if (useP6Protocol) InMotionV2Commands.getP6RealTimeData()
95+
if (useP6Protocol) InMotionV2Commands.getP6Settings()
9696
else InMotionV2Commands.getCurrentSettings()
9797

9898
/**
@@ -102,6 +102,7 @@ class InMotionV2Adapter @Inject constructor() : WheelAdapter {
102102
* isn't yet known the V14 path is the safer default.
103103
*/
104104
override fun horn(): ByteArray {
105+
if (useP6Protocol) return InMotionV2Commands.hornP6()
105106
val m = detectedModel
106107
return if (m == null || m.hornOpcode == InMotionV2Model.HORN_PLAY_BEEP) {
107108
InMotionV2Commands.horn()
@@ -110,7 +111,9 @@ class InMotionV2Adapter @Inject constructor() : WheelAdapter {
110111
}
111112
}
112113

113-
override fun setLight(on: Boolean): ByteArray = InMotionV2Commands.setLight(on)
114+
override fun setLight(on: Boolean): ByteArray =
115+
if (useP6Protocol) InMotionV2Commands.setP6Light(on)
116+
else InMotionV2Commands.setLight(on)
114117

115118
/**
116119
* Max speed dispatch. Models that can carry alarm thresholds in the same
@@ -122,6 +125,7 @@ class InMotionV2Adapter @Inject constructor() : WheelAdapter {
122125
* just means the wheel keeps whatever alarm value it had configured.
123126
*/
124127
override fun setMaxSpeed(tiltbackKmh: Float, alarmKmh: Float): ByteArray {
128+
if (useP6Protocol) return InMotionV2Commands.setP6MaxSpeed(tiltbackKmh)
125129
val m = detectedModel
126130
return if (m == null || m.maxSpeedHasAlarms) {
127131
InMotionV2Commands.setMaxSpeedV14(tiltbackKmh, alarmKmh)
@@ -130,6 +134,14 @@ class InMotionV2Adapter @Inject constructor() : WheelAdapter {
130134
}
131135
}
132136

137+
/**
138+
* P6 follows up `60 21 [val]` with `60 3e [val 00 00]` to commit the new
139+
* tiltback to flash; without it the change is volatile. The repository
140+
* sends both back to back when this returns non-null.
141+
*/
142+
override fun setMaxSpeedCommit(tiltbackKmh: Float): ByteArray? =
143+
if (useP6Protocol) InMotionV2Commands.commitP6MaxSpeed(tiltbackKmh) else null
144+
133145
override fun setVolume(percent: Int): ByteArray = InMotionV2Commands.setVolume(percent)
134146
override fun setDRL(on: Boolean): ByteArray = InMotionV2Commands.setDRL(on)
135147
override fun setLock(locked: Boolean): ByteArray = InMotionV2Commands.setLock(locked)
@@ -228,6 +240,13 @@ class InMotionV2Adapter @Inject constructor() : WheelAdapter {
228240
DecodeResult.ModelName("InMotion P6 ($serial)", InMotionV2Model.P6)
229241
} else DecodeResult.Unknown
230242
}
243+
0x20 -> {
244+
// settings page A: `02 a0 [body]` — the body starts with a 0x20
245+
// sub-cmd echo and the parser pulls tiltback at offset 13-14.
246+
if (data.size < 3) return DecodeResult.Unknown
247+
val settings = InMotionV2Parser.parseP6Settings(data.copyOfRange(2, data.size))
248+
settings?.let { DecodeResult.Settings(it) } ?: DecodeResult.Unknown
249+
}
231250
else -> DecodeResult.Unknown
232251
}
233252
}

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,67 @@ object InMotionV2Commands {
8080
fun getP6RealTimeData(): ByteArray =
8181
InMotionV2Protocol.buildExtendedPacket(0x07, byteArrayOf())
8282

83+
/**
84+
* Read settings page A (`02 21 20 [20]`). Comes back as a 51-byte body
85+
* with current tiltback at offset 13-14 (uint16 LE / 100, km/h).
86+
*/
87+
fun getP6Settings(): ByteArray =
88+
InMotionV2Protocol.buildExtendedPacket(0x20, byteArrayOf(0x20))
89+
90+
/**
91+
* Set the P6 tiltback / max speed.
92+
*
93+
* P6 takes a 2-byte uint16 LE value in 0.01 km/h units, NOT the V14's
94+
* 4-byte (tilt + alarm) packet. From a real-hardware capture, the
95+
* InMotion app pairs each `60 21 [val]` write with a `60 3e [val 00 00]`
96+
* commit-to-flash write a few hundred milliseconds later. We mimic that
97+
* pairing — the caller writes both packets back to back.
98+
*/
99+
fun setP6MaxSpeed(tiltbackKmh: Float): ByteArray {
100+
val v = ByteUtils.putUint16LE((tiltbackKmh * 100).toInt())
101+
return InMotionV2Protocol.buildExtendedPacket(
102+
Command.CONTROL,
103+
byteArrayOf(ControlSubCmd.SET_MAX_SPEED, v[0], v[1])
104+
)
105+
}
106+
107+
/**
108+
* Persist-to-flash companion to [setP6MaxSpeed]. The InMotion app sends
109+
* this immediately after the `60 21` write; without it the change is
110+
* volatile and gets lost when the wheel sleeps.
111+
*/
112+
fun commitP6MaxSpeed(tiltbackKmh: Float): ByteArray {
113+
val v = ByteUtils.putUint16LE((tiltbackKmh * 100).toInt())
114+
return InMotionV2Protocol.buildExtendedPacket(
115+
Command.CONTROL,
116+
byteArrayOf(0x3E, v[0], v[1], 0x00, 0x00)
117+
)
118+
}
119+
120+
/**
121+
* P6 light: `60 50 [on/off, on/off]`. The second byte mirrors the first
122+
* — V14 uses a 1-byte arg and is silently ignored by the P6, which is
123+
* why the watch / phone toggle didn't take on Gio's wheel.
124+
*/
125+
fun setP6Light(on: Boolean): ByteArray {
126+
val v = if (on) 0x01.toByte() else 0x00.toByte()
127+
return InMotionV2Protocol.buildExtendedPacket(
128+
Command.CONTROL,
129+
byteArrayOf(ControlSubCmd.SET_LIGHT, v, v)
130+
)
131+
}
132+
133+
/**
134+
* P6 horn: `60 51 [18 01]`. V14 sends `[02 64]` (sound id + volume) and
135+
* the P6 ignores it. Args here are taken verbatim from the InMotion app
136+
* capture and produce the standard P6 chirp.
137+
*/
138+
fun hornP6(): ByteArray =
139+
InMotionV2Protocol.buildExtendedPacket(
140+
Command.CONTROL,
141+
byteArrayOf(ControlSubCmd.PLAY_SOUND, 0x18, 0x01)
142+
)
143+
83144
// --- Control commands ---
84145

85146
/**

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

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,17 +197,51 @@ object InMotionV2Parser {
197197
if (data.size < 4) return null
198198
val voltage = ByteUtils.getUint16LE(data, 0) / 100f
199199
val current = ByteUtils.getInt16LE(data, 2) / 100f
200-
val batteryPercent = ((voltage - 165f) / 70f * 100f).toInt().coerceIn(0, 100)
200+
201+
// Real per-pack battery percent at offsets 20-23 of the data block
202+
// (98.94 / 96.90 in the real-P6 capture, matched the on-screen 98%).
203+
// Falls back to a voltage estimate while frames are still partial.
204+
val battery1 = if (data.size >= 22) ByteUtils.getUint16LE(data, 20) / 100f else 0f
205+
val battery2 = if (data.size >= 24) ByteUtils.getUint16LE(data, 22) / 100f else 0f
206+
val batteryPercent = if (battery1 > 0f || battery2 > 0f) {
207+
((battery1 + battery2) / 2f).toInt().coerceIn(0, 100)
208+
} else {
209+
((voltage - 165f) / 70f * 100f).toInt().coerceIn(0, 100)
210+
}
211+
212+
// Total mileage as uint32 LE at offset 58, in 0.01 km units.
213+
// (Matched on-screen 1773.2 mi / 2853.72 km.)
214+
val tripDistanceKm = if (data.size >= 62) {
215+
ByteUtils.getUint32LE(data, 58) / 100f
216+
} else 0f
217+
201218
return WheelData(
202219
voltage = voltage,
203220
current = current,
204221
batteryPercent = batteryPercent,
205-
battery1Percent = batteryPercent.toFloat(),
206-
battery2Percent = batteryPercent.toFloat(),
222+
battery1Percent = battery1.takeIf { it > 0f } ?: batteryPercent.toFloat(),
223+
battery2Percent = battery2.takeIf { it > 0f } ?: batteryPercent.toFloat(),
224+
tripDistance = tripDistanceKm,
207225
timestamp = System.currentTimeMillis()
208226
)
209227
}
210228

229+
/**
230+
* Parse the P6 settings response (sub 0x20 with arg 0x20). The 51-byte
231+
* body has current tiltback at offset 13-14 as uint16 LE in 0.01 km/h.
232+
* Only that field is filled in today — alarm thresholds at 17-19 are
233+
* present in the bytes but we don't have a labelled capture confirming
234+
* which is alarm-1 / alarm-2 / alarm-3, so they stay at their defaults.
235+
*/
236+
fun parseP6Settings(data: ByteArray): WheelSettings? {
237+
if (data.size < 21) return null
238+
// First byte echoes the sub-cmd (0x20). Skip it.
239+
val d = if (data[0] == 0x20.toByte()) data.copyOfRange(1, data.size) else data
240+
if (d.size < 14) return null
241+
val tiltback = ByteUtils.getUint16LE(d, 12) / 100f
242+
return WheelSettings(maxSpeedKmh = tiltback)
243+
}
244+
211245
/**
212246
* Extract the ASCII serial from the data block of a `21 02 86 01 00 …` info
213247
* bundle response. Returns null if the layout doesn't match what we've seen.

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ interface WheelAdapter {
7373
fun horn(): ByteArray?
7474
fun setLight(on: Boolean): ByteArray?
7575
fun setMaxSpeed(tiltbackKmh: Float, alarmKmh: Float): ByteArray?
76+
77+
/**
78+
* Optional second packet to send right after [setMaxSpeed], used by the P6
79+
* to commit the new tiltback to flash via `60 3e [val 00 00]`. Return null
80+
* for wheels that persist the change in a single write.
81+
*/
82+
fun setMaxSpeedCommit(tiltbackKmh: Float): ByteArray? = null
83+
7684
fun setVolume(percent: Int): ByteArray?
7785
fun setDRL(on: Boolean): ByteArray?
7886
fun setLock(locked: Boolean): ByteArray?

app/src/main/java/com/eried/eucplanet/data/repository/WheelRepository.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,9 @@ class WheelRepository @Inject constructor(
292292
// tiltback, the readback-based detector would lock the toggle on.
293293
lastSentTiltbackKmh = tiltbackKmh
294294
wheelAdapter.setMaxSpeed(tiltbackKmh, beepKmh)?.let { bleManager.writeCommand(it) }
295+
// Some wheels (P6) need a follow-up flash-commit packet for the change
296+
// to persist past sleep. Adapter returns null for wheels that don't.
297+
wheelAdapter.setMaxSpeedCommit(tiltbackKmh)?.let { bleManager.writeCommand(it) }
295298
}
296299

297300
suspend fun toggleSafetySpeed() {

0 commit comments

Comments
 (0)