Skip to content

Commit a9cce42

Browse files
eriedclaude
andcommitted
P6: fix temperatures, add connect-auth handshake, serialise lock taps
Three fixes from a labelled real-hardware capture (FINALP6/NEW CAPTURE) plus its WIM-rendered values. 1. parseP6Telemetry temperatures: replaced data[28]/4 (MOS) and data[30]/4 (motor) with the actual MOS sensor at data[70] read as a raw Fahrenheit byte. data[28..29] is the speed-alarm field (uint16 LE = 13679 = 85 mph * 100, constant across 2300+ frames in the long ride capture). Motor and driver-board temps are not present in the realtime 0x87 stream on this firmware, so they are no longer reported on P6. 2. Connect-time auth handshake. The P6 silently drops control writes (light, horn, auto-headlight, max-speed) at the L2CAP layer until the password handshake has run once. Added requiresConnectAuth() to WheelAdapter (default false), set true in the P6 path of InMotionV2Adapter, and wired runConnectAuthHandshake() into the polling loop right after initSequence finishes. V14 wheels are unaffected (default false). 3. Mutex around authenticateAndLock. The pending CompletableDeferreds were nullable singletons that get clobbered on rapid lock taps, stranding the first coroutine's deferred to time out without ever writing the lock packet. Added authMutex.withLock to serialise. Also exposed setP6AutoHeadlight builder for the new 0x2F sub-cmd (unused for now; UI wiring to follow). Added analysis tools under tools/ for re-running the offset hunt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4ec5c80 commit a9cce42

8 files changed

