GotwayProtocol.kt
package io.github.tritbool.euc.ble.protocols
import androidx.annotation.VisibleForTesting
import io.github.tritbool.euc.ble.core.BLEConstants
import io.github.tritbool.euc.ble.core.ByteUtils
import io.github.tritbool.euc.ble.frames.FixedSizeFrameParser
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 kotlin.math.abs
/**
* Gotway EUC Protocol Implementation
* Supports Gotway series electric unicycles
*/
/**
* Gotway/Begode reverse-engineered protocol (updated)
*
* This comment summarizes the observed variants:
* - Raw "A" / "B" frames (controller serial stream, header 0x55 0xAA)
* - Short "legacy" packets (e.g., 0x01 / 0x02) re-emitted by some adapters
* - Type 0xA5 packets (compressed commands/status emitted by firmware/adapters)
*
* General observations:
* - Raw A/B frames observed on the serial port typically use header 0x55 0xAA
* and Big Endian (BE) fields for 16/32 bit integers.
* - Legacy packets (0x01/0x02) and some 0xA5 packets often use Little Endian (LE)
* encoding and a more compact format.
* - Current/temperature fields can be signed; must be converted correctly.
* - Many firmware/adapters do not add checksum. The stream can be fragmented,
* delayed, or lose bytes on the BLE side (no flow control).
* - Some packets include BMS cell voltages at the end, encoded as pairs of 2 bytes (LE)
* or in mV/centivolts depending on the variant.
*
* Example (observed):
* A: 55 AA 19 F0 00 00 00 00 00 00 01 2C FD CA 00 01 FF F8 00 18 5A 5A 5A 5A
* B: 55 AA 00 0A 4A 12 48 00 1C 20 00 2A 00 03 00 07 00 08 04 18 5A 5A 5A 5A
*
* Summary format (adjust according to firmware/model):
* - Frame A (header 0x55 0xAA):
* Bytes 0-1: 0x55 0xAA
* Bytes 2-3: BE voltage (fixed point, e.g., 1/100)
* Bytes 4-5: BE speed (fixed point, e.g., 3.6 * value / 100 -> km/h)
* Bytes 6-9: BE distance (uint32, meters)
* Bytes 10-11: BE current (signed, fixed point)
* Bytes 12-13: BE temperature (signed or raw MPU value)
* Bytes 14-17: unknown / flags
* Byte 18: frame type (e.g., 0x00)
* Byte 19: footer (0x18)
* Bytes 20-..: footer 0x5A 0x5A 0x5A 0x5A (or variants) + optional BMS trailing
*
* - Frame B (header 0x55 0xAA):
* Bytes 2-5: BE total distance (uint32)
* Byte 6: pedals mode / alarms (nibbles)
* Bytes 7-12: additional unknown fields
* Byte 13: LED / mode
* Bytes 14-17: unknown
* Byte 18: frame type (e.g., 0x04)
* Footer same
*
* - Legacy packets (e.g., 0x01 / 0x02):
* - Often sent by Serial->BLE adapter or alternative firmware.
* - LE fields, more compact formats; can represent voltage/speed/etc.
*
* - 0xA5 packets:
* - Used for commands (LIGHT_ON/OFF, BEEP, POWER_OFF) and sometimes for compressed states.
* Different structure (header 0xA5 ...).
*
* Parsing recommendations:
* - Dispatch by first byte/header: 0x55 (A/B raw), 0x01/0x02 (legacy), 0xA5 (command/status),
* or by type byte in the frame if present.
* - For A/B: process integers as BE. For legacy/0xA5: try LE.
* - Handle fragmentation: tolerate variable sizes, ignore too short frames,
* attempt re-synchronization on 0x55 0xAA or adapter headers.
* - Dynamically extract cell voltages from the queue if present:
* read pairs of 2 bytes (LE) and convert to V (mV -> V or /100 -> V depending on ranges).
* - Correctly convert signed values (current, motor temperatures).
* - Stay defensive: validate plausible ranges (voltage, current, temperature).
*
* Why were these variants not in the old comment?
* - The original comment described the raw serial stream observed on a specific controller/firmware.
* Other firmware/adapters (Serial->BLE) re-emit or transform these bytes
* (different headers, different endianness) — these variants were not necessarily
* present during the initial reverse engineering.
*/
open class GotwayProtocol(internal val scope: CoroutineScope = CoroutineScope(Dispatchers.IO)) :
EUCProtocol {
companion object {
const val FRAME_SIZE = 24
val HEADER: ByteArray = BLEConstants.GOTWAY_FRAME_HEADER
val FOOTER: ByteArray = BLEConstants.GOTWAY_FRAME_FOOTER
private const val MIN_BATTERY_VOLTAGE = 52.0
private const val MAX_BATTERY_VOLTAGE = 134.4
private const val MAX_BMS_CELL_SLOTS = 56
}
private val frameParser = FixedSizeFrameParser(FRAME_SIZE, HEADER, FOOTER)
private val frameReassembler: 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 lastKnownVoltage: Double? = null
private var lastKnownCurrent: Double? = null
private var hasType1Voltage = false
private var hasType7Current = false
private var lastKnownSpeed = 0.0
private var lastKnownTemperature = 0.0
private var lastKnownTripDistance = 0.0
private var lastKnownTotalDistance: Double? = null
private var lastKnownMotorTemperature: Double? = null
private var lastKnownPwm: Double? = null
private var lastKnownModel: String? = null
private var lastKnownFirmwareVersion: String? = null
private var lastKnownPhaseCurrent: Double? = null
private var gotwayFirmwareVariant: String? = null
private var useHwPwm = false
private val smartBmsCellPages: MutableMap<Int, DoubleArray> = mutableMapOf()
init {
// Start observing frames asynchronously
scope.launch {
frameReassembler.observeFrames().collectLatest { frame ->
processFrame(frame)
}
}
}
override val manufacturer: String = "Gotway"
override val supportedModels: List<String> = listOf(
"MSuper", "MSX", "MSX Pro", "Mten3", "Mten4", "MTen5",
"Nikola", "Nikola Plus", "Tesla", "Monster", "Monster Pro",
"Begode", "Begode RS", "Begode Master", "Begode Hero", "Master Pro",
"blitz", "blitz pro", "mten3", "mten4", "mten5", "A1", "A2", "race",
"Extreme"
)
override val supportedCommandTypes: Set<CommandType> = setOf(
CommandType.LIGHT_ON,
CommandType.LIGHT_OFF,
CommandType.BEEP,
CommandType.POWER_OFF,
CommandType.LIGHT_BRIGHTNESS,
CommandType.REQUEST_SERIAL,
CommandType.REQUEST_FIRMWARE
)
override fun getServiceUUID(): UUID = UUID.fromString(BLEConstants.GOTWAY_SERVICE_UUID)
override fun getDataCharacteristicUUID(): UUID =
UUID.fromString(BLEConstants.GOTWAY_READ_CHARACTERISTIC)
override fun canHandle(device: EUCDevice): Boolean {
val name = device.name
return device.manufacturerId == BLEConstants.MANUFACTURER_GOTWAY ||
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 hasHeader = (chunk[0].toInt() and 0xFF) == 0x55 && (chunk[1].toInt() and 0xFF) == 0xAA
val hasFooter = chunk.size >= 4 &&
(chunk[chunk.size - 4].toInt() and 0xFF) == 0x5A &&
(chunk[chunk.size - 3].toInt() and 0xFF) == 0x5A &&
(chunk[chunk.size - 2].toInt() and 0xFF) == 0x5A &&
(chunk[chunk.size - 1].toInt() and 0xFF) == 0x5A
return hasHeader || hasFooter
}
override fun close() {
scope.cancel()
smartBmsCellPages.clear()
lastKnownVoltage = null
lastKnownCurrent = null
hasType1Voltage = false
hasType7Current = false
lastKnownSpeed = 0.0
lastKnownTemperature = 0.0
lastKnownTripDistance = 0.0
lastKnownTotalDistance = null
lastKnownMotorTemperature = null
lastKnownPwm = null
lastKnownModel = null
lastKnownFirmwareVersion = null
lastKnownPhaseCurrent = null
gotwayFirmwareVariant = null
useHwPwm = false
_channel.close()
}
override fun decode(data: ByteArray): EUCData? {
_rawFrameFlow.tryEmit(data.clone())
parseLegacyAsciiMetadata(data)
// 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
}
@VisibleForTesting
private fun processFrame(frame: ByteArray) {
val eucData = when (frame.getOrNull(18)?.toInt()?.and(0xFF)) {
0x00 -> parseTypeA(frame)
0x01 -> {
parseType1(frame)
null
}
0x02 -> {
parseType2or3(frame, bmsIndex = 0)
null
}
0x03 -> {
parseType2or3(frame, bmsIndex = 1)
null
}
0x04 -> parseTypeB(frame)
0x07 -> parseType7(frame)
else -> null // Ignore unknown frame types from the reassembler
}
eucData?.let { _channel.trySend(it) }
}
@VisibleForTesting
private fun parseTypeA(data: ByteArray): EUCData? {
val speedRaw = ByteUtils.tryGetSignedShortBE(data, 4)?.toInt() ?: return null
val speed = abs((speedRaw * 3.6) / 100.0)
if (abs(speed) > 200.0) return null // frame corrompue ou sentinel
val voltageRaw = ByteUtils.tryGetUnsignedShortBE(data, 2) ?: return null
val fallbackVoltage = voltageRaw / 100.0
val voltage = lastKnownVoltage ?: fallbackVoltage
if (!hasType1Voltage) {
// Before the dedicated Type 1 battery-voltage frame is seen, keep tracking
// voltage from Type A so Type B/Type 7 carry-forward telemetry remains usable.
lastKnownVoltage = fallbackVoltage
}
if (voltage > 300.0) return null // pareil pour voltage
val frameVariant = ByteUtils.tryGetUnsignedByte(data, 19)
val tripDistanceKm = when (frameVariant) {
0x18 -> ByteUtils.tryGetUnsignedShortBE(data, 8)?.toDouble()?.div(1000.0)
else -> ByteUtils.tryGetUnsignedIntBE(data, 6)?.toDouble()
} ?: return null
val currentRaw = ByteUtils.tryGetSignedShortBE(data, 10) ?: return null
// phaseCurrent: courant moteur, toujours positif (valeur absolue en A)
val phaseCurrent = abs(currentRaw / 100.0)
lastKnownPhaseCurrent = phaseCurrent
// current: courant batterie, signé (négatif = régén)
// Reste l'offset 10 comme fallback jusqu'à ce que Type 7 arrive.
// IMPORTANT: on met à jour lastKnownCurrent SANS abs, signe préservé.
if (!hasType7Current) {
lastKnownCurrent = currentRaw / 100.0
}
val current = lastKnownCurrent ?: (currentRaw / 100.0)
val tempRaw = ByteUtils.tryGetSignedShortBE(data, 12) ?: return null
val temperature = tempRaw / 100.0 // Assuming a 1/100 scale
lastKnownSpeed = speed
lastKnownTemperature = temperature
val pwmFromTypeA = if (useHwPwm) {
abs((ByteUtils.tryGetSignedShortBE(data, 14) ?: 0).toDouble())
} else {
abs((ByteUtils.tryGetSignedShortBE(data, 14) ?: 0).toDouble()) / 10.0
}
lastKnownPwm = pwmFromTypeA
val power = voltage * current
val batteryLevel = estimateBatteryLevel(voltage)
lastKnownTripDistance = tripDistanceKm
return EUCData(
frameType = "Type A",
speed = speed,
voltage = voltage,
current = current,
temperature = temperature,
batteryLevel = batteryLevel,
distance = tripDistanceKm,
power = power,
pwm = lastKnownPwm,
timestamp = System.currentTimeMillis(),
rawData = data,
manufacturer = manufacturer,
model = lastKnownModel ?: "Gotway (Type A)",
serialNumber = null,
firmwareVersion = lastKnownFirmwareVersion ?: gotwayFirmwareVariant,
isCharging = false, // Not available in this frame
rideTime = 0,
cellVoltages = getCombinedCellVoltages(),
motorTemperature = lastKnownMotorTemperature,
totalDistance = lastKnownTotalDistance,
phaseCurrent = lastKnownPhaseCurrent
)
}
@VisibleForTesting
private fun parseTypeB(data: ByteArray): EUCData? {
val distanceRaw = ByteUtils.tryGetUnsignedIntBE(data, 2) ?: return null
val settings = ByteUtils.tryGetUnsignedShortBE(data, 6)
// Legacy WheelLog maps firmware pedals bits with "2 - raw" before exposing the
// mode value to the app layer (typically mapped into 0..2); keep this behavior
// for compatibility with existing consumers.
val pedalsMode = settings?.let { 2 - ((it shr 13) and 0x03) }
val alarmMode = settings?.let { (it shr 10) and 0x03 }
val rollAngleMode = settings?.let { (it shr 7) and 0x03 }
val usesMiles = settings?.let { (it and 0x01) == 1 }
val autoPowerOffMinutes = ByteUtils.tryGetUnsignedShortBE(data, 8)
// Legacy parser ignores values >= 100, treating them as unset/invalid in this field.
val tiltBackSpeed = ByteUtils.tryGetUnsignedShortBE(data, 10)?.takeIf { it < 100 }
val ledMode = ByteUtils.tryGetUnsignedByte(data, 13)
val alertFlags = ByteUtils.tryGetUnsignedByte(data, 14)
val lightMode = ByteUtils.tryGetUnsignedByte(data, 15)?.and(0x03)
val wheelAlarm = alertFlags?.let { (it and 0x01) == 1 }
lastKnownTotalDistance = distanceRaw.toDouble()
val voltage = lastKnownVoltage ?: 0.0
val current = lastKnownCurrent ?: 0.0
val power = voltage * current
val batteryLevel = estimateBatteryLevel(voltage)
return EUCData(
frameType = "Type B",
speed = lastKnownSpeed,
voltage = voltage,
current = current,
temperature = lastKnownTemperature,
batteryLevel = batteryLevel,
distance = lastKnownTripDistance,
power = power,
pwm = lastKnownPwm,
timestamp = System.currentTimeMillis(),
rawData = data,
manufacturer = manufacturer,
model = lastKnownModel ?: "Gotway (Type B)",
serialNumber = null,
firmwareVersion = lastKnownFirmwareVersion ?: gotwayFirmwareVariant,
isCharging = false,
rideTime = 0,
cellVoltages = null,
motorTemperature = null,
totalDistance = lastKnownTotalDistance,
pedalsMode = pedalsMode,
alarmMode = alarmMode,
rollAngleMode = rollAngleMode,
usesMiles = usesMiles,
autoPowerOffMinutes = autoPowerOffMinutes,
tiltBackSpeed = tiltBackSpeed,
ledMode = ledMode,
lightMode = lightMode,
alertFlags = alertFlags,
wheelAlarm = wheelAlarm,
phaseCurrent = lastKnownPhaseCurrent
)
}
@VisibleForTesting
private fun parseType1(data: ByteArray) {
val batteryVoltageTenth = ByteUtils.tryGetUnsignedShortBE(data, 6) ?: return
lastKnownVoltage = batteryVoltageTenth / 10.0
hasType1Voltage = true
}
@VisibleForTesting
private fun parseType2or3(data: ByteArray, bmsIndex: Int) {
val page = ByteUtils.tryGetUnsignedByte(data, 19) ?: return
val cells = smartBmsCellPages.getOrPut(bmsIndex) { DoubleArray(MAX_BMS_CELL_SLOTS) }
for (i in 0 until 8) {
val cellRaw = ByteUtils.tryGetUnsignedShortBE(data, (i + 1) * 2) ?: continue
val cellIndex = page * 8 + i
if (cellIndex in cells.indices) {
cells[cellIndex] = cellRaw / 1000.0
}
}
}
@VisibleForTesting
private fun parseType7(data: ByteArray): EUCData? {
val batteryCurrentRaw = ByteUtils.tryGetSignedShortBE(data, 2) ?: return null
val motorTemperatureRaw = ByteUtils.tryGetSignedShortBE(data, 6) ?: return null
val truePwmRaw = ByteUtils.tryGetSignedShortBE(data, 8)
// WheelLog/Begode Type 7 convention: positive raw value means charge/regen, so
// published battery current is inverted to match discharge-positive telemetry.
lastKnownCurrent = (-batteryCurrentRaw) / 100.0
hasType7Current = true
lastKnownMotorTemperature = motorTemperatureRaw.toDouble()
truePwmRaw?.let { raw ->
// WheelLog maps Type 7 PWM raw values as whole-percent units (used as-is),
// while Type A fallback values represent tenths of percent.
val truePwm = abs(raw.toDouble())
// WheelLog only switches to Type 7 as authoritative PWM once a non-zero value is
// observed; keep the latest Type A fallback when Type 7 reports zero/unset.
if (truePwm > 0.0) {
lastKnownPwm = truePwm
}
}
val voltage = lastKnownVoltage ?: 0.0
val current = lastKnownCurrent ?: 0.0
val power = voltage * current
return EUCData(
frameType = "Type 7",
speed = lastKnownSpeed,
voltage = voltage,
current = current,
temperature = lastKnownTemperature,
batteryLevel = estimateBatteryLevel(voltage),
distance = lastKnownTripDistance,
power = power,
pwm = lastKnownPwm,
timestamp = System.currentTimeMillis(),
rawData = data,
manufacturer = manufacturer,
model = lastKnownModel ?: "Gotway (Type 7)",
serialNumber = null,
firmwareVersion = lastKnownFirmwareVersion ?: gotwayFirmwareVariant,
isCharging = false,
rideTime = 0,
cellVoltages = getCombinedCellVoltages(),
motorTemperature = lastKnownMotorTemperature,
totalDistance = lastKnownTotalDistance,
phaseCurrent = lastKnownPhaseCurrent
)
}
private fun estimateBatteryLevel(voltage: Double): Int {
if (voltage <= 0.0) return 0
return (((voltage - MIN_BATTERY_VOLTAGE) / (MAX_BATTERY_VOLTAGE - MIN_BATTERY_VOLTAGE)) * 100.0)
.toInt()
.coerceIn(0, 100)
}
private fun getCombinedCellVoltages(): List<Double>? {
if (smartBmsCellPages.isEmpty()) return null
val combined = smartBmsCellPages.values
.flatMap { it.asList() }
.filter { it > 0.0 }
return combined.ifEmpty { null }
}
private fun parseLegacyAsciiMetadata(data: ByteArray) {
if (data.isEmpty()) return
if (data.size >= 2 && data[0] == HEADER[0] && data[1] == HEADER[1]) return
val message = data.decodeToString().trim()
if (message.isEmpty()) return
when {
message.startsWith("NAME", ignoreCase = true) -> {
val name = message.substringAfter("NAME", "").trim()
if (name.isNotEmpty()) {
lastKnownModel = name
}
}
message.startsWith("GW", ignoreCase = true) -> {
lastKnownFirmwareVersion = message.substring(2).trim().ifEmpty { null }
gotwayFirmwareVariant = "Begode"
useHwPwm = false
}
message.startsWith("JN", ignoreCase = true) -> {
lastKnownFirmwareVersion = message.substring(2).trim().ifEmpty { null }
gotwayFirmwareVariant = "ExtremeBull"
useHwPwm = false
}
message.startsWith("CF", ignoreCase = true) -> {
lastKnownFirmwareVersion = message.substring(2).trim().ifEmpty { null }
gotwayFirmwareVariant = "Freestyl3r"
useHwPwm = true
}
message.startsWith("BF", ignoreCase = true) -> {
lastKnownFirmwareVersion = message.substring(2).trim().ifEmpty { null }
gotwayFirmwareVariant = "SV"
useHwPwm = true
}
}
}
override fun createCommand(commandType: CommandType, value: Any): ByteArray {
val header = BLEConstants.GOTWAY_COMMAND_HEADER
return when (commandType) {
CommandType.LIGHT_ON -> header + byteArrayOf(0x01, 0x01, 0x01)
CommandType.LIGHT_OFF -> header + byteArrayOf(0x01, 0x01, 0x00)
CommandType.BEEP -> header + byteArrayOf(0x02, 0x01)
CommandType.POWER_OFF -> header + byteArrayOf(0x03, 0x01)
CommandType.LIGHT_BRIGHTNESS -> {
if (value is Int && value in 0..100) {
val brightness = (value * 255 / 100).toByte()
header + byteArrayOf(0x04, brightness)
} else byteArrayOf()
}
CommandType.REQUEST_SERIAL -> "N".encodeToByteArray()
CommandType.REQUEST_FIRMWARE -> "V".encodeToByteArray()
else -> byteArrayOf()
}
}
override fun getPollingPlan(): ProtocolPollingPlan {
return ProtocolPollingPlan(
enabled = true,
startupQueries = listOf(
ProtocolQuerySpec(
id = "gotway.request-model",
commandType = CommandType.REQUEST_SERIAL,
maxRetries = 3
),
ProtocolQuerySpec(
id = "gotway.request-firmware",
commandType = CommandType.REQUEST_FIRMWARE,
initialDelayMs = 200L,
maxRetries = 3
)
),
periodicQueries = emptyList()
)
}
override fun matchesQueryResponse(query: ProtocolQuerySpec, data: ByteArray): Boolean {
// Gotway responds to "N" and "V" with ASCII strings (not framed telemetry)
if (data.isEmpty()) return false
// If the response starts with the standard frame header 0x55 0xAA, it's telemetry not a query response
if (data.size >= 2 && data[0] == 0x55.toByte() && data[1] == 0xAA.toByte()) return false
// ASCII responses to name/version queries
return when (query.commandType) {
CommandType.REQUEST_SERIAL, CommandType.REQUEST_FIRMWARE -> true
else -> false
}
}
override fun isDeviceReady(data: EUCData): Boolean {
// Conservative readiness checks: p
return data.voltage > 0 && data.speed >= 0
}
/**
* 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 Type 2/3 frames which contain smart BMS cell voltage pages.
*/
override fun getBMSData(): List<BMSData> {
val allIndices = smartBmsCellPages.keys.distinct().sorted()
return allIndices.map { index ->
val cells = smartBmsCellPages[index]?.asList()?.filter { it > 0.0 }?.ifEmpty { null }
BMSData(
bmsIndex = index,
voltage = null, // Voltage not available in smart BMS pages
current = null, // Current not available in smart BMS pages
remainingCapacity = null, // Capacity not available in smart BMS pages
factoryCapacity = null, // Factory capacity not available in smart BMS pages
cycles = null, // Cycle count not available in smart BMS pages
temperatures = null, // Temperatures not available in smart BMS pages
cellVoltages = cells
)
}
}
}