Skip to content

Commit 8c0d912

Browse files
eriedclaude
andcommitted
fix(veteran-lock): split the 25-byte LdAp frame across two ATT writes
A fresh Lynx S btsnoop showed the LeaperKim app writes the lock frame in TWO ATT writes — first 20 bytes, then 5 bytes (valueByte + CRC32-BE trailer) — back to back. Sending the same 25-byte buffer as a single ATT write delivers only 20 bytes to the wheel because the negotiated MTU is the default ~23; the CRC check fails on the wheel side and the lock silently no-ops. This is the same MTU constraint that already forced the horn into an LkAp + LdAp pair. WheelAdapter gets a new setLockFollowup() mirroring hornFollowup. The Veteran adapter caches the full 25-byte frame at setLock() so the followup returns a CRC-consistent tail (both halves must come from the same wall-clock build or the CRC drifts). WheelRepository writes both halves in order on both the no-auth path (Veteran) and the auth path (unused by Veteran today, kept symmetric for future families). Test verifies the split sizes, that the concatenated bytes form a CRC-valid 25-byte LdAp frame, and that the followup is locked to the preceding setLock build. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3062e31 commit 8c0d912

6 files changed

Lines changed: 107 additions & 8 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ class CompositeWheelAdapter @Inject constructor(
117117
override fun setVolume(percent: Int): ByteArray? = active.setVolume(percent)
118118
override fun setDRL(on: Boolean): ByteArray? = active.setDRL(on)
119119
override fun setLock(locked: Boolean): ByteArray? = active.setLock(locked)
120+
override fun setLockFollowup(locked: Boolean): ByteArray? = active.setLockFollowup(locked)
120121
override fun resetTripMeter(): ByteArray? = active.resetTripMeter()
121122

122123
override fun requestAuthKey(): ByteArray? = active.requestAuthKey()

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

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,38 @@ class VeteranAdapter @Inject constructor() : WheelAdapter {
131131
override fun setVolume(percent: Int): ByteArray? = null
132132
override fun setDRL(on: Boolean): ByteArray? = null
133133
// Software lock decoded from a Lynx S btsnoop, June 2026 (see
134-
// VeteranCommands.setLock). Bytes 4..7 of the payload are the rider's
135-
// wall clock at the moment of writing; the wheel rejects frames whose
136-
// timestamp doesn't match, which is why the earlier session-counter
137-
// version of this never actually locked the wheel. Wheel CRC-validates
138-
// the frame, so older models that don't recognise the opcode silently
139-
// ignore it — safe to expose on every Veteran-family wheel without
140-
// per-model gating.
141-
override fun setLock(locked: Boolean): ByteArray = VeteranCommands.setLock(locked)
134+
// VeteranCommands.setLock). 25-byte LdAp vendor frame split across TWO
135+
// ATT writes because Lynx-class firmware doesn't negotiate an MTU big
136+
// enough for the full frame:
137+
// - setLock: first 20 bytes (magic + length + first 14 payload bytes)
138+
// - setLockFollowup: trailing 5 bytes (valueByte + CRC32-BE)
139+
// The LeaperKim official app does the same split; sending the full 25
140+
// bytes as one ATT write delivers only the first 20 bytes and the wheel
141+
// rejects the truncated frame on CRC check, which is exactly what the
142+
// earlier single-write build hit on every toggle.
143+
//
144+
// The full frame is cached after [setLock] so [setLockFollowup] returns
145+
// the tail of THAT exact frame: bytes 4..7 of the payload are the rider's
146+
// wall clock at the moment of writing, and the trailing CRC32 is computed
147+
// over the whole frame, so the two halves MUST come from a single build
148+
// or the wheel's CRC check fails. authMutex in WheelRepository serialises
149+
// setLock+setLockFollowup pairs so the cache is safe.
150+
@Volatile private var pendingLockFrame: ByteArray? = null
151+
152+
override fun setLock(locked: Boolean): ByteArray {
153+
val full = VeteranCommands.setLock(locked)
154+
pendingLockFrame = full
155+
return full.copyOfRange(0, VeteranCommands.LOCK_FIRST_WRITE_SIZE)
156+
}
157+
158+
override fun setLockFollowup(locked: Boolean): ByteArray? {
159+
val full = pendingLockFrame ?: return null
160+
pendingLockFrame = null
161+
return full.copyOfRange(
162+
VeteranCommands.LOCK_FIRST_WRITE_SIZE,
163+
VeteranCommands.LOCK_TOTAL_SIZE,
164+
)
165+
}
142166

143167
// CLEARMETER zeroes offset 8..11 (trip) on the next frame; see
144168
// VeteranCommands.resetTrip and spec section 6.

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,4 +266,10 @@ object VeteranCommands {
266266
private val LDAP = byteArrayOf(0x4C, 0x64, 0x41, 0x70) // "LdAp"
267267
private const val SUBOP_TILTBACK: Byte = 0x02
268268
private const val SUBOP_ALARM: Byte = 0x80.toByte()
269+
270+
/** Total bytes in a built lock LdAp frame. */
271+
internal const val LOCK_TOTAL_SIZE = 25
272+
/** First-write boundary for the lock frame: matches the LeaperKim app's
273+
* 20-byte ATT split. Anything beyond this goes in the followup write. */
274+
internal const val LOCK_FIRST_WRITE_SIZE = 20
269275
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,18 @@ interface WheelAdapter {
184184
fun setDRL(on: Boolean): ByteArray?
185185
fun setLock(locked: Boolean): ByteArray?
186186

187+
/**
188+
* Tail bytes to write immediately after [setLock], on wheels whose lock
189+
* frame exceeds the default ATT MTU and must be sent as two writes. The
190+
* caller writes [setLock] first, then this. Default null for families
191+
* whose lock fits in one packet. Veteran (Lynx-class, 25-byte LdAp lock
192+
* frame) returns the trailing 5 bytes (valueByte + CRC32) here; without
193+
* the split the wheel only receives the first 20 bytes and the CRC check
194+
* fails on the wheel side, so the lock silently no-ops — the exact
195+
* symptom users reported.
196+
*/
197+
fun setLockFollowup(locked: Boolean): ByteArray? = null
198+
187199
/**
188200
* Resets the wheel's onboard trip meter (the field reported as
189201
* [com.eried.eucplanet.data.model.WheelData.tripDistance]) by sending the

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,9 +997,15 @@ class WheelRepository @Inject constructor(
997997
*/
998998
private suspend fun authenticateAndLock(locked: Boolean): Boolean = authMutex.withLock {
999999
val lockPacket = wheelAdapter.setLock(locked) ?: return@withLock false
1000+
// Lynx-class Veteran returns a second 5-byte tail because its 25-byte
1001+
// lock frame is split across two writes; pulled IMMEDIATELY after
1002+
// setLock so the cached full-frame in the adapter is still valid.
1003+
// Null on every other family (single-write lock).
1004+
val lockTail = wheelAdapter.setLockFollowup(locked)
10001005

10011006
if (!wheelAdapter.capabilities.needsAuthForLock) {
10021007
bleManager.writeCommand(lockPacket)
1008+
lockTail?.let { bleManager.writeCommand(it) }
10031009
return@withLock true
10041010
}
10051011

@@ -1049,6 +1055,7 @@ class WheelRepository @Inject constructor(
10491055
// Step 3: Send lock/unlock command
10501056
Log.i(TAG, "Lock packet (${lockPacket.size} bytes): ${lockPacket.joinToString(" ") { "%02X".format(it) }}")
10511057
bleManager.writeCommand(lockPacket)
1058+
lockTail?.let { bleManager.writeCommand(it) }
10521059
true
10531060
}
10541061

app/src/test/java/com/eried/eucplanet/ble/VeteranLockTest.kt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,53 @@ class VeteranLockTest {
8888
assertEquals(23, frame[11].toInt() and 0xFF)
8989
assertEquals(42, frame[12].toInt() and 0xFF)
9090
}
91+
92+
/**
93+
* The wheel only accepts the lock frame when it's written as two
94+
* separate ATT writes (20 + 5 bytes), matching the LeaperKim app's
95+
* btsnoop. A single 25-byte write is truncated to 20 bytes by the
96+
* default ATT MTU and the wheel rejects it on CRC check — the bug
97+
* that made the previous "wall-clock only" fix still appear broken.
98+
*/
99+
@Test
100+
fun `adapter splits lock frame into 20 plus 5 byte writes`() {
101+
val adapter = VeteranAdapter()
102+
val first = adapter.setLock(true)
103+
val tail = adapter.setLockFollowup(true)
104+
assertEquals("first ATT write is 20 bytes", 20, first.size)
105+
assertEquals("followup ATT write is 5 bytes", 5, tail!!.size)
106+
// The two halves concatenated must be a CRC-valid 25-byte LdAp frame.
107+
val full = first + tail
108+
assertEquals(25, full.size)
109+
// Magic + length
110+
assertEquals(0x4C.toByte(), full[0])
111+
assertEquals(0x64.toByte(), full[1])
112+
assertEquals(0x41.toByte(), full[2])
113+
assertEquals(0x70.toByte(), full[3])
114+
assertEquals(25.toByte(), full[4])
115+
// CRC32-BE over bytes 0..20 must equal the trailer in bytes 21..24
116+
val want = CRC32().apply { update(full, 0, 21) }.value.toInt()
117+
val got = ((full[21].toInt() and 0xFF) shl 24) or
118+
((full[22].toInt() and 0xFF) shl 16) or
119+
((full[23].toInt() and 0xFF) shl 8) or
120+
(full[24].toInt() and 0xFF)
121+
assertEquals(want, got)
122+
}
123+
124+
/**
125+
* setLockFollowup MUST come from the same build as the preceding setLock
126+
* (the CRC covers the wall-clock bytes; a fresh build at the next second
127+
* would produce a different CRC and split halves wouldn't agree).
128+
*/
129+
@Test
130+
fun `followup tail matches the preceding setLock build`() {
131+
val adapter = VeteranAdapter()
132+
val first = adapter.setLock(false)
133+
// Simulate the WheelRepository call order: setLock immediately, then
134+
// followup. The cache in the adapter holds the full frame.
135+
val tail = adapter.setLockFollowup(false)!!
136+
val full = first + tail
137+
// Repeat: state == 0 (UNLOCK) at payload byte 12 (offset 17 in frame).
138+
assertEquals(0x00.toByte(), full[17])
139+
}
91140
}

0 commit comments

Comments
 (0)