GotwayProtocol.kt
package io.github.tritbool.euc.ble.protocols
import android.util.Log
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 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
/** Conversion factor for wheels that have been set to imperial units by the
* Begode app. When the imperial flag is set in a Type-B frame the wheel
* transmits speed in mph and trip distance in miles on every subsequent
* Type-A frame; we multiply by this constant to convert back to km/h and
* km so all downstream consumers always receive metric values. */
private const val MILES_TO_KM = 1.60934
}
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 var hasSeenType7Pwm = false
//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()
/** True when the wheel has reported imperial-units mode via bit 0 of the Type-B
* settings word. Latched on every Type-B frame and applied in parseTypeA to
* convert mph→km/h and miles→km transparently. */
private var wheelInMiles = false
init {
// Start observing frames asynchronously
scope.launch {
frameReassembler.observeFrames().collectLatest { frame ->
processFrame(frame)
}
}
}
override val manufacturer: String = "Gotway"
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 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
hasSeenType7Pwm = 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)
// SmirnoV firmware tuning frame (tag 0xFF): discard without parsing.
// eucplanet explicitly discards this with a log line; our else branch
// would silently drop it too, but an explicit case improves observability.
0xFF -> null
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 rawSpeedKmh = abs((speedRaw * 3.6) / 100.0)
// Apply imperial conversion: when the Begode app is set to mph the wheel
// transmits speed in mph on the wire even though the spec says km/h. Multiply
// by MILES_TO_KM so all downstream consumers always receive km/h.
val speed = if (wheelInMiles) rawSpeedKmh * MILES_TO_KM else rawSpeedKmh
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 rawTripDistanceKm = when (frameVariant) {
0x18 -> ByteUtils.tryGetUnsignedShortBE(data, 8)?.toDouble()?.div(1000.0)
else -> ByteUtils.tryGetUnsignedIntBE(data, 6)?.toDouble()?.div(1000.0)
} ?: return null
// Apply imperial conversion to distance as well.
val tripDistanceKm = if (wheelInMiles) rawTripDistanceKm * MILES_TO_KM else rawTripDistanceKm
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 / 10.0 // Assuming a 1/100 scale
lastKnownSpeed = speed
lastKnownTemperature = temperature
val rawPwmA = ByteUtils.tryGetSignedShortBE(data, 14) ?: 0
val pwmFromTypeA = if (useHwPwm) {
abs(rawPwmA.toDouble())
} else {
abs(rawPwmA.toDouble()) / 10.0
}
// Fallback uniquement tant qu'aucune vraie PWM Type 7 n'a été observée.
if (!hasSeenType7Pwm) {
lastKnownPwm = if (pwmFromTypeA in 0.0..100.0) pwmFromTypeA else null
}
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 }
// Latch the imperial flag so parseTypeA can convert speed/distance on every
// subsequent frame without repeating the detection logic there.
wheelInMiles = usesMiles == true
val autoPowerOffMinutes = ByteUtils.tryGetUnsignedShortBE(data, 8)
// Sentinel raised from 100 to 200: modern high-speed wheels (Master Pro, XWay,
// MSX HS) legitimately set tiltback thresholds above 100 km/h, so the old
// `< 100` guard incorrectly discarded valid settings for those models.
val tiltBackSpeed = ByteUtils.tryGetUnsignedShortBE(data, 10)?.takeIf { it < 200 }
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 ->
// Type 7 = vraie PWM entière en %, Begode partage le PWM réel (pas calculé).[web:23][file:16]
val truePwmAbs = kotlin.math.abs(raw.toDouble())
// 0 < PWM ≤ 100 => valeur valide.
if (truePwmAbs in 0.0..100.0) {
lastKnownPwm = truePwmAbs
hasSeenType7Pwm = true
}
// Sinon (0 ou hors plage) => on ne change pas lastKnownPwm, et on NE met PAS PWM à 46 % random.
}
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 decodeBegodeEncodedName(data: ByteArray): String {
// Format Begode 0x10+char : UNIQUEMENT valide si le paquet est
// constitué EXCLUSIVEMENT de paires 0x10+char (hors premier byte de longueur éventuel).
// On vérifie d'abord que tous les bytes non-nuls suivent ce pattern.
// Un paquet "EXTREME" = 7 chars = 14 bytes de paires 0x10+char,
// éventuellement précédés d'un byte de taille.
val start = if (data.size % 2 != 0 && data[0].toInt().and(0xFF) == data.size - 1) 1 else 0
val slice = data.drop(start)
// Valider que TOUS les bytes pairs sont 0x10 et impairs sont printable ASCII
if (slice.size < 6) return ""
if (slice.size % 2 != 0) return ""
for (i in slice.indices step 2) {
val prefix = slice[i].toInt().and(0xFF)
val char = slice[i + 1].toInt().and(0xFF)
if (prefix != 0x10) return ""
if (char !in 0x20..0x7E) return ""
}
val result = slice.filterIndexed { i, _ -> i % 2 == 1 }
.map { it.toInt().and(0xFF).toChar() }
.joinToString("")
.trim()
android.util.Log.d("GotwayASCII", "decodeBegodeEncodedName: decoded='$result'")
return result
}
private fun parseLegacyAsciiMetadata(data: ByteArray) {
if (data.isEmpty()) return
if (data.size >= 2 && data[0] == HEADER[0] && data[1] == HEADER[1]) return
android.util.Log.d(
"GotwayASCII",
"parseLegacyAsciiMetadata: rawSize=${data.size} first5=${
data.take(5).map { "%02X".format(it) }
}"
)
// Tentative 1 : décodage ASCII direct (GW/JN/CF/BF et NAME standard)
val directMessage = data.decodeToString().trim()
val message: String = when {
directMessage.startsWith("NAME", ignoreCase = true) -> directMessage
directMessage.startsWith("GW", ignoreCase = true) -> directMessage
directMessage.startsWith("JN", ignoreCase = true) -> directMessage
directMessage.startsWith("CF", ignoreCase = true) -> directMessage
directMessage.startsWith("BF", ignoreCase = true) -> directMessage
else -> {
// Tentative 2 : décodage format Begode 0x10+char
// Seulement si le paquet est structurellement 100% du format 0x10+char
val begodeDecoded = decodeBegodeEncodedName(data)
if (begodeDecoded.isNotEmpty()) "NAME$begodeDecoded"
else {
android.util.Log.d(
"GotwayASCII",
"parseLegacyAsciiMetadata: UNRECOGNIZED direct='$directMessage'"
)
return
}
}
}
android.util.Log.d("GotwayASCII", "parseLegacyAsciiMetadata: PARSED message='$message'")
when {
message.startsWith("NAME", ignoreCase = true) -> {
val name = message.substringAfter("NAME", "").trimStart(':', ' ').trim()
if (name.isNotEmpty()) {
android.util.Log.d(
"GotwayASCII",
"parseLegacyAsciiMetadata: MODEL found name='$name'"
)
lastKnownModel = name
}
}
message.startsWith("GW", ignoreCase = true) -> {
lastKnownFirmwareVersion = message.substring(2).trim().ifEmpty { null }
gotwayFirmwareVariant = "Begode"
useHwPwm = false
android.util.Log.d(
"GotwayASCII",
"parseLegacyAsciiMetadata: FIRMWARE found variant=Begode version=$lastKnownFirmwareVersion"
)
}
message.startsWith("JN", ignoreCase = true) -> {
lastKnownFirmwareVersion = message.substring(2).trim().ifEmpty { null }
gotwayFirmwareVariant = "ExtremeBull"
useHwPwm = false
android.util.Log.d(
"GotwayASCII",
"parseLegacyAsciiMetadata: FIRMWARE found variant=ExtremeBull version=$lastKnownFirmwareVersion"
)
}
message.startsWith("CF", ignoreCase = true) -> {
lastKnownFirmwareVersion = message.substring(2).trim().ifEmpty { null }
gotwayFirmwareVariant = "Freestyl3r"
useHwPwm = true
android.util.Log.d(
"GotwayASCII",
"parseLegacyAsciiMetadata: FIRMWARE found variant=Freestyl3r version=$lastKnownFirmwareVersion"
)
}
message.startsWith("BF", ignoreCase = true) -> {
lastKnownFirmwareVersion = message.substring(2).trim().ifEmpty { null }
gotwayFirmwareVariant = "SV"
useHwPwm = true
android.util.Log.d(
"GotwayASCII",
"parseLegacyAsciiMetadata: FIRMWARE found variant=SV version=$lastKnownFirmwareVersion"
)
}
}
}
override fun matchesQueryResponse(query: ProtocolQuerySpec, data: ByteArray): Boolean {
if (data.isEmpty()) return false
if (data.size >= 2 && data[0] == 0x55.toByte() && data[1] == 0xAA.toByte()) return false
Log.d(
"GotwayQueryMatch",
"matchesQueryResponse: query=${query.commandType} size=${data.size} data=${
data.take(20).map { "%02X".format(it) }
}"
)
return when (query.commandType) {
CommandType.REQUEST_SERIAL -> {
val direct = data.decodeToString().trim()
if (direct.startsWith("NAME", ignoreCase = true)) {
android.util.Log.d(
"GotwayQueryMatch",
"matchesQueryResponse: REQUEST_SERIAL matched direct NAME"
)
return true
}
// Begode 0x10+char : valide seulement si le paquet est structurellement pur
val begodeDecoded = decodeBegodeEncodedName(data)
val matched = begodeDecoded.isNotEmpty()
android.util.Log.d(
"GotwayQueryMatch",
"matchesQueryResponse: REQUEST_SERIAL begodeDecoded='$begodeDecoded' matched=$matched"
)
matched
}
CommandType.REQUEST_FIRMWARE -> {
val str = data.decodeToString().trim()
val matched = str.startsWith("GW", ignoreCase = true)
|| str.startsWith("JN", ignoreCase = true)
|| str.startsWith("CF", ignoreCase = true)
|| str.startsWith("BF", ignoreCase = true)
android.util.Log.d(
"GotwayQueryMatch",
"matchesQueryResponse: REQUEST_FIRMWARE str='$str' matched=$matched"
)
matched
}
else -> false
}
}
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,
initialDelayMs = 1000L, // laisser la roue finir son burst initial
responseTimeoutMs = 2000L, // les roues Gotway peuvent être lentes
maxRetries = 5,
retryBackoffMs = 500L
),
ProtocolQuerySpec(
id = "gotway.request-firmware",
commandType = CommandType.REQUEST_FIRMWARE,
initialDelayMs = 1500L,
responseTimeoutMs = 2000L,
maxRetries = 3,
retryBackoffMs = 500L
)
),
periodicQueries = emptyList()
)
}
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
)
}
}
}