LeaperkimProtocol.kt
package io.github.tritbool.euc.ble.protocols
import io.github.tritbool.euc.ble.core.BLEConstants
import io.github.tritbool.euc.ble.core.ByteUtils
import io.github.tritbool.euc.ble.frames.ByteByByteFrameParser
import io.github.tritbool.euc.ble.frames.FrameReassembler
import io.github.tritbool.euc.ble.models.BMSData
import io.github.tritbool.euc.ble.models.EUCData
import io.github.tritbool.euc.ble.models.EUCDevice
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import java.util.UUID
import java.util.zip.CRC32
import kotlin.math.roundToInt
/**
* Leaperkim/Veteran protocol implementation based on WheelLog raw frame behavior.
*
* Stream framing:
* - Header: DC 5A 5C
* - Byte 3: frame length marker (len)
* - Full frame size: len + 4 bytes
* - For long frames (len > 38), trailing CRC32 is expected
*/
open class LeaperkimProtocol(internal val scope: CoroutineScope = CoroutineScope(Dispatchers.IO)) : EUCProtocol {
companion object {
private const val LEAPERKIM_MAX_BMS_CELLS = 42
}
override val manufacturer: String = "Leaperkim"
override val supportedModels: List<String> = listOf(
"Patton", "Patton S", "Sherman", "Sherman S", "Sherman L",
"Lynx", "Lynx S", "Abrams", "Oryx"
)
override val supportedCommandTypes: Set<CommandType> = setOf(
CommandType.LIGHT_ON,
CommandType.LIGHT_OFF,
CommandType.BEEP,
CommandType.SET_PEDALS_MODE,
CommandType.RESET_TRIP,
CommandType.CUSTOM
)
override fun getServiceUUID(): UUID = UUID.fromString(BLEConstants.LEAPERKIM_SERVICE_UUID)
override fun getDataCharacteristicUUID(): UUID =
UUID.fromString(BLEConstants.LEAPERKIM_READ_CHARACTERISTIC)
override fun getWriteCharacteristicUUID(): UUID =
UUID.fromString(BLEConstants.LEAPERKIM_WRITE_CHARACTERISTIC)
override fun canHandle(device: EUCDevice): Boolean {
val name = device.name
return device.manufacturerId == BLEConstants.MANUFACTURER_LEAPERKIM ||
device.manufacturerId == BLEConstants.MANUFACTURER_VETERAN ||
supportedModels.map { model ->
model.contains(
name,
ignoreCase = true
) || name.contains(model, ignoreCase = true)
}.reduce { a, b -> a || b }
}
override fun looksLikeMyFrames(chunk: ByteArray): Boolean {
if (chunk.size < 3) return false
return (chunk[0].toInt() and 0xFF) == 0xDC &&
(chunk[1].toInt() and 0xFF) == 0x5A &&
(chunk[2].toInt() and 0xFF) == 0x5C
}
private enum class ParseState {
UNKNOWN,
LENGTH_SEARCH,
COLLECTING
}
private var parseState: ParseState = ParseState.UNKNOWN
private var old1: Int = 0
private var old2: Int = 0
private var expectedLen: Int = 0
private val streamBuffer = ArrayList<Byte>()
private val unpacker: (Byte) -> List<ByteArray> = unpacker@{ next ->
val out = mutableListOf<ByteArray>()
val c = next.toInt() and 0xFF
when (parseState) {
ParseState.COLLECTING -> {
val currentSize = streamBuffer.size
if ((currentSize == 22 && c != 0x00) ||
(currentSize == 30 && c != 0x00 && c != 0x07) ||
(currentSize == 23 && (c and 0xFE) != 0x00)
) {
resetUnpacker()
return@unpacker out
}
streamBuffer.add(c.toByte())
if (currentSize == expectedLen + 3) {
out.add(streamBuffer.toByteArray())
resetUnpacker()
}
}
ParseState.LENGTH_SEARCH -> {
streamBuffer.add(c.toByte())
expectedLen = c
parseState = ParseState.COLLECTING
old2 = old1
old1 = c
}
ParseState.UNKNOWN -> {
if (c == 0x5C && old1 == 0x5A && old2 == 0xDC) {
streamBuffer.clear()
streamBuffer.add(0xDC.toByte())
streamBuffer.add(0x5A.toByte())
streamBuffer.add(0x5C.toByte())
parseState = ParseState.LENGTH_SEARCH
} else if (c == 0x5A && old1 == 0xDC) {
old2 = old1
} else {
old2 = 0
}
old1 = c
}
}
out
}
private fun resetUnpacker() {
old1 = 0
old2 = 0
expectedLen = 0
parseState = ParseState.UNKNOWN
streamBuffer.clear()
}
@Volatile
var debugFramesObserved: Int = 0
private set
@Volatile
var debugFramesParsed: Int = 0
private set
@Volatile
var debugFramesSent: Int = 0
private set
@Volatile
var debugSendFailures: Int = 0
private set
private val frameParser = ByteByByteFrameParser(unpacker, resetUnpacker = { resetUnpacker() })
private val frameReassembler = FrameReassembler(frameParser)
private val _channel = Channel<EUCData>(capacity = Channel.UNLIMITED)
override val dataFlow: Flow<EUCData> = _channel.receiveAsFlow()
private val _rawFrameFlow = MutableSharedFlow<ByteArray>(
extraBufferCapacity = BLEConstants.DEFAULT_FLOW_BUFFER_CAPACITY,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val rawFrameFlow: Flow<ByteArray> = _rawFrameFlow.asSharedFlow()
//private val scope = CoroutineScope(Dispatchers.IO)
private var sessionStartTimestampMs: Long? = null
@Volatile
private var lastMajorVersion: Int? = null
private val bmsCellPages: MutableMap<Int, DoubleArray> = mutableMapOf()
private val bmsTemperatures: MutableMap<Int, List<Double>> = mutableMapOf()
private val bmsCurrents: MutableMap<Int, Double> = mutableMapOf()
init {
scope.launch {
frameReassembler.observeFrames().collectLatest { frame ->
processFrame(frame)
}
}
}
override fun decode(data: ByteArray): EUCData? {
if (data.isEmpty()) return null
_rawFrameFlow.tryEmit(data.clone())
updateLastKnownVersionFromRawChunk(data)
scope.launch {
frameReassembler.processIncomingBytes(data)
}
return null
}
private fun updateLastKnownVersionFromRawChunk(data: ByteArray) {
if (data.size < 30) return
if (data[0] != 0xDC.toByte() || data[1] != 0x5A.toByte() || data[2] != 0x5C.toByte()) return
val len = ByteUtils.getUnsignedByte(data, 3)
if (data.size != len + 4) return
// WheelLog-compatible Leaperkim frames longer than 38 bytes carry trailing CRC32.
if (len > 38 && !isCrcValid(data, len)) return
val versionRaw = ByteUtils.tryGetUnsignedShortBE(data, 28) ?: return
if (versionRaw > 0) {
lastMajorVersion = extractMajorVersion(versionRaw)
}
}
private fun processFrame(frame: ByteArray) {
debugFramesObserved++
val parsed = parseFrame(frame)
if (parsed != null) {
debugFramesParsed++
val result = _channel.trySend(parsed)
if (result.isSuccess) {
debugFramesSent++
} else {
debugSendFailures++
}
}
}
private fun parseFrame(frame: ByteArray): EUCData? {
if (frame.size < 36) return null
if (frame[0] != 0xDC.toByte() || frame[1] != 0x5A.toByte() || frame[2] != 0x5C.toByte()) return null
val len = ByteUtils.getUnsignedByte(frame, 3)
if (frame.size != len + 4) return null
// WheelLog-compatible Leaperkim frames longer than 38 bytes carry trailing CRC32.
if (len > 38 && !isCrcValid(frame, len)) return null
val voltageRaw = ByteUtils.tryGetUnsignedShortBE(frame, 4) ?: return null
val speedRaw = ByteUtils.tryGetSignedShortBE(frame, 6) ?: return null
val distanceRaw = ByteUtils.tryGetUnsignedIntLE(frame, 8) ?: return null
val totalDistanceRaw = ByteUtils.tryGetUnsignedIntLE(frame, 12) ?: return null
val currentRaw = ByteUtils.tryGetSignedShortBE(frame, 16) ?: return null
val tempRaw = ByteUtils.tryGetSignedShortBE(frame, 18) ?: return null
val angleRaw = ByteUtils.tryGetSignedShortBE(frame, 32)
val pwmRaw = ByteUtils.tryGetUnsignedShortBE(frame, 34) ?: 0
val chargeMode = ByteUtils.tryGetUnsignedShortBE(frame, 22) ?: 0
val autoOffSeconds = ByteUtils.tryGetUnsignedShortBE(frame, 20) ?: 0
val speedAlertRaw = ByteUtils.tryGetUnsignedShortBE(frame, 24) ?: 0
val speedTiltBackRaw = ByteUtils.tryGetUnsignedShortBE(frame, 26) ?: 0
val pedalsMode = ByteUtils.tryGetUnsignedShortBE(frame, 30)
val versionRaw = ByteUtils.tryGetUnsignedShortBE(frame, 28) ?: 0
val voltage = voltageRaw / 100.0
val speed = speedRaw / 100.0
val current = currentRaw / 100.0
val temperature = tempRaw / 100.0
val pwm = pwmRaw / 100.0
val angle = angleRaw?.let { it / 100.0 }
if (voltage !in 20.0..180.0) return null
if (speed !in -120.0..120.0) return null
if (current !in -300.0..300.0) return null
if (temperature !in -50.0..130.0) return null
val majorVersion = extractMajorVersion(versionRaw)
lastMajorVersion = majorVersion
if (majorVersion >= 5) {
parseSmartBms(frame)
}
val model = modelByMajorVersion(majorVersion)
val battery = estimateBatteryPercent(voltageRaw, majorVersion)
val tripDistanceKm = decodeDistanceKm(distanceRaw)
val totalDistanceKm = decodeDistanceKm(totalDistanceRaw)
val now = System.currentTimeMillis()
val rideTimeSeconds = deriveRideTimeSeconds(now)
return EUCData(
speed = speed,
voltage = voltage,
current = current,
temperature = temperature,
batteryLevel = battery,
distance = tripDistanceKm,
power = voltage * current,
pwm = pwm,
timestamp = now,
rawData = frame,
manufacturer = manufacturer,
model = model,
serialNumber = null,
firmwareVersion = if (versionRaw > 0) formatVersion(versionRaw) else null,
isCharging = chargeMode > 0,
rideTime = rideTimeSeconds,
cellVoltages = getCombinedCellVoltages(),
motorTemperature = null,
totalDistance = totalDistanceKm,
angle = angle,
pedalsMode = pedalsMode,
autoPowerOffMinutes = autoOffSeconds.takeIf { it > 0 }?.let { it / 60 },
tiltBackSpeed = speedTiltBackRaw.takeIf { it > 0 }?.let { it * 10 },
alarm1Speed = speedAlertRaw.takeIf { it > 0 }?.let { it * 10 }
)
}
private fun parseSmartBms(frame: ByteArray) {
val packetNum = ByteUtils.tryGetUnsignedByte(frame, 46) ?: return
val bmsIndex = if (packetNum < 4) 1 else 2
val cells = bmsCellPages.getOrPut(bmsIndex) { DoubleArray(LEAPERKIM_MAX_BMS_CELLS) }
when (packetNum) {
0, 4 -> {
val bms1CurrentRaw = ByteUtils.tryGetSignedShortBE(frame, 69)
val bms2CurrentRaw = ByteUtils.tryGetSignedShortBE(frame, 71)
bms1CurrentRaw?.let { bmsCurrents[1] = it / 100.0 }
bms2CurrentRaw?.let { bmsCurrents[2] = it / 100.0 }
}
1, 5 -> {
for (i in 0 until 15) {
val raw = ByteUtils.tryGetUnsignedShortBE(frame, 53 + i * 2) ?: continue
cells[i] = raw / 1000.0
}
}
2, 6 -> {
for (i in 0 until 15) {
val raw = ByteUtils.tryGetUnsignedShortBE(frame, 53 + i * 2) ?: continue
val index = i + 15
if (index in cells.indices) {
cells[index] = raw / 1000.0
}
}
}
3, 7 -> {
for (i in 0 until 12) {
val raw = ByteUtils.tryGetUnsignedShortBE(frame, 59 + i * 2) ?: continue
val index = i + 30
if (index in cells.indices) {
cells[index] = raw / 1000.0
}
}
val temps = buildList {
for (i in 0 until 6) {
val raw = ByteUtils.tryGetSignedShortBE(frame, 47 + i * 2) ?: continue
add(raw / 100.0)
}
}
if (temps.isNotEmpty()) {
bmsTemperatures[bmsIndex] = temps
}
}
}
}
private fun getCombinedCellVoltages(): List<Double>? {
if (bmsCellPages.isEmpty()) return null
val combined = bmsCellPages.values
.flatMap { it.asList() }
.filter { it > 0.0 }
return combined.ifEmpty { null }
}
override fun getBMSData(): List<BMSData> {
val allIndices =
(bmsCellPages.keys + bmsTemperatures.keys + bmsCurrents.keys).distinct().sorted()
return allIndices.map { index ->
BMSData(
bmsIndex = index,
voltage = null,
current = bmsCurrents[index],
remainingCapacity = null,
factoryCapacity = null,
cycles = null,
temperatures = bmsTemperatures[index],
cellVoltages = bmsCellPages[index]?.asList()?.filter { it > 0.0 }?.ifEmpty { null }
)
}
}
private fun isCrcValid(frame: ByteArray, len: Int): Boolean {
if (frame.size < len + 4) return false
val crc = CRC32()
crc.update(frame, 0, len)
val calc = crc.value
val provided = ByteUtils.getUnsignedIntBE(frame, len).toLong()
return calc == provided
}
private fun decodeDistanceKm(raw: Long): Double {
val masked = raw and 0x00FF_FFFFL
return masked / 1000.0
}
protected open fun modelByMajorVersion(version: Int): String {
return when (version) {
0, 1 -> "Sherman"
2 -> "Abrams"
3 -> "Sherman S"
4 -> "Patton"
5 -> "Lynx"
6 -> "Sherman L"
7 -> "Patton S"
8 -> "Oryx"
9 -> "Lynx S"
else -> "Leaperkim"
}
}
protected open fun estimateBatteryPercent(voltageRaw: Int, majorVersion: Int): Int {
val battery = when (majorVersion) {
4, 7 -> ((voltageRaw - 9600) / (12525.0 - 9600.0) * 100.0)
5, 6, 9 -> ((voltageRaw - 11520) / (15030.0 - 11520.0) * 100.0)
8 -> ((voltageRaw - 13886) / (17535.0 - 13886.0) * 100.0)
else -> ((voltageRaw - 7935) / (10020.0 - 7935.0) * 100.0)
}
return battery.roundToInt().coerceIn(0, 100)
}
protected open fun extractMajorVersion(versionRaw: Int): Int = versionRaw / 1000
protected open fun formatVersion(versionRaw: Int): String {
val major = versionRaw / 1000
val minor = (versionRaw % 1000) / 100
val patch = versionRaw % 100
return "%03d.%01d.%02d".format(major, minor, patch)
}
private fun deriveRideTimeSeconds(nowMs: Long): Long {
val start = sessionStartTimestampMs ?: nowMs.also { sessionStartTimestampMs = it }
return ((nowMs - start) / 1000L).coerceAtLeast(0L)
}
override fun createCommand(commandType: CommandType, value: Any): ByteArray {
return when (commandType) {
CommandType.LIGHT_ON -> "SetLightON".encodeToByteArray()
CommandType.LIGHT_OFF -> "SetLightOFF".encodeToByteArray()
CommandType.BEEP -> {
if ((lastMajorVersion ?: 0) < 3) {
"b".encodeToByteArray()
} else {
byteArrayOf(
0x4c, 0x6b, 0x41, 0x70, 0x0e, 0x00,
0x80.toByte(), 0x80.toByte(), 0x80.toByte(), 0x01,
0xca.toByte(), 0x87.toByte(), 0xe6.toByte(), 0x6f
)
}
}
CommandType.SET_PEDALS_MODE -> {
when ((value as? Int)?.coerceIn(0, 2)) {
0 -> "SETh".encodeToByteArray()
1 -> "SETm".encodeToByteArray()
2 -> "SETs".encodeToByteArray()
else -> byteArrayOf()
}
}
CommandType.RESET_TRIP -> "CLEARMETER".encodeToByteArray()
CommandType.CUSTOM -> {
when (value) {
is ByteArray -> value.clone()
is String -> value.encodeToByteArray()
else -> byteArrayOf()
}
}
else -> byteArrayOf()
}
}
override fun isDeviceReady(data: EUCData): Boolean {
return data.voltage > 40.0 && data.model.isNotBlank()
}
override fun close() {
scope.cancel()
bmsCellPages.clear()
bmsTemperatures.clear()
bmsCurrents.clear()
_channel.close()
}
}