Lines changed: 830 additions & 18 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,18 @@ class InMotionV2Adapter @Inject constructor() : WheelAdapter {
156156
override fun verifyAuth(encryptedKey: ByteArray): ByteArray =
157157
InMotionV2Commands.verifyAuth(encryptedKey)
158158

159+
/**
160+
* The InMotion P6 silently drops control commands (light, horn, auto
161+
* headlight, max-speed) until the password auth handshake has run
162+
* once after connect. The handshake is a fixed echo (the wheel returns
163+
* a 16-byte "encrypted" blob and accepts the same blob back), so
164+
* running it adds no security but unlocks the control endpoint.
165+
*
166+
* V14 family wheels do NOT need this — their light/horn writes work
167+
* pre-auth; only lock requires the handshake on demand.
168+
*/
169+
override fun requiresConnectAuth(): Boolean = useP6Protocol
170+
159171
/**
160172
* Walk the reassembly buffer for complete AA AA frames, parse each, decode,
161173
* and return the resulting DecodeResults. Mirrors the legacy reassembly that

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,23 @@ object InMotionV2Commands {
141141
byteArrayOf(ControlSubCmd.PLAY_SOUND, 0x18, 0x01)
142142
)
143143

144+
/**
145+
* P6 Auto Headlight toggle (the "Auto Headlight" switch under General
146+
* Settings → Lighting). Wire format: `aa aa 16 05 02 21 60 2f [v] [chk]`,
147+
* single-byte payload (1 = ON, 0 = OFF). Verified against the FINAL P6
148+
* capture — five toggles by the user produced exactly this frame each
149+
* time, with `2f 01 7e` for ON and `2f 00 7f` for OFF.
150+
*
151+
* On a P6, manual Light (`60 50`) is independent of Auto Headlight: when
152+
* Auto Headlight is on, the wheel decides based on ambient light; when
153+
* it is off, the user controls the headlight directly via `60 50`.
154+
*/
155+
fun setP6AutoHeadlight(on: Boolean): ByteArray =
156+
InMotionV2Protocol.buildExtendedPacket(
157+
Command.CONTROL,
158+
byteArrayOf(0x2F, if (on) 0x01 else 0x00)
159+
)
160+
144161
/**
145162
* Set the P6 **Speed Limit Alarm** (the threshold above which the wheel
146163
* beeps). Goes through `60 3e [v_lo v_hi 00 00]` — same opcode the

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

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -241,17 +241,30 @@ object InMotionV2Parser {
241241
ByteUtils.getUint32LE(data, 58) / 100f
242242
} else 0f
243243

244-
// Per-sensor temperatures: byte / 4 = degrees Celsius.
245-
// - Off 28: MOS temperature. Verified at v1:23 = 27.75 C ≈ 82 F label.
246-
// - Off 30: motor temperature. Verified at v1:23 = 51.00 C ≈ 124 F label.
247-
// Driver-board temp would land near 32-33 C (= 91 F label) but no
248-
// single-byte offset in the labelled frame fits cleanly — leaving
249-
// it un-parsed until a longer labelled capture lets us nail it.
250-
val mosTempC = if (data.size > 28) data[28].toInt() and 0xFF else 0
251-
val motorTempC = if (data.size > 30) data[30].toInt() and 0xFF else 0
244+
// MOS temperature: byte at offset 70, raw degrees Fahrenheit.
245+
// Verified against the FINAL P6 capture (NEW CAPTURE/btsnoop_hci.log):
246+
// labelled MOS=72 °F at video t=120 s reads 0x48 = 72 across the
247+
// entire capture, and the OLD ride capture's data[70] drifts
248+
// 67-80 °F (19-27 °C) plausibly with riding heat.
249+
//
250+
// The previous offsets 28/30 with byte/4 were a numerical
251+
// coincidence: data[28..29] is the speed-alarm pair (uint16 LE in
252+
// 0.01 km/h, value 13679 = 85 mph for our wheel) and the low byte
253+
// 0x6f /4 = 27.75 happened to land near a hot-wheel MOS reading in
254+
// the old capture. Across 2300+ frames in the long ride capture,
255+
// data[28] is constant at 111 — confirming it is not a sensor.
256+
//
257+
// Motor and driver-board temperatures do not appear in the realtime
258+
// 0x87 stream on this firmware (every candidate offset is either
259+
// a static config byte or a wrap-around counter). The InMotion app
260+
// shows them as 79 °F on a parked wheel, which is most likely a
261+
// cached default rather than a live sensor read. We therefore only
262+
// expose MOS until a different request unlocks the other sensors.
252263
val temps = mutableListOf<Float>()
253-
if (mosTempC > 0) temps.add(mosTempC / 4f)
254-
if (motorTempC > 0) temps.add(motorTempC / 4f)
264+
if (data.size > 70) {
265+
val mosF = data[70].toInt() and 0xFF
266+
if (mosF > 0) temps.add((mosF - 32) * 5f / 9f)
267+
}
255268

256269
// Park vs Drive: offset 80 = 0x49 when the wheel is engaged
257270
// (rider on, motor under load), 0x00 when lifted off / park-mode,

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,22 @@ interface WheelAdapter {
9797
fun requestAuthKey(): ByteArray?
9898
fun verifyAuth(encryptedKey: ByteArray): ByteArray?
9999

100+
/**
101+
* Whether the wheel needs the password auth handshake to be run once
102+
* right after [initSequence] finishes, before any control writes. Set to
103+
* true for wheels where the firmware silently drops control commands
104+
* (light, horn, max-speed) until the connect-time handshake completes.
105+
*
106+
* Confirmed cases:
107+
* - InMotion P6: requires auth at connect, otherwise the dashboard
108+
* Light / Auto Headlight toggles look successful at the L2CAP layer
109+
* but the wheel never obeys.
110+
*
111+
* Default false (no extra writes); the lock path runs auth on demand
112+
* via [requestAuthKey] / [verifyAuth] regardless of this flag.
113+
*/
114+
fun requiresConnectAuth(): Boolean = false
115+
100116
/**
101117
* Process a raw BLE notification and return zero or more decoded results.
102118
*

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

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
2222
import kotlinx.coroutines.flow.StateFlow
2323
import kotlinx.coroutines.flow.asStateFlow
2424
import kotlinx.coroutines.launch
25+
import kotlinx.coroutines.sync.Mutex
26+
import kotlinx.coroutines.sync.withLock
2527
import kotlinx.coroutines.withTimeoutOrNull
2628
import javax.inject.Inject
2729
import javax.inject.Singleton
@@ -110,6 +112,14 @@ class WheelRepository @Inject constructor(
110112
private var pendingAuthKeyDeferred: CompletableDeferred<ByteArray>? = null
111113
private var pendingAuthConfirmDeferred: CompletableDeferred<Boolean>? = null
112114

115+
// Serialise the lock auth handshake so rapid taps can't strand each
116+
// other's deferreds. Without this, two concurrent toggleLock() calls
117+
// both write the singleton `pendingAuthKeyDeferred`; the first call's
118+
// deferred is overwritten and times out without ever reaching the
119+
// setLock write — exactly the symptom seen on the P6 ("lock doesn't
120+
// work" while WIM works because WIM serialises taps in its UI).
121+
private val authMutex = Mutex()
122+
113123
private var lastConnectedAddress: String? = null
114124
private var initState = 0
115125
private var pollingActive = false
@@ -223,20 +233,20 @@ class WheelRepository @Inject constructor(
223233
* requestAuthKey → receive 16-byte key → verifyAuth → receive confirm → setLock
224234
* handshake; otherwise we just send setLock directly.
225235
*/
226-
private suspend fun authenticateAndLock(locked: Boolean): Boolean {
227-
val lockPacket = wheelAdapter.setLock(locked) ?: return false
236+
private suspend fun authenticateAndLock(locked: Boolean): Boolean = authMutex.withLock {
237+
val lockPacket = wheelAdapter.setLock(locked) ?: return@withLock false
228238

229239
if (!wheelAdapter.capabilities.needsAuthForLock) {
230240
bleManager.writeCommand(lockPacket)
231-
return true
241+
return@withLock true
232242
}
233243

234244
val authReqPacket = wheelAdapter.requestAuthKey()
235245
val verifyBuilder = wheelAdapter::verifyAuth
236246
if (authReqPacket == null) {
237247
// Capabilities say auth is required but adapter exposes no key request — bug.
238248
Log.e(TAG, "Lock: adapter requires auth but provides no requestAuthKey()")
239-
return false
249+
return@withLock false
240250
}
241251

242252
// Step 1: Request auth key from wheel
@@ -250,15 +260,15 @@ class WheelRepository @Inject constructor(
250260

251261
if (key == null) {
252262
Log.e(TAG, "Lock: auth key timeout")
253-
return false
263+
return@withLock false
254264
}
255265
authKey = key
256266
Log.i(TAG, "Lock: got auth key (${key.size} bytes): ${key.joinToString(" ") { "%02X".format(it) }}")
257267

258268
// Step 2: Verify auth by echoing the key back
259269
val verifyPacket = verifyBuilder(key) ?: run {
260270
Log.e(TAG, "Lock: adapter returned null for verifyAuth")
261-
return false
271+
return@withLock false
262272
}
263273
val confirmDeferred = CompletableDeferred<Boolean>()
264274
pendingAuthConfirmDeferred = confirmDeferred
@@ -270,14 +280,14 @@ class WheelRepository @Inject constructor(
270280

271281
if (!confirmed) {
272282
Log.e(TAG, "Lock: auth verify failed or timeout")
273-
return false
283+
return@withLock false
274284
}
275285
Log.i(TAG, "Lock: auth verified, sending lock=$locked")
276286

277287
// Step 3: Send lock/unlock command
278288
Log.i(TAG, "Lock packet (${lockPacket.size} bytes): ${lockPacket.joinToString(" ") { "%02X".format(it) }}")
279289
bleManager.writeCommand(lockPacket)
280-
return true
290+
true
281291
}
282292

283293
fun setDRL(on: Boolean) {
@@ -344,10 +354,21 @@ class WheelRepository @Inject constructor(
344354
private suspend fun runPollingLoop() {
345355
val initSequence = wheelAdapter.initSequence()
346356
var realtimeCycle = 0
357+
var connectAuthDone = !wheelAdapter.requiresConnectAuth()
347358
while (pollingActive && bleManager.connectionState.value == ConnectionState.CONNECTED) {
348359
if (initState < initSequence.size) {
349360
bleManager.writeCommand(initSequence[initState])
350361
initState++
362+
} else if (!connectAuthDone) {
363+
// Wheels that silently drop control commands until the password
364+
// auth handshake has run once (the P6 today). Run it inline
365+
// before normal polling — once it completes, light/horn/max-speed
366+
// writes start being honoured. We mark it done even on failure
367+
// so we don't loop on an authentication that's never going to
368+
// succeed; the user can still use telemetry and the lock path
369+
// re-runs auth on demand.
370+
runConnectAuthHandshake()
371+
connectAuthDone = true
351372
} else {
352373
// Normal polling - realtime data, interleaved with periodic settings refresh
353374
// so externally-changed state (lock via InMotion app, etc.) is detected.
@@ -362,6 +383,35 @@ class WheelRepository @Inject constructor(
362383
}
363384
}
364385

386+
/**
387+
* Run the password handshake right after init completes for wheels that
388+
* gate control writes on it (the P6). Mirrors [authenticateAndLock]'s
389+
* auth steps but stops after the verify ACK — there is no lock packet
390+
* to send. Failure is logged and ignored; the user keeps their telemetry.
391+
*/
392+
private suspend fun runConnectAuthHandshake() {
393+
val authReqPacket = wheelAdapter.requestAuthKey() ?: return
394+
val keyDeferred = CompletableDeferred<ByteArray>()
395+
pendingAuthKeyDeferred = keyDeferred
396+
bleManager.writeCommand(authReqPacket)
397+
Log.i(TAG, "Connect-auth: requesting auth key…")
398+
val key = withTimeoutOrNull(4000L) { keyDeferred.await() }
399+
pendingAuthKeyDeferred = null
400+
if (key == null) {
401+
Log.w(TAG, "Connect-auth: key timeout — control commands may not work until lock toggle")
402+
return
403+
}
404+
authKey = key
405+
val verifyPacket = wheelAdapter.verifyAuth(key) ?: return
406+
val confirmDeferred = CompletableDeferred<Boolean>()
407+
pendingAuthConfirmDeferred = confirmDeferred
408+
bleManager.writeCommand(verifyPacket)
409+
val ok = withTimeoutOrNull(4000L) { confirmDeferred.await() } ?: false
410+
pendingAuthConfirmDeferred = null
411+
if (ok) Log.i(TAG, "Connect-auth: verified, control endpoint primed")
412+
else Log.w(TAG, "Connect-auth: verify failed — control commands may not work")
413+
}
414+
365415
// --- Speed limits reconciliation on (re)connect ---
366416

367417
/**

0 commit comments

Comments
 (0)