Skip to content

Commit d362c15

Browse files
committed
Merge more-wheels-support into p6-multiwheel
Brings the KingSong / Veteran / Begode / Ninebot / InMotion V1 adapters plus the audit fixes, version bump to 0.4.0-preview1, credits work, and multi-wheel BleScanner / CompositeWheelAdapter wiring into the p6-fixes branch (which already includes the Watch on-screen buttons, Service Mode Inspect tab + Attach + watch_info path, locale work, and P6 protocol research). Both sides cleanly auto-merged; no conflicts. # Conflicts: # BRANCH.md # app/build.gradle.kts # app/src/main/res/values/strings.xml # wear/build.gradle.kts
2 parents 7bd8ade + 8e31bf2 commit d362c15

35 files changed

Lines changed: 6604 additions & 367 deletions

BRANCH.md

Lines changed: 44 additions & 344 deletions
Large diffs are not rendered by default.

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ android {
2727
applicationId = "com.eried.eucplanet"
2828
minSdk = 29
2929
targetSdk = 35
30-
versionCode = 27
31-
versionName = "0.3.2-p6preview18"
30+
versionCode = 28
31+
versionName = "0.4.0-preview2"
3232

3333
val buildStamp = SimpleDateFormat("yyMMdd.HHmm")
3434
.apply { timeZone = TimeZone.getTimeZone("UTC") }
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package com.eried.eucplanet.ble
2+
3+
import javax.inject.Inject
4+
import javax.inject.Singleton
5+
6+
/**
7+
* Begode/Gotway wheel adapter — Master / RS / EX / T4 / MSP / Hero / Mten /
8+
* MSX / MCM5 family on the HM-10 (0xFFE0 / 0xFFE1) BLE profile.
9+
*
10+
* Wire format (per docs/protocols/begode.md):
11+
* - 24-byte BIG-ENDIAN frames `55 AA <16-byte payload> <tag> <subidx> 5A 5A 5A 5A`.
12+
* - Tag at offset 18 disambiguates `0x00` Live A, `0x01..0x03` BMS, `0x04`
13+
* Live B, `0x07` extras, `0xFF` SmirnoV PID.
14+
* - Voltage scaling depends on per-model nominal voltage class — see
15+
* [BegodeParser.voltageRatioFor].
16+
*
17+
* Outbound commands are short ASCII strings written WITHOUT response — see
18+
* [BegodeCommands]. Begode pushes telemetry unsolicited; init / poll loops
19+
* are empty.
20+
*
21+
* Protocol research credit: WheelLog (Ilya Shkolnik and contributors,
22+
* https://github.com/Wheellog/wheellog.android — GPLv3, used as a protocol
23+
* reference; the implementation here is original).
24+
*/
25+
@Singleton
26+
class BegodeAdapter @Inject constructor() : WheelAdapter {
27+
override val familyId = "begode"
28+
override val capabilities = WheelCapabilities.BEGODE
29+
30+
@Volatile private var detectedModel: BegodeModel? = null
31+
32+
/**
33+
* Tracks the wheel's current light state across off/on/strobe so the
34+
* boolean [setLight] toggle can rotate predictably. Begode is the only
35+
* brand with a 3-state light (spec 6.5); we collapse strobe to "on" for
36+
* the on/off API and skip the strobe state in the cycle.
37+
*/
38+
@Volatile private var lightOn: Boolean = false
39+
40+
private val parser = BegodeParser()
41+
42+
override fun bleProfile(): BleProfile = BleProfile.HM10
43+
44+
override fun notifyConnectingTo(deviceName: String?) {
45+
detectedModel = deviceName?.let { BegodeModel.fromReportedName(it) }
46+
}
47+
48+
// Begode wheels stream telemetry unsolicited — no init handshake, no poll
49+
// loop. Spec 5: settings arrive embedded in 0x04 frames at all times.
50+
override fun initSequence(): List<ByteArray> = emptyList()
51+
override fun pollRealtime(): ByteArray = ByteArray(0)
52+
override fun pollSettings(): ByteArray = ByteArray(0)
53+
54+
override fun horn(): ByteArray = BegodeCommands.horn()
55+
56+
/**
57+
* Begode lights are 3-state (off / on / strobe). The shared adapter API
58+
* is on/off only, so we map true→on (`Q`) and false→off (`E`) and skip
59+
* the strobe state. Strobe is reachable only via a future dedicated
60+
* "cycle light" UI affordance.
61+
*/
62+
override fun setLight(on: Boolean): ByteArray {
63+
lightOn = on
64+
return if (on) BegodeCommands.lightOn() else BegodeCommands.lightOff()
65+
}
66+
67+
/**
68+
* Begode max-speed is a 4-byte W/Y/HL/b sequence; we only return the
69+
* first byte here so the existing single-write [WheelAdapter] contract
70+
* holds. The follow-up bytes will move into a dedicated
71+
* paced-write extension once the connection layer grows one — for now,
72+
* the wheel just won't latch the new max-speed without the trailing
73+
* bytes, which fails safely (no setting change).
74+
*
75+
* `alarmKmh` is ignored: Begode treats `wheelMaxSpeed` as tiltback
76+
* threshold, with no separate alarm setter on stock FW (open question
77+
* 9 in spec).
78+
*/
79+
override fun setMaxSpeed(tiltbackKmh: Float, alarmKmh: Float): ByteArray? {
80+
// Returning only the first packet keeps the contract intact; the rest
81+
// of the W-prefix sequence is built but not yet plumbed through.
82+
// Conservative: don't half-send a control sequence to the wheel.
83+
return null
84+
}
85+
86+
override fun setVolume(percent: Int): ByteArray? = null
87+
88+
// Begode has no software lock and no native DRL control. Spec 6.0 / 8.0.
89+
override fun setDRL(on: Boolean): ByteArray? = null
90+
override fun setLock(locked: Boolean): ByteArray? = null
91+
override fun requestAuthKey(): ByteArray? = null
92+
override fun verifyAuth(encryptedKey: ByteArray): ByteArray? = null
93+
94+
override fun onRawNotification(rawBytes: ByteArray): List<DecodeResult> {
95+
return parser.feed(rawBytes, detectedModel)
96+
}
97+
98+
override fun onDisconnect() {
99+
parser.reset()
100+
detectedModel = null
101+
lightOn = false
102+
}
103+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package com.eried.eucplanet.ble
2+
3+
/**
4+
* Outbound command builders for Begode/Gotway wheels.
5+
*
6+
* Begode has no envelope: every command is a 1..3 byte ASCII string written
7+
* with WRITE_NO_RESPONSE to the FFE1 characteristic, no checksum, no ack.
8+
* Confirmation only arrives via the next 0x04 (Live B) telemetry frame.
9+
*
10+
* Spec: docs/protocols/begode.md sections 6.1, 6.2. Protocol research credit:
11+
* WheelLog (Ilya Shkolnik and contributors, GPLv3) — used as a protocol
12+
* reference; the implementation here is original.
13+
*/
14+
object BegodeCommands {
15+
16+
// Single-byte ASCII commands (spec 6.1).
17+
fun horn(): ByteArray = byteArrayOf('b'.code.toByte())
18+
19+
// Spec uses E/Q/T for off/on/strobe; the user-facing ticket calls the
20+
// toggle "light cycle" which historically maps to a single byte 'l' on
21+
// some firmwares. Stock Begode uses the explicit E/Q/T variants — those
22+
// are what we send so all FW revisions react.
23+
fun lightOff(): ByteArray = byteArrayOf('E'.code.toByte())
24+
fun lightOn(): ByteArray = byteArrayOf('Q'.code.toByte())
25+
fun lightStrobe(): ByteArray = byteArrayOf('T'.code.toByte())
26+
27+
fun rollAngleLow(): ByteArray = byteArrayOf('>'.code.toByte())
28+
fun rollAngleMedium(): ByteArray = byteArrayOf('='.code.toByte())
29+
fun rollAngleHigh(): ByteArray = byteArrayOf('<'.code.toByte())
30+
31+
fun pedalsHard(): ByteArray = byteArrayOf('h'.code.toByte())
32+
fun pedalsMedium(): ByteArray = byteArrayOf('f'.code.toByte())
33+
fun pedalsSoft(): ByteArray = byteArrayOf('s'.code.toByte())
34+
35+
fun unitsKm(): ByteArray = byteArrayOf('g'.code.toByte())
36+
fun unitsMiles(): ByteArray = byteArrayOf('m'.code.toByte())
37+
38+
// Calibration is a two-step dance; the adapter is responsible for the
39+
// ~300 ms gap between them per spec 6.1. We just expose both bytes.
40+
fun calibrationStart(): ByteArray = byteArrayOf('c'.code.toByte())
41+
fun calibrationConfirm(): ByteArray = byteArrayOf('y'.code.toByte())
42+
43+
fun powerOff(): ByteArray = byteArrayOf('V'.code.toByte())
44+
45+
fun beepTest(): ByteArray = byteArrayOf('b'.code.toByte())
46+
47+
fun disableMaxSpeed(): ByteArray = byteArrayOf('"'.code.toByte())
48+
49+
/**
50+
* Set max speed via the W-prefix sub-menu. Spec 6.2: send `W`, then `Y`,
51+
* then two ASCII digits encoding tens/units, then `b` to confirm. The
52+
* adapter must space the writes ~100/200 ms apart; we return the four
53+
* bytes in dispatch order and let the writer pace them.
54+
*
55+
* Speed clamped to 0..99 km/h since the wire format is two ASCII digits.
56+
*/
57+
fun setMaxSpeed(kmh: Int): List<ByteArray> {
58+
val clamped = kmh.coerceIn(0, 99)
59+
val high = ((clamped / 10) + 0x30).toByte()
60+
val low = ((clamped % 10) + 0x30).toByte()
61+
return listOf(
62+
byteArrayOf('W'.code.toByte()),
63+
byteArrayOf('Y'.code.toByte()),
64+
byteArrayOf(high, low),
65+
byteArrayOf('b'.code.toByte())
66+
)
67+
}
68+
69+
/**
70+
* Set beeper volume 1..9 via the W-prefix sub-menu. Spec 6.2.
71+
* Returns null for out-of-range values so the caller can no-op cleanly.
72+
*/
73+
fun setBeeperVolume(level: Int): List<ByteArray>? {
74+
if (level !in 1..9) return null
75+
return listOf(
76+
byteArrayOf('W'.code.toByte()),
77+
byteArrayOf('B'.code.toByte()),
78+
byteArrayOf((level + 0x30).toByte())
79+
)
80+
}
81+
82+
/** Set LED mode 0..9 via the W-prefix sub-menu. Spec 6.2. */
83+
fun setLedMode(mode: Int): List<ByteArray>? {
84+
if (mode !in 0..9) return null
85+
return listOf(
86+
byteArrayOf('W'.code.toByte()),
87+
byteArrayOf('M'.code.toByte()),
88+
byteArrayOf((mode + 0x30).toByte())
89+
)
90+
}
91+
92+
/** Request firmware-banner ASCII reply (`GW`/`JN`/`CF`/`BF`...). Spec 6.1. */
93+
fun queryFirmware(): ByteArray = byteArrayOf('V'.code.toByte())
94+
95+
/** Request model-name ASCII reply (`NAME ...`). Spec 6.1. */
96+
fun queryModelName(): ByteArray = byteArrayOf('N'.code.toByte())
97+
}
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+
}

0 commit comments

Comments
 (0)