Skip to content

Commit 9be0297

Browse files
eriedclaude
andcommitted
Stub KingSong / Begode / Veteran adapters and dispatcher
Lays the scaffolding for the three new brand families on top of the spec docs in docs/protocols/. Concretely: - BleProfile.HM10 — shared 0xFFE0 / 0xFFE1 service+characteristic for KingSong / Begode / Veteran (all three brands ride the same HM-10 module). Brand is disambiguated post-connect by first-frame magic. - KingsongModel / BegodeModel / VeteranModel registries with display names, nominal voltage class and max-speed defaults per model. Each has a fromReportedName() heuristic that reads the BLE-advertised name and returns the best enum match. - KingsongAdapter / BegodeAdapter / VeteranAdapter — full WheelAdapter implementations whose telemetry / control methods all return null / Unknown today. Frame parsing and command building land in follow-up commits, one brand at a time. - WheelCapabilities.KINGSONG / BEGODE / VETERAN reflect the per-brand control surface (no software lock for any of the three, no volume control, Veteran has no max-speed write). - CompositeWheelAdapter — picks one of the four sub-adapters by BLE-advertised name on notifyConnectingTo() and routes the rest of the WheelAdapter surface to it. Falls back to the InMotion V2 adapter for unmatched names so existing V14 / P6 setups stay unchanged. - BleModule binds CompositeWheelAdapter as the WheelAdapter implementation injected into BleConnectionManager. - BleScanner.isLikelyWheel learned the new BLE name patterns: KingSong KS-/S22/S20/S18/F18P/F22P, Begode/Gotway/Master/RS/EX/MSP/MSX/Mten/ MCM5/Hero/T3/T4, Veteran Sherman/Patton/Lynx/Abrams. Protocol research credit: WheelLog (Ilya Shkolnik and contributors, https://github.com/Wheellog/wheellog.android) is the primary public reference for these protocols. The implementation here is original. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 905a18c commit 9be0297

10 files changed

Lines changed: 553 additions & 19 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.eried.eucplanet.ble
2+
3+
import javax.inject.Inject
4+
import javax.inject.Singleton
5+
6+
/**
7+
* Begode/Gotway wheel adapter — stub. Recognises Master / RS / EX / T4 / MSP
8+
* / Hero / Mten / MSX / MCM5 family wheels on the HM-10 (0xFFE0 / 0xFFE1) BLE
9+
* profile.
10+
*
11+
* Wire format (per docs/protocols/begode.md):
12+
* - 24-byte BIG-ENDIAN frames `55 AA <16-byte payload> <tag> <subidx> 5A 5A 5A 5A`.
13+
* - Tag at offset 18 disambiguates `0x00` Live A, `0x01..0x03` BMS, `0x04`
14+
* Live B, `0x07` extras.
15+
* - Voltage scaling depends on per-model nominal voltage class (84 / 100 /
16+
* 126 / 134 / 151 V), see [BegodeModel.nominalVoltage].
17+
*
18+
* Outbound commands are single ASCII characters: `b` beep, `l`/`L` light
19+
* cycle, `>`/`=`/`<` tiltback level, `m`/`g` pedal mode, plus `W`-prefix
20+
* sub-menus for max-speed / volume / LED.
21+
*
22+
* This stub returns null/Unknown for every method. Realtime parsing,
23+
* the multi-tag dispatcher and the per-model voltage scaler land in
24+
* follow-up commits.
25+
*
26+
* Protocol research credit: WheelLog (Ilya Shkolnik and contributors,
27+
* https://github.com/Wheellog/wheellog.android — GPLv3, used as a protocol
28+
* reference; the implementation here is original).
29+
*/
30+
@Singleton
31+
class BegodeAdapter @Inject constructor() : WheelAdapter {
32+
override val familyId = "begode"
33+
override val capabilities = WheelCapabilities.BEGODE
34+
35+
@Volatile private var detectedModel: BegodeModel? = null
36+
37+
override fun bleProfile(): BleProfile = BleProfile.HM10
38+
39+
override fun notifyConnectingTo(deviceName: String?) {
40+
detectedModel = deviceName?.let { BegodeModel.fromReportedName(it) }
41+
}
42+
43+
override fun initSequence(): List<ByteArray> = emptyList()
44+
override fun pollRealtime(): ByteArray = ByteArray(0)
45+
override fun pollSettings(): ByteArray = ByteArray(0)
46+
47+
override fun horn(): ByteArray? = null
48+
override fun setLight(on: Boolean): ByteArray? = null
49+
override fun setMaxSpeed(tiltbackKmh: Float, alarmKmh: Float): ByteArray? = null
50+
override fun setVolume(percent: Int): ByteArray? = null
51+
override fun setDRL(on: Boolean): ByteArray? = null
52+
override fun setLock(locked: Boolean): ByteArray? = null
53+
54+
override fun requestAuthKey(): ByteArray? = null
55+
override fun verifyAuth(encryptedKey: ByteArray): ByteArray? = null
56+
57+
override fun onRawNotification(rawBytes: ByteArray): List<DecodeResult> = emptyList()
58+
59+
override fun onDisconnect() {
60+
detectedModel = null
61+
}
62+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package com.eried.eucplanet.ble
2+
3+
/**
4+
* Models in the Begode/Gotway BLE protocol family. Begode reports the model as
5+
* an ASCII firmware-version string (e.g. "GW135.20.16") in the firmware-banner
6+
* frame; the leading prefix and one model-character byte identify the model.
7+
*
8+
* Voltage-class is the most important per-model attribute because Begode's
9+
* realtime telemetry voltage field is a raw u16 BE that must be scaled by a
10+
* nominal-voltage-derived ratio (84 V wheels use 0.625, 100 V wheels use ~0.74,
11+
* 126 V wheels use ~0.94, etc.) to land on real volts.
12+
*
13+
* Spec: docs/protocols/begode.md. Protocol research credit goes to WheelLog
14+
* (Ilya Shkolnik and contributors); the implementation here is original.
15+
*/
16+
enum class BegodeModel(
17+
val displayName: String,
18+
val nominalVoltage: Int,
19+
val maxSpeedKmh: Int
20+
) {
21+
MTEN4( "Begode Mten4", 84, 35),
22+
MTEN5( "Begode Mten5", 84, 35),
23+
MCM5_V1( "Begode MCM5 v1", 67, 40),
24+
MCM5_V2( "Begode MCM5 v2", 67, 40),
25+
MSX( "Begode MSX", 100, 60),
26+
MSP( "Begode MSP", 100, 60),
27+
HERO( "Begode Hero", 100, 70),
28+
EX( "Begode EX", 100, 60),
29+
EX_N( "Begode EX.N", 100, 60),
30+
EX2( "Begode EX2", 126, 80),
31+
RS( "Begode RS", 126, 80),
32+
RS_HT( "Begode RS-HT", 134, 100),
33+
T3( "Begode T3", 84, 45),
34+
T4( "Begode T4", 134, 100),
35+
MASTER( "Begode Master", 134, 100),
36+
MASTER_PRO("Begode Master Pro", 151, 120);
37+
38+
companion object {
39+
/**
40+
* Best-effort match of the wheel's reported name (BLE-advertised) to
41+
* an enum value. Begode advertises wildly inconsistent names per
42+
* model and firmware ("RS_5012", "Master_4400", "Gotway_*",
43+
* "Begode_*"), so this is heuristic. Returns null if no obvious
44+
* match; the adapter falls back on a generic 84-V profile.
45+
*/
46+
fun fromReportedName(name: String): BegodeModel? {
47+
val n = name.lowercase()
48+
return when {
49+
"master" in n && "pro" in n -> MASTER_PRO
50+
"master" in n -> MASTER
51+
"hero" in n -> HERO
52+
"rs-ht" in n || "rs_ht" in n || "rsht" in n -> RS_HT
53+
"rs" in n -> RS
54+
"ex2" in n -> EX2
55+
"ex.n" in n || "ex_n" in n -> EX_N
56+
"ex" in n -> EX
57+
"msx" in n -> MSX
58+
"msp" in n -> MSP
59+
"mten5" in n || "mten 5" in n -> MTEN5
60+
"mten4" in n || "mten 4" in n -> MTEN4
61+
"mcm5" in n -> MCM5_V2
62+
"t4" in n -> T4
63+
"t3" in n -> T3
64+
else -> null
65+
}
66+
}
67+
}
68+
}

app/src/main/java/com/eried/eucplanet/ble/BleScanner.kt

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -75,27 +75,51 @@ class BleScanner @Inject constructor(
7575
/**
7676
* BLE-name allowlist for the default ("known wheels only") scan mode.
7777
*
78-
* V14 advertises as `Adventure-<id>`, P6 as `P6-<id>`. The InMotion V2
79-
* registry covers V8 through V13 — those wheels broadcast as
80-
* `V<digits><letters?>-<id>` (V11-…, V11Y-…, V12HS-…, V13Pro-…) per
81-
* community captures. We don't have one of each here to confirm, so
82-
* the regex errs inclusive. The generic `InMotion` prefix catches
83-
* anything that ships with the brand name in the advertised name.
78+
* Recognises the InMotion V2 family (V14 `Adventure-…`, P6 `P6-…`, and
79+
* the broader `V<digits>…` pattern that covers V11/V12/V13), KingSong
80+
* (`KS-…`, `KingSong…`, `S22…` / `S20…` / `S18…`, `F22P` / `F18P`),
81+
* Begode/Gotway (`Gotway_…`, `Begode_…`, plus model-specific prefixes
82+
* `Master_…`, `RS_…`, `EX_…`, `MSP…`, `MSX…`, `Mten…`, `MCM5…`,
83+
* `Hero…`, `T3…`, `T4…`) and Veteran (`Sherman…`, `Patton…`, `Lynx…`,
84+
* `Abrams…`).
85+
*
8486
* Users with an unusual name can flip the "show all" switch on the
8587
* scan screen.
8688
*/
8789
private fun isLikelyWheel(name: String): Boolean {
90+
// InMotion V2 family
8891
if (name.startsWith("Adventure-")) return true
8992
if (name.startsWith("P6-")) return true
9093
if (name.startsWith("InMotion")) return true
9194
// V8-…, V9-…, V10-…, V11-…, V11Y-…, V12HS-…, V13Pro-…: leading V
92-
// followed by at least one digit and at least one more character
93-
// (separator, model letter, or further digit). Rejects bare "V" /
94-
// "V1" / "V12" beacons, accepts the InMotion V2 family.
95-
if (name.length < 3 || name[0] != 'V' || !name[1].isDigit()) return false
96-
var i = 2
97-
while (i < name.length && name[i].isDigit()) i++
98-
return i < name.length
95+
// followed by at least one digit and at least one more character.
96+
if (name.length >= 3 && name[0] == 'V' && name[1].isDigit()) {
97+
var i = 2
98+
while (i < name.length && name[i].isDigit()) i++
99+
if (i < name.length) return true
100+
}
101+
// KingSong
102+
if (name.startsWith("KS-") || name.startsWith("KS ") ||
103+
name.startsWith("KingSong", ignoreCase = true)) return true
104+
if (Regex("^S(?:1[6-9]|2[02])(?:\\b|[-_ ])").containsMatchIn(name)) return true
105+
if (name.startsWith("F18P", ignoreCase = true) ||
106+
name.startsWith("F22P", ignoreCase = true)) return true
107+
// Begode/Gotway
108+
if (name.startsWith("Gotway", ignoreCase = true) ||
109+
name.startsWith("Begode", ignoreCase = true) ||
110+
name.startsWith("Master_", ignoreCase = true) ||
111+
name.startsWith("RS_", ignoreCase = true) || name.startsWith("RS-", ignoreCase = true) ||
112+
name.startsWith("EX_", ignoreCase = true) || name.startsWith("EX.", ignoreCase = true) ||
113+
name.startsWith("EX2", ignoreCase = true) ||
114+
name.startsWith("MSP", ignoreCase = true) || name.startsWith("MSX", ignoreCase = true) ||
115+
name.startsWith("Mten", ignoreCase = true) || name.startsWith("MCM5", ignoreCase = true) ||
116+
name.startsWith("Hero", ignoreCase = true) ||
117+
name.startsWith("T3", ignoreCase = true) || name.startsWith("T4", ignoreCase = true)) return true
118+
// Veteran
119+
val nl = name.lowercase()
120+
if ("sherman" in nl || "patton" in nl || "abrams" in nl ||
121+
Regex("\\blynx\\b").containsMatchIn(nl)) return true
122+
return false
99123
}
100124

101125
@SuppressLint("MissingPermission")
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package com.eried.eucplanet.ble
2+
3+
import javax.inject.Inject
4+
import javax.inject.Singleton
5+
6+
/**
7+
* Adapter dispatcher. Holds the four per-family adapters (InMotion V2,
8+
* KingSong, Begode/Gotway, Veteran) and routes every WheelAdapter call to
9+
* the one that matches the connected wheel's BLE-advertised name.
10+
*
11+
* Selection happens on [notifyConnectingTo] — the BLE connection manager
12+
* already calls this hook before the first packet, so the rest of the
13+
* adapter surface (poll, decode, control commands) is delegated cleanly to
14+
* one sub-adapter from the first packet onward.
15+
*
16+
* If no name pattern matches we fall back to the InMotion V2 adapter (the
17+
* verified default). That keeps existing V14 / P6 setups working unchanged
18+
* — a wheel that isn't a KS / Begode / Veteran name pattern matches the
19+
* "Adventure-*", "P6-*", or generic "InMotion*" patterns the V2 adapter
20+
* already understands.
21+
*/
22+
@Singleton
23+
class CompositeWheelAdapter @Inject constructor(
24+
private val inmotion: InMotionV2Adapter,
25+
private val kingsong: KingsongAdapter,
26+
private val begode: BegodeAdapter,
27+
private val veteran: VeteranAdapter
28+
) : WheelAdapter {
29+
30+
@Volatile private var active: WheelAdapter = inmotion
31+
32+
override val familyId: String get() = active.familyId
33+
override val capabilities: WheelCapabilities get() = active.capabilities
34+
35+
override fun bleProfile(): BleProfile = active.bleProfile()
36+
37+
override fun notifyConnectingTo(deviceName: String?) {
38+
active = pickAdapter(deviceName)
39+
active.notifyConnectingTo(deviceName)
40+
}
41+
42+
override fun initSequence(): List<ByteArray> = active.initSequence()
43+
override fun pollRealtime(): ByteArray = active.pollRealtime()
44+
override fun pollSettings(): ByteArray = active.pollSettings()
45+
46+
override fun horn(): ByteArray? = active.horn()
47+
override fun setLight(on: Boolean): ByteArray? = active.setLight(on)
48+
override fun setMaxSpeed(tiltbackKmh: Float, alarmKmh: Float): ByteArray? =
49+
active.setMaxSpeed(tiltbackKmh, alarmKmh)
50+
override fun setMaxSpeedCommit(tiltbackKmh: Float): ByteArray? =
51+
active.setMaxSpeedCommit(tiltbackKmh)
52+
override fun setAlarmSpeedCommit(alarmKmh: Float): ByteArray? =
53+
active.setAlarmSpeedCommit(alarmKmh)
54+
55+
override fun setVolume(percent: Int): ByteArray? = active.setVolume(percent)
56+
override fun setDRL(on: Boolean): ByteArray? = active.setDRL(on)
57+
override fun setLock(locked: Boolean): ByteArray? = active.setLock(locked)
58+
59+
override fun requestAuthKey(): ByteArray? = active.requestAuthKey()
60+
override fun verifyAuth(encryptedKey: ByteArray): ByteArray? = active.verifyAuth(encryptedKey)
61+
62+
override fun onRawNotification(rawBytes: ByteArray): List<DecodeResult> =
63+
active.onRawNotification(rawBytes)
64+
65+
override fun onDisconnect() {
66+
active.onDisconnect()
67+
// Reset the dispatch back to the verified default so the next
68+
// connect attempt starts from a clean state if the user picks a
69+
// different wheel.
70+
active = inmotion
71+
}
72+
73+
private fun pickAdapter(deviceName: String?): WheelAdapter {
74+
if (deviceName.isNullOrBlank()) return inmotion
75+
val n = deviceName.lowercase()
76+
return when {
77+
// KingSong: "KS-…", "S22 …", "S20 …", etc.
78+
n.startsWith("ks-") || n.startsWith("ks ") ||
79+
n.startsWith("kingsong") ||
80+
Regex("^s(?:1[6-9]|2[02])(?:\\b|[-_ ])").containsMatchIn(n) ||
81+
n.startsWith("f18") || n.startsWith("f22") -> kingsong
82+
83+
// Veteran: explicit names. Veteran wheels sometimes also
84+
// advertise as "GotWay_*" with the same firmware family;
85+
// when that happens we'll catch them post-connect by
86+
// sniffing the `DC 5A 5C` magic in the future router. For
87+
// the BLE-name pre-select we only route Veteran when the
88+
// model name is unambiguous.
89+
"sherman" in n || "patton" in n || "abrams" in n ||
90+
Regex("\\blynx\\b").containsMatchIn(n) -> veteran
91+
92+
// Begode/Gotway. "GotWay_*" / "Begode_*" / model-specific
93+
// prefixes ("RS_*", "Master_*", "EX_*", "MSP_*", etc.).
94+
n.startsWith("gotway") || n.startsWith("begode") ||
95+
n.startsWith("master") || n.startsWith("rs_") || n.startsWith("rs-") ||
96+
n.startsWith("ex_") || n.startsWith("ex.") || n.startsWith("ex2") ||
97+
n.startsWith("msp") || n.startsWith("msx") ||
98+
n.startsWith("mten") || n.startsWith("mcm5") ||
99+
n.startsWith("hero") || n.startsWith("t3") || n.startsWith("t4") -> begode
100+
101+
// InMotion V2 default (V14 "Adventure-*", P6 "P6-*", and
102+
// every "InMotion*" / "V*" name handled inside that adapter).
103+
else -> inmotion
104+
}
105+
}
106+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.eried.eucplanet.ble
2+
3+
import javax.inject.Inject
4+
import javax.inject.Singleton
5+
6+
/**
7+
* KingSong wheel adapter — stub. Recognises KS-* / S22 / S20 / S18 family
8+
* wheels on the HM-10 (0xFFE0 / 0xFFE1) BLE profile and routes their
9+
* telemetry through the same `WheelAdapter` interface that drives V14 and P6.
10+
*
11+
* Wire format (per docs/protocols/kingsong.md):
12+
* - Fixed 20-byte frames `AA 55 ... type 14 5A 5A`, no CRC, mostly u16 LE.
13+
* - Inbound packet types include live `0xA9`, trip `0xB9`, name `0xBB`,
14+
* speed-limit `0xF6`, BMS pages `0xF1`/`0xF2`, etc.
15+
* - Outbound commands: beep `0x88`, light `0x73`, pedal mode `0x87`,
16+
* query `0x98`.
17+
*
18+
* This stub returns null/Unknown for every method. Telemetry parsing,
19+
* command building and the per-packet-type dispatcher will land in
20+
* follow-up commits — kept separate so reviewers can verify the
21+
* scaffolding (scan filter, model registry, capability gating) in
22+
* isolation before the wire-format work goes in.
23+
*
24+
* Protocol research credit: WheelLog (Ilya Shkolnik and contributors,
25+
* https://github.com/Wheellog/wheellog.android — GPLv3, used as a protocol
26+
* reference; the implementation here is original).
27+
*/
28+
@Singleton
29+
class KingsongAdapter @Inject constructor() : WheelAdapter {
30+
override val familyId = "kingsong"
31+
override val capabilities = WheelCapabilities.KINGSONG
32+
33+
@Volatile private var detectedModel: KingsongModel? = null
34+
35+
override fun bleProfile(): BleProfile = BleProfile.HM10
36+
37+
override fun notifyConnectingTo(deviceName: String?) {
38+
detectedModel = deviceName?.let { KingsongModel.fromReportedName(it) }
39+
}
40+
41+
override fun initSequence(): List<ByteArray> = emptyList()
42+
override fun pollRealtime(): ByteArray = ByteArray(0)
43+
override fun pollSettings(): ByteArray = ByteArray(0)
44+
45+
override fun horn(): ByteArray? = null
46+
override fun setLight(on: Boolean): ByteArray? = null
47+
override fun setMaxSpeed(tiltbackKmh: Float, alarmKmh: Float): ByteArray? = null
48+
override fun setVolume(percent: Int): ByteArray? = null
49+
override fun setDRL(on: Boolean): ByteArray? = null
50+
override fun setLock(locked: Boolean): ByteArray? = null
51+
52+
override fun requestAuthKey(): ByteArray? = null
53+
override fun verifyAuth(encryptedKey: ByteArray): ByteArray? = null
54+
55+
override fun onRawNotification(rawBytes: ByteArray): List<DecodeResult> = emptyList()
56+
57+
override fun onDisconnect() {
58+
detectedModel = null
59+
}
60+
}

0 commit comments

Comments
 (0)