KingsongProtocol.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
/**
* Improved KingSong protocol: tolerant parsing, header resync, header-aware frame reassembly
* using ByteByByteFrameParser, safe bounds checks, optional cell voltages parsing, and clamped command generation.
*
* Supports frame types: 0xA9 (live telemetry), 0xB9 (distance/fan/temp2), 0xBB (name/model/version),
* 0xB3 (serial number), 0xF5 (CPU load/PWM), 0xF6 (speed limit), 0xA4/0xB5 (alarm speeds),
* 0xF1/0xF2 (Smart BMS data).
*/
class KingsongProtocol(internal val scope: CoroutineScope = CoroutineScope(Dispatchers.IO)) :
EUCProtocol {
private val header1 = BLEConstants.KINGSONG_FRAME_HEADER_1
private val header2 = BLEConstants.KINGSONG_FRAME_HEADER_2
private val MIN_LENGTH = 20
// Keep enough replay for short startup races and enough extra capacity for bursty BLE chunks.
private val unpackBuffer = ArrayList<Byte>()
// Unpacker: accumulates bytes and returns 0..N complete frames.
// Heuristic rule: detects headers (AA 55 or 55 AA) and extracts the slice until the next header.
private val unpacker: (Byte) -> List<ByteArray> = { b: Byte ->
val out = mutableListOf<ByteArray>()
unpackBuffer.add(b)
fun findHeaderIndex(from: Int = 0): Int {
val bufSize = unpackBuffer.size
if (bufSize < 2) return -1
var i = maxOf(from, 0)
val maxStart = bufSize - 2
while (i <= maxStart) {
val a = unpackBuffer[i]
val c = unpackBuffer[i + 1]
if ((a == header1[0] && c == header1[1]) || (a == header2[0] && c == header2[1])) return i
i++
}
return -1
}
var headerIdx = findHeaderIndex(0)
while (headerIdx >= 0) {
// look for the next header
val nextHeader = findHeaderIndex(headerIdx + 2)
if (nextHeader >= 0) {
// extract frame [headerIdx, nextHeader)
val len = nextHeader - headerIdx
val frame = ByteArray(len) { i -> unpackBuffer[headerIdx + i] }
out.add(frame)
// remove emitted bytes
repeat(nextHeader) { unpackBuffer.removeAt(0) }
headerIdx = findHeaderIndex(0)
continue
}
// no next header
// if we have at least MIN_LENGTH bytes after the header, emit at least that (floating heuristic)
if (unpackBuffer.size >= headerIdx + MIN_LENGTH) {
// emit all remaining as a frame instead of waiting indefinitely
val len = unpackBuffer.size - headerIdx
val frame = ByteArray(len) { i -> unpackBuffer[headerIdx + i] }
out.add(frame)
// remove emitted bytes
repeat(headerIdx + len) { unpackBuffer.removeAt(0) }
} else {
// not enough data to complete a frame, wait for more
break
}
headerIdx = findHeaderIndex(0)
}
// if no header, keep at most 1 byte (preserve possibility of fractured header)
if (findHeaderIndex(0) < 0) {
val keep = 1
while (unpackBuffer.size > keep) unpackBuffer.removeAt(0)
}
out
}
override val manufacturer: String = "KingSong"
override val supportedModels: List<String> = listOf(
"KS-14D", "KS-16", "KS-16S", "KS-16X", "KS-18L", "KS-18XL",
"KS-19", "KS-S18", "KS-S19", "KS-S20", "KS-S22", "KS-F22"
)
override val supportedCommandTypes: Set<CommandType> = setOf(
CommandType.LIGHT_ON,
CommandType.LIGHT_OFF,
CommandType.SET_LIGHT_MODE,
CommandType.LIGHT_BRIGHTNESS,
CommandType.BEEP,
CommandType.POWER_OFF,
CommandType.SET_PEDALS_MODE,
CommandType.SET_LED_MODE,
CommandType.SET_SPEED_LIMIT,
CommandType.SET_ALARM_SPEED,
CommandType.CALIBRATE,
CommandType.REQUEST_SERIAL,
CommandType.REQUEST_FIRMWARE
)
override fun getServiceUUID(): UUID = UUID.fromString(BLEConstants.KINGSONG_SERVICE_UUID)
override fun getDataCharacteristicUUID(): UUID =
UUID.fromString(BLEConstants.KINGSONG_READ_CHARACTERISTIC)
override fun canHandle(device: EUCDevice): Boolean {
val name = device.name
return device.manufacturerId == BLEConstants.MANUFACTURER_KINGSONG ||
supportedModels.map { model -> model.contains(name, ignoreCase = true) }
.reduce { a, b -> a || b }
}
override fun looksLikeMyFrames(chunk: ByteArray): Boolean {
if (chunk.size < 2) return false
val a = chunk[0].toInt() and 0xFF
val b = chunk[1].toInt() and 0xFF
return (a == 0xAA && b == 0x55) || (a == 0x55 && b == 0xAA)
}
private fun ensureRange(data: ByteArray, offset: Int, length: Int): Boolean {
return offset >= 0 && data.size >= offset + length
}
// Internal buffer used by the unpacker
private val byteParser = ByteByByteFrameParser(unpacker, resetUnpacker = {
unpackBuffer.clear()
})
private val frameReassembler = FrameReassembler(byteParser)
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 _writeFlow = MutableSharedFlow<ByteArray>(
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val writeFlow: Flow<ByteArray> = _writeFlow.asSharedFlow()
//private val scope = CoroutineScope(Dispatchers.IO)
private var sessionStartTimestampMs: Long? = null
private var lastKnownPwm: Double? = null
// Stateful fields accumulated from non-A9 frames
private var lastKnownModel: String? = null
private var lastKnownFirmwareVersion: String? = null
private var lastKnownSerialNumber: String? = null
private var lastKnownTopSpeed: Double? = null
private var lastKnownFanStatus: Int? = null
private var lastKnownChargingStatus: Int? = null
private var lastKnownTemperature2: Double? = null
private var lastKnownCpuLoad: Int? = null
private var lastKnownSpeedLimit: Double? = null
private var lastKnownAlarm1Speed: Int? = null
private var lastKnownAlarm2Speed: Int? = null
private var lastKnownAlarm3Speed: Int? = null
private var lastKnownWheelMaxSpeed: Int? = null
private var lastKnownWheelDistance: Double? = null
private var lastKnownTotalDistance: Double? = null
// BMS cell data: bmsIndex (1 or 2) -> array of cell voltages
private val bmsCellPages: MutableMap<Int, DoubleArray> = mutableMapOf()
// BMS summary data from page 0x00: bmsIndex -> [voltage, current, remainingCapacity, factoryCapacity, cycles]
private val bmsSummary: MutableMap<Int, BMSSummary> = mutableMapOf()
// BMS temperature data from page 0x01: bmsIndex -> list of temperature values in °C
private val bmsTemperatures: MutableMap<Int, List<Double>> = mutableMapOf()
private data class BMSSummary(
val voltage: Double, // volts
val current: Double, // amps
val remainingCapacity: Int, // mAh
val factoryCapacity: Int, // mAh
val cycles: Int // charge cycles
)
companion object {
private const val MAX_BMS_CELLS = 30
// Frame types
private const val FRAME_TYPE_A9 = 0xA9
private const val FRAME_TYPE_B9 = 0xB9
private const val FRAME_TYPE_BB = 0xBB
private const val FRAME_TYPE_B3 = 0xB3
private const val FRAME_TYPE_F5 = 0xF5
private const val FRAME_TYPE_F6 = 0xF6
private const val FRAME_TYPE_A4 = 0xA4
private const val FRAME_TYPE_B5 = 0xB5
private const val FRAME_TYPE_F1 = 0xF1
private const val FRAME_TYPE_F2 = 0xF2
// BMS page types
private const val BMS_PAGE_SUMMARY = 0x00
private const val BMS_PAGE_TEMPERATURES = 0x01
private const val BMS_PAGE_CELL_VOLTAGES_START = 0x02
// Special byte values
private const val PEDALS_MODE_MARKER = 0xE0
private const val BMS_FRAME_OFFSET = 0xF0
// Serial number length
private const val SERIAL_NUMBER_LENGTH = 17
// Frame byte offsets
private const val OFFSET_VOLTAGE = 2
private const val OFFSET_SPEED = 4
private const val OFFSET_MESSAGE_TYPE = 16
private const val OFFSET_PAGE_NUM = 17
}
init {
// Start observing frames asynchronously and process them
scope.launch {
frameReassembler.observeFrames().collectLatest { frame ->
processFrame(frame)
}
}
}
private fun parseFrame(data: ByteArray): EUCData? {
if (data.isEmpty()) return null
val headerIdx = run {
for (i in 0..(data.size - 2)) {
if (data[i] == header1[0] && data[i + 1] == header1[1]) return@run i
if (data[i] == header2[0] && data[i + 1] == header2[1]) return@run i
}
-1
}
if (headerIdx < 0) return null
if (data.size - headerIdx < MIN_LENGTH) {
return null
}
val messageType = ByteUtils.getUnsignedByte(data, headerIdx + OFFSET_MESSAGE_TYPE)
return when (messageType) {
FRAME_TYPE_A9 -> parseTypeA9(data, headerIdx)
FRAME_TYPE_B9 -> {
parseTypeB9(data, headerIdx)
null
}
FRAME_TYPE_BB -> {
parseTypeBB(data, headerIdx)
null
}
FRAME_TYPE_B3 -> {
parseTypeB3(data, headerIdx)
null
}
FRAME_TYPE_F5 -> {
parseTypeF5(data, headerIdx)
null
}
FRAME_TYPE_F6 -> {
parseTypeF6(data, headerIdx)
null
}
FRAME_TYPE_A4, FRAME_TYPE_B5 -> {
parseTypeA4B5(data, headerIdx)
null
}
FRAME_TYPE_F1, FRAME_TYPE_F2 -> {
parseTypeBMS(data, headerIdx, messageType)
null
}
else -> null
}
}
private fun parseTypeA9(data: ByteArray, base: Int): EUCData? {
try {
val voltage = if (ensureRange(data, base + OFFSET_VOLTAGE, 2))
ByteUtils.getUnsignedShortLE(data, base + OFFSET_VOLTAGE) / 100.0 else 0.0
val speed = if (ensureRange(data, base + OFFSET_SPEED, 2))
ByteUtils.getUnsignedShortLE(data, base + OFFSET_SPEED) / 100.0 else 0.0
val totalDistRaw = if (ensureRange(data, base + 6, 4))
ByteUtils.getUnsignedIntLE(data, base + 6).toDouble() else 0.0
val current = if (ensureRange(data, base + 10, 2))
ByteUtils.getSignedShortLE(data, base + 10) / 100.0 else 0.0
val temperature = if (ensureRange(data, base + 12, 2))
ByteUtils.getSignedShortLE(data, base + 12) / 100.0 else 0.0
val statusByte = if (ensureRange(data, base + 14, 1))
ByteUtils.getUnsignedByte(data, base + 14) else 0
val batteryLevel = if (ensureRange(data, base + 15, 1)) {
ByteUtils.getUnsignedByte(data, base + 15).coerceIn(0, 100)
} else {
estimateBatteryPercent(voltage)
}
// === Sanity filters ===
if (voltage !in 40.0..180.0) return null
if (speed !in 0.0..80.0) return null
if (temperature !in -40.0..100.0) return null
if (current !in -200.0..200.0) return null
val isCharging = (statusByte and 0x01) != 0
// Mode detection (legacy: byte 15 == PEDALS_MODE_MARKER means mode is in byte 14)
if (ensureRange(data, base + 15, 1) && ByteUtils.getUnsignedByte(
data,
base + 15
) == PEDALS_MODE_MARKER
) {
// pedals mode is in byte 14 — but only when byte 15 is the mode marker
// This conflicts with battery %; in legacy KS the battery is sent separately.
// We don't alter battery here, but note it for reference.
}
lastKnownTotalDistance = totalDistRaw
val power = voltage * current
val now = System.currentTimeMillis()
val rideTimeSeconds = deriveRideTimeSeconds(now)
return EUCData(
speed = speed,
voltage = voltage,
current = current,
temperature = temperature,
batteryLevel = batteryLevel,
distance = lastKnownWheelDistance ?: totalDistRaw,
power = power,
pwm = lastKnownPwm,
timestamp = now,
rawData = data,
manufacturer = manufacturer,
model = lastKnownModel ?: "KingSong",
serialNumber = lastKnownSerialNumber,
firmwareVersion = lastKnownFirmwareVersion,
isCharging = isCharging,
rideTime = rideTimeSeconds,
cellVoltages = getCombinedCellVoltages(),
motorTemperature = null,
totalDistance = lastKnownTotalDistance,
topSpeed = lastKnownTopSpeed,
fanStatus = lastKnownFanStatus,
chargingStatus = lastKnownChargingStatus,
temperature2 = lastKnownTemperature2,
cpuLoad = lastKnownCpuLoad,
speedLimit = lastKnownSpeedLimit,
alarm1Speed = lastKnownAlarm1Speed,
alarm2Speed = lastKnownAlarm2Speed,
alarm3Speed = lastKnownAlarm3Speed,
wheelMaxSpeed = lastKnownWheelMaxSpeed,
wheelDistance = lastKnownWheelDistance
)
} catch (_: Exception) {
return null
}
}
/**
* Frame 0xB9: Distance/Time/Fan/ChargingStatus/Temperature2
* Legacy: wheelDistance(uint32 LE @2), topSpeed(uint16 LE @8), fanStatus(@12),
* chargingStatus(@13), temperature2(uint16 LE @14)
*/
private fun parseTypeB9(data: ByteArray, base: Int) {
if (ensureRange(data, base + 2, 4)) {
lastKnownWheelDistance = ByteUtils.getUnsignedIntLE(data, base + 2).toDouble()
}
if (ensureRange(data, base + 8, 2)) {
lastKnownTopSpeed = ByteUtils.getUnsignedShortLE(data, base + 8) / 100.0
}
if (ensureRange(data, base + 12, 1)) {
lastKnownFanStatus = ByteUtils.getUnsignedByte(data, base + 12)
}
if (ensureRange(data, base + 13, 1)) {
lastKnownChargingStatus = ByteUtils.getUnsignedByte(data, base + 13)
}
if (ensureRange(data, base + 14, 2)) {
lastKnownTemperature2 = ByteUtils.getUnsignedShortLE(data, base + 14) / 100.0
}
}
/**
* Frame 0xBB: Name/Model/Version
* Bytes 2..15: null-terminated ASCII name string (e.g. "KS-16X-1234")
* Model is derived from name parts; version from last segment.
*/
private fun parseTypeBB(data: ByteArray, base: Int) {
if (!ensureRange(data, base + 2, 14)) return
var end = 0
for (i in 0 until 14) {
if (data[base + 2 + i] == 0.toByte()) break
end++
}
if (end == 0) return
val name = String(data, base + 2, end).trim()
val parts = name.split("-")
if (parts.size >= 2) {
lastKnownModel = parts.dropLast(1).joinToString("-")
try {
val versionInt = parts.last().toInt()
lastKnownFirmwareVersion = "%.2f".format(versionInt / 100.0)
} catch (_: NumberFormatException) {
// version part not numeric, keep model as full name
lastKnownModel = name
}
} else {
lastKnownModel = name
}
}
/**
* Frame 0xB3: Serial Number
* Bytes 2..15 (14 bytes) + bytes 17..19 (3 bytes) = 17-char serial
*/
private fun parseTypeB3(data: ByteArray, base: Int) {
if (!ensureRange(data, base + 2, 14)) return
if (!ensureRange(data, base + 17, 3)) return
val snBytes = ByteArray(SERIAL_NUMBER_LENGTH)
System.arraycopy(data, base + 2, snBytes, 0, 14)
System.arraycopy(data, base + 17, snBytes, 14, 3)
lastKnownSerialNumber = String(snBytes).trimEnd('\u0000').trim()
}
private fun parseTypeF5(data: ByteArray, base: Int) {
if (ensureRange(data, base + 14, 1)) {
lastKnownCpuLoad = ByteUtils.getUnsignedByte(data, base + 14)
}
if (!ensureRange(data, base + 15, 1)) return
val outputByte = ByteUtils.getUnsignedByte(data, base + 15)
lastKnownPwm = outputByte / 100.0
}
/**
* Frame 0xF6: Speed limit
* uint16 LE @2, in 0.01 km/h units
*/
private fun parseTypeF6(data: ByteArray, base: Int) {
if (!ensureRange(data, base + 2, 2)) return
lastKnownSpeedLimit = ByteUtils.getUnsignedShortLE(data, base + 2) / 100.0
}
/**
* Frame 0xA4 / 0xB5: Alarm speeds and max speed
* alarm1 @4, alarm2 @6, alarm3 @8, maxSpeed @10 (all single bytes)
*/
private fun parseTypeA4B5(data: ByteArray, base: Int) {
if (ensureRange(data, base + 4, 1)) {
lastKnownAlarm1Speed = ByteUtils.getUnsignedByte(data, base + 4)
}
if (ensureRange(data, base + 6, 1)) {
lastKnownAlarm2Speed = ByteUtils.getUnsignedByte(data, base + 6)
}
if (ensureRange(data, base + 8, 1)) {
lastKnownAlarm3Speed = ByteUtils.getUnsignedByte(data, base + 8)
}
if (ensureRange(data, base + 10, 1)) {
lastKnownWheelMaxSpeed = ByteUtils.getUnsignedByte(data, base + 10)
}
}
/**
* Frame 0xF1/0xF2: Smart BMS data
* BMS number = messageType - BMS_FRAME_OFFSET (1 or 2)
* Page number @17 determines what data is in the payload:
* BMS_PAGE_SUMMARY: voltage, current, remaining/factory cap, cycles
* BMS_PAGE_TEMPERATURES: temperatures (6 probes + MOS temp)
* BMS_PAGE_CELL_VOLTAGES_START..0x06: cell voltages (7 cells per page)
*/
private fun parseTypeBMS(data: ByteArray, base: Int, messageType: Int) {
val bmsIndex = messageType - BMS_FRAME_OFFSET
if (!ensureRange(data, base + OFFSET_PAGE_NUM, 1)) return
val pageNum = ByteUtils.getUnsignedByte(data, base + OFFSET_PAGE_NUM)
when (pageNum) {
0x00 -> {
// Page 0x00: BMS voltage, current, remaining capacity, factory capacity, cycles
if (!ensureRange(data, base + 2, 10)) return
val bmsVoltage = ByteUtils.getUnsignedShortLE(data, base + 2) / 100.0
val bmsCurrent = ByteUtils.getSignedShortLE(data, base + 4) / 100.0
val remainingCapacity = ByteUtils.getUnsignedShortLE(data, base + 6)
val factoryCapacity = ByteUtils.getUnsignedShortLE(data, base + 8)
val cycles = ByteUtils.getUnsignedShortLE(data, base + 10)
bmsSummary[bmsIndex] = BMSSummary(
voltage = bmsVoltage,
current = bmsCurrent,
remainingCapacity = remainingCapacity,
factoryCapacity = factoryCapacity,
cycles = cycles
)
}
0x01 -> {
// Page 0x01: BMS temperatures (6 probes + MOS temp)
val temps = mutableListOf<Double>()
for (i in 0 until 7) {
val offset = base + 2 + i * 2
if (ensureRange(data, offset, 2)) {
val raw = ByteUtils.getSignedShortLE(data, offset)
// Temperature is in 0.1°C units
temps.add(raw / 10.0)
}
}
if (temps.isNotEmpty()) {
bmsTemperatures[bmsIndex] = temps
}
}
0x02, 0x03, 0x04, 0x05, 0x06 -> {
val cells = bmsCellPages.getOrPut(bmsIndex) { DoubleArray(MAX_BMS_CELLS) }
val startCell = (pageNum - 0x02) * 7
for (i in 0 until 7) {
val offset = base + 2 + i * 2
if (ensureRange(data, offset, 2)) {
val cellIndex = startCell + i
if (cellIndex < MAX_BMS_CELLS) {
cells[cellIndex] = ByteUtils.getUnsignedShortLE(data, offset) / 1000.0
}
}
}
}
}
}
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 }
}
/**
* Returns the current BMS data snapshots for all detected battery packs.
* Each entry represents one BMS unit (typically 1 or 2 for dual-battery wheels).
* Data is accumulated from frame types 0xF1/0xF2 pages 0x00 (summary), 0x01 (temperatures),
* and 0x02-0x06 (cell voltages).
*/
override fun getBMSData(): List<BMSData> {
val allIndices =
(bmsSummary.keys + bmsTemperatures.keys + bmsCellPages.keys).distinct().sorted()
return allIndices.map { index ->
val summary = bmsSummary[index]
val temps = bmsTemperatures[index]
val cells = bmsCellPages[index]?.asList()?.filter { it > 0.0 }?.ifEmpty { null }
BMSData(
bmsIndex = index,
voltage = summary?.voltage,
current = summary?.current,
remainingCapacity = summary?.remainingCapacity,
factoryCapacity = summary?.factoryCapacity,
cycles = summary?.cycles,
temperatures = temps,
cellVoltages = cells
)
}
}
private fun deriveRideTimeSeconds(nowMs: Long): Long {
val start = sessionStartTimestampMs ?: nowMs.also { sessionStartTimestampMs = it }
return ((nowMs - start) / 1000L).coerceAtLeast(0L)
}
private fun estimateBatteryPercent(voltage: Double): Int {
return (((voltage - 50.0) / (100.0 - 50.0)) * 100.0).toInt().coerceIn(0, 100)
}
private fun processFrame(frame: ByteArray) {
val parsed = parseFrame(frame)
parsed?.let { _channel.trySend(it) }
if (frame.size >= MIN_LENGTH) {
val headerIdx = run {
for (i in 0..(frame.size - 2)) {
if (frame[i] == header1[0] && frame[i + 1] == header1[1]) return@run i
if (frame[i] == header2[0] && frame[i + 1] == header2[1]) return@run i
}
-1
}
if (headerIdx >= 0 && frame.size - headerIdx >= MIN_LENGTH) {
val messageType = ByteUtils.getUnsignedByte(frame, headerIdx + OFFSET_MESSAGE_TYPE)
if (messageType == FRAME_TYPE_A4) {
val reply = frame.copyOfRange(headerIdx, headerIdx + MIN_LENGTH)
reply[16] = 0x98.toByte()
reply[2] = 0x01.toByte()
_writeFlow.tryEmit(reply)
}
}
}
}
override fun close() {
scope.cancel()
lastKnownPwm = null
_channel.close()
}
override fun decode(data: ByteArray): EUCData? {
if (data.isEmpty()) return null
_rawFrameFlow.tryEmit(data.clone())
// Let the reassembler handle the incoming bytes asynchronously
scope.launch {
frameReassembler.processIncomingBytes(data)
}
// Return null because data is emitted asynchronously via the dataFlow
return null
}
override fun createCommand(commandType: CommandType, value: Any): ByteArray {
return when (commandType) {
CommandType.LIGHT_ON -> buildLegacyCommand(
command = 0x73,
payload2 = 0x13,
payload3 = 0x01
)
CommandType.LIGHT_OFF -> buildLegacyCommand(
command = 0x73,
payload2 = 0x12,
payload3 = 0x01
)
CommandType.SET_LIGHT_MODE -> {
val mode = (value as? Int)?.coerceIn(0, 2) ?: return byteArrayOf()
buildLegacyCommand(command = 0x73, payload2 = mode + 0x12, payload3 = 0x01)
}
CommandType.BEEP -> buildLegacyCommand(command = 0x88)
CommandType.POWER_OFF -> buildLegacyCommand(command = 0x40)
CommandType.SET_PEDALS_MODE -> {
val pedalsMode = (value as? Int)?.coerceIn(0, 2) ?: return byteArrayOf()
buildLegacyCommand(
command = 0x87,
payload2 = pedalsMode,
payload3 = 0xE0,
payload17 = 0x15
)
}
CommandType.SET_LED_MODE -> {
val ledMode = (value as? Int)?.coerceIn(0, 0xFF) ?: return byteArrayOf()
buildLegacyCommand(command = 0x6C, payload2 = ledMode)
}
CommandType.LIGHT_BRIGHTNESS -> {
val intVal = (value as? Int) ?: return byteArrayOf()
val mode = when {
intVal <= 0 -> 0
intVal >= 100 -> 2
else -> 1
}
buildLegacyCommand(command = 0x73, payload2 = mode + 0x12, payload3 = 0x01)
}
CommandType.SET_SPEED_LIMIT -> {
// KingSong uses a combined command (0x85) that sets alarms + max speed together.
// SET_SPEED_LIMIT updates the max speed while preserving current alarm values.
val maxSpeed = (value as? Int)?.coerceIn(0, 100) ?: return byteArrayOf()
buildAlarmSpeedCommand(
alarm1 = lastKnownAlarm1Speed ?: 0,
alarm2 = lastKnownAlarm2Speed ?: 0,
alarm3 = lastKnownAlarm3Speed ?: 0,
maxSpeed = maxSpeed
)
}
CommandType.SET_ALARM_SPEED -> {
// value is expected to be a Map or IntArray [alarm1, alarm2, alarm3]
val speeds = when (value) {
is IntArray -> value
is List<*> -> (value.filterIsInstance<Int>()).toIntArray()
else -> return byteArrayOf()
}
if (speeds.size < 3) return byteArrayOf()
buildAlarmSpeedCommand(
alarm1 = speeds[0].coerceIn(0, 100),
alarm2 = speeds[1].coerceIn(0, 100),
alarm3 = speeds[2].coerceIn(0, 100),
maxSpeed = lastKnownWheelMaxSpeed ?: 0
)
}
CommandType.CALIBRATE -> buildLegacyCommand(command = 0x89)
CommandType.REQUEST_SERIAL -> buildLegacyCommand(command = 0x63)
CommandType.REQUEST_FIRMWARE -> buildLegacyCommand(command = 0x9B)
else -> byteArrayOf()
}
}
private fun buildAlarmSpeedCommand(
alarm1: Int,
alarm2: Int,
alarm3: Int,
maxSpeed: Int
): ByteArray {
val data = byteArrayOf(
0xAA.toByte(), 0x55.toByte(), 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x85.toByte(), 0x14, 0x5A, 0x5A
)
data[2] = alarm1.toByte()
data[4] = alarm2.toByte()
data[6] = alarm3.toByte()
data[8] = maxSpeed.toByte()
return data
}
private fun buildLegacyCommand(
command: Int,
payload2: Int = 0x00,
payload3: Int = 0x00,
payload17: Int = 0x14
): ByteArray {
val data = byteArrayOf(
0xAA.toByte(), 0x55.toByte(), 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x14, 0x5A, 0x5A
)
data[2] = payload2.toByte()
data[3] = payload3.toByte()
data[16] = command.toByte()
data[17] = payload17.toByte()
return data
}
override fun getPollingPlan(): ProtocolPollingPlan {
return ProtocolPollingPlan(
enabled = true,
startupQueries = listOf(
ProtocolQuerySpec(
"ks.request-name-version",
CommandType.REQUEST_FIRMWARE,
maxRetries = 3
),
ProtocolQuerySpec(
"ks.request-serial",
CommandType.REQUEST_SERIAL,
initialDelayMs = 200L,
maxRetries = 3
),
ProtocolQuerySpec(
"ks.request-alarms",
CommandType.CUSTOM,
buildLegacyCommand(command = 0x98),
initialDelayMs = 400L,
maxRetries = 2
)
),
periodicQueries = emptyList()
)
}
override fun matchesQueryResponse(query: ProtocolQuerySpec, data: ByteArray): Boolean {
if (data.size < MIN_LENGTH) return false
val headerIdx = run {
for (i in 0..(data.size - 2)) {
if (data[i] == header1[0] && data[i + 1] == header1[1]) return@run i
if (data[i] == header2[0] && data[i + 1] == header2[1]) return@run i
}
-1
}
if (headerIdx < 0 || data.size - headerIdx < MIN_LENGTH) return false
val messageType = ByteUtils.getUnsignedByte(data, headerIdx + 16)
return when (query.commandType) {
CommandType.REQUEST_FIRMWARE -> messageType == FRAME_TYPE_BB
CommandType.REQUEST_SERIAL -> messageType == FRAME_TYPE_B3
CommandType.CUSTOM -> messageType == FRAME_TYPE_A4 || messageType == FRAME_TYPE_B5
else -> false
}
}
override fun isDeviceReady(data: EUCData): Boolean {
val tempOk = data.temperature < 75.0
val voltageOk = data.voltage > 40.0
val batteryOk = data.batteryLevel >= 5
return voltageOk && tempOk && batteryOk
}
}