EUCProtocol.kt
package io.github.tritbool.euc.ble.protocols
import io.github.tritbool.euc.ble.models.BMSData
import io.github.tritbool.euc.ble.models.EUCData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import java.io.Closeable
import java.util.UUID
/**
* Specifies a single GATT service requirement within a [GattSignature].
*
* A service spec matches when:
* - The service UUID is present in the discovered GATT services.
* - All [requiredCharacteristicUUIDs] are present as characteristics of that service.
* - None of the [excludedCharacteristicUUIDs] are present as characteristics of that service.
*
* @param uuid The service UUID that must be present.
* @param requiredCharacteristicUUIDs Characteristic UUIDs that must ALL be present in the service.
* @param excludedCharacteristicUUIDs Characteristic UUIDs that must NOT be present in the service.
*/
data class GattServiceSpec(
val uuid: UUID,
val version:Int = 1,
val requiredCharacteristicUUIDs: Set<UUID> = emptySet(),
val excludedCharacteristicUUIDs: Set<UUID> = emptySet(),
)
/**
* A GATT signature is a list of [GattServiceSpec] entries that all must match for a protocol to
* be identified by GATT fingerprinting. All specs use AND semantics (every spec must hold).
*
* A protocol can declare multiple alternative signatures (OR semantics between signatures):
* the protocol matches if at least one signature matches.
*/
typealias GattSignature = List<GattServiceSpec>
/**
* Base interface for EUC manufacturer protocols.
*
* Protocol selection is performed by GATT fingerprint matching using [EucFingerprintDatabase].
* If no fingerprint match is found, the caller is responsible for selecting a protocol manually.
*/
interface EUCProtocol : Closeable {
/**
* Manufacturer name (used for display and logging).
*/
val manufacturer: String
val dataFlow: Flow<EUCData>
/**
* Flow that emits every raw BLE characteristic notification received by this protocol,
* as a defensive copy of the original byte array. Collectors can use this to write raw
* logs or perform any custom processing on the unmodified BLE data.
*
* The default implementation emits nothing; concrete protocols override this to provide
* a live stream of incoming bytes.
*/
val rawFrameFlow: Flow<ByteArray> get() = emptyFlow()
/**
* Flow of commands or raw bytes that the protocol needs to write to the device automatically.
*/
val writeFlow: Flow<ByteArray> get() = emptyFlow()
/**
* Decode raw BLE data into EUCData
*/
fun decode(data: ByteArray): EUCData?
/**
* Returns the candidate data characteristic UUIDs for this protocol.
*
* Most protocols expose a single data characteristic. Protocols that dynamically detect their
* dialect (such as InMotion V1/V2) should override this to return all possible candidates so
* that BLE notifications are enabled for each characteristic at connection time.
*/
fun getCandidateDataCharacteristicUUIDs(): List<UUID> = listOf(getDataCharacteristicUUID())
/**
* Get the UUID for the data characteristic
*/
fun getDataCharacteristicUUID(): UUID
/**
* Get the UUID for the service
*/
fun getServiceUUID(): UUID
/**
* Create a command for the EUC
*/
fun createCommand(commandType: CommandType, value: Any): ByteArray
/**
* Explicit command support matrix for this protocol.
* Commands outside this set are considered unsupported by design.
*/
val supportedCommandTypes: Set<CommandType>
get() = emptySet()
/**
* API-level command support check (used by framework and clients).
*/
fun getCommandSupport(commandType: CommandType): CommandSupport {
return if (supportedCommandTypes.contains(commandType)) {
CommandSupport.SUPPORTED
} else {
CommandSupport.UNSUPPORTED
}
}
/**
* Returns true if the given BLE device advertisement name suggests this protocol.
*
* Used as a secondary selection signal when GATT fingerprinting is ambiguous or
* unavailable (e.g. protocols that share a common service UUID). The default
* implementation returns false; protocols with a recognizable device-name pattern
* should override this and match their known advertised names or model keywords.
*/
fun matchesDeviceName(deviceName: String): Boolean = false
/**
* Optional polling/query plan consumed by BLEManager orchestration.
*/
fun getPollingPlan(): ProtocolPollingPlan = ProtocolPollingPlan.disabled()
/**
* Optional query/response matcher used by BLEManager observability and retry loop.
*/
fun matchesQueryResponse(query: ProtocolQuerySpec, data: ByteArray): Boolean = false
/**
* Get the UUID for the write characteristic (if different from data characteristic)
*/
fun getWriteCharacteristicUUID(): UUID = getDataCharacteristicUUID()
/**
* Check if the device is ready for operation
*/
fun isDeviceReady(data: EUCData): Boolean
/**
* Get BMS (Battery Management System) data for this protocol.
* Returns null if this protocol does not support BMS data extraction.
* Protocols that support BMS data should override this method to return
* a list of BMSData objects representing the current state of all battery packs.
*/
fun getBMSData(): List<BMSData>? = null
}
/**
* Standard command types for EUCs
*/
enum class CommandType {
LIGHT_ON,
LIGHT_OFF,
SET_LIGHT_MODE,
LIGHT_BRIGHTNESS,
/** Set the high beam independently from the low beam (LeaperKim two-frame binary command). */
SET_HIGH_BEAM,
SPEAKER_VOLUME,
BEEP,
POWER_OFF,
LOCK,
UNLOCK,
SET_PEDALS_MODE,
SET_LED_MODE,
SET_LED_STROBE,
SET_SPEED_LIMIT,
SET_ALARM_SPEED,
CALIBRATE,
REQUEST_SERIAL,
REQUEST_FIRMWARE,
REQUEST_BATTERY_INFO,
REQUEST_BMS_SERIAL,
RESET_TRIP,
/** LeaperKim: pedal zero-point tilt offset, value = tenths of a degree (signed Int). */
SET_PEDAL_ANGLE,
/** LeaperKim: ride-mode scalar 0..100, value = Int. */
SET_RIDE_MODE,
/** LeaperKim: maximum PWM percentage 0..100, value = Int. */
SET_PWM_LIMIT,
/** KingSong: charge cutoff percentage 0..100, value = Int. */
SET_CHARGE_LIMIT,
/** KingSong: idle auto-poweroff delay in seconds, value = Int. */
SET_STANDBY_DELAY,
/** KingSong: pedal cutoff lean angle in tenths of a degree, value = Int. */
SET_PEDAL_CUTOFF_ANGLE,
/** KingSong: pedal pitch trim in tenths of a degree (signed), value = Int. */
SET_PEDAL_PITCH_TRIM,
CUSTOM
}
enum class CommandSupport {
SUPPORTED,
UNSUPPORTED
}
data class ProtocolQuerySpec(
val id: String,
val commandType: CommandType,
val value: Any = Unit,
val initialDelayMs: Long = 0L,
val intervalMs: Long = 0L,
val responseTimeoutMs: Long = 1500L,
val maxRetries: Int = 2,
val retryBackoffMs: Long = 500L
)
data class ProtocolPollingPlan(
val enabled: Boolean,
val startupQueries: List<ProtocolQuerySpec> = emptyList(),
val periodicQueries: List<ProtocolQuerySpec> = emptyList()
) {
companion object {
fun disabled() = ProtocolPollingPlan(enabled = false)
}
